Project Routes
Public and user-facing endpoints for projects — list, fetch, submit, vote, comment and rate.
Why this matters
Project routes are the surface area your directory exposes to the world. Every category page, every search result, every submission form, every upvote — they all flow through these endpoints. Get them wrong and you have a directory with broken submissions, slow listing pages, and rate-limited search; get them right and you can run a 50,000-listing catalog on a $20/month Supabase plan.
This page documents what each route returns, what it expects in the body, and which permission tier triggers each error code. If you're integrating an external client (a mobile app, a Slack bot, an automated submission pipeline), this is the contract you build against.
GET /api/projects
List projects with filters and pagination.
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
status | string | live | live / pending / rejected |
category | string | — | Category slug. Filters by slug membership. |
sphere | string | — | Sphere name. Same idea. |
pricing | string | all | all / free / freemium / paid |
sort | string | newest | Any key from directoryConfig.sortOptions |
page | number | 1 | Page number |
limit | number | 40 | Items per page |
q | string | — | Search query (substring match) |
Response:
{
"data": [ /* array of projects */ ],
"meta": {
"total": 1248,
"page": 1,
"pageSize": 40,
"hasMore": true
}
}GET /api/projects/[slug]
Get a single project. Returns 404 if not live (unless the request is from the owner or an admin).
POST /api/projects
Create a new submission. Requires auth.
Submission form preview:
Body: validated by ProjectSubmissionSchema from lib/validations/schemas.ts:
{
plan: 'standard' | 'premium',
name: string,
slug: string, // auto-generated if empty
url: string,
description: string,
long_description?: string,
categories: string[], // array of category slugs
logo_url?: string,
screenshots?: string[],
pricing_type: 'free' | 'freemium' | 'paid',
// ... more fields
}Response:
- If plan is
premium, returns a Stripe Checkout URL — redirect user there - If plan is
standard, returns the created project ID directly
Rate-limited: submission tier.
PUT /api/projects/[id]
Update an existing project. Requires ownership or admin.
Body mirrors POST — partial updates supported. Status transitions (pending → live) are admin-only.
DELETE /api/projects/[id]
Delete a project. Requires ownership or admin. Soft-delete by default (sets deleted_at); admins can hard-delete via /api/admin/projects/[id].
POST /api/projects/[id]/upvote
Toggle upvote. Body: none. Requires auth.
Rate-limited: voting tier.
Response:
{ "data": { "upvoted": true, "upvote_count": 42 } }GET /api/projects/[id]/comments
List comments on a project. Paginated.
Feature-flagged: returns 404 if comments: false in features.config.ts.
POST /api/projects/[id]/comments
Add a comment. Requires auth. Rate-limited.
POST /api/projects/[id]/ratings
Add or update this user's rating. Requires auth.
Body: { "rating": 1..5 }.
Error examples
// Missing auth
{ "error": "Unauthorized" } // 401
// Tried to edit someone else's project
{ "error": "Forbidden" } // 403
// Feature disabled
{ "error": "Not found" } // 404
// Validation failed
{
"error": "Invalid input",
"details": { "name": { "_errors": ["Required"] } }
} // 400
// Rate limited
{
"error": "Too many requests",
"retryAfter": 60
} // 429A real-world walkthrough: a Slack bot that submits projects
Imagine you're cataloging design tools and want a Slack /submit-tool <url> slash command that creates a pending project automatically. The flow:
- Slack sends a slash command POST to your Vercel route (e.g.,
/api/integrations/slack/submit). - Your route fetches the URL's OG metadata, fills in
nameanddescription, and POSTs to/api/projectsusing a service-role authenticated client. - The new project lands in
pendingstatus. Your admin moderates as usual.
const project = await fetch(`${BASE_URL}/api/projects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${slackUserToken}`,
},
body: JSON.stringify({
plan: 'standard',
name: ogTitle,
url: submittedUrl,
description: ogDescription,
categories: ['design-tools'],
pricing_type: 'freemium',
}),
});The submission rate limiter fires per-IP, so high-frequency abuse from one user is bounded automatically. For programmatic clients, set X-Forwarded-For accurately or the limiter will lump them together with the calling server.
Common pitfalls
- Using
?status=to fetch your own pending submissions. Public callers only seeliveprojects. To list your own pending submissions, hit/api/me/submissionsinstead — it filters by ownership and ignores the public status filter. - Bypassing
ProjectSubmissionSchemain custom integrations. If you build a programmatic submission flow, validate against the same Zod schema your form uses. Skipping it means malformed payloads land in the database and break listing pages. - Forgetting that
premiumplan returns a Stripe URL, not a project ID. If your client doesn't redirect afterPOST /api/projects, the user pays nothing and you have no project. Always check the response shape —data.checkoutUrlfor premium,data.idfor standard. - Hot-reloading the rate-limit config without restarting. Changes to
directoryConfig.rateLimitsrequire a process restart; a hot reload leaves the in-memory limiter on the old values. This bites in dev when tuning numbers.
FAQ
How do I paginate through more than 1,000 results?
Use page=N&limit=40 until meta.hasMore === false. The default page size is 40; the maximum is 100 (higher returns 400). For exporting full data, prefer the admin's /api/admin/projects/export route which streams CSV without pagination.
Can I sort by upvote count or rating?
Yes — pass sort=upvotes or sort=rating. The available sort keys are defined in directoryConfig.sortOptions. Custom sort keys are a config change, not an API change.
Why do upvotes return 401 even when the user is signed in?
Make sure your fetch call includes credentials. From the browser, fetch('/api/...', { credentials: 'include' }) is the default for same-origin, but server-side or cross-origin calls strip cookies unless you forward them explicitly.