Skip to main content

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:

ParamTypeDefaultNotes
statusstringlivelive / pending / rejected
categorystringCategory slug. Filters by slug membership.
spherestringSphere name. Same idea.
pricingstringallall / free / freemium / paid
sortstringnewestAny key from directoryConfig.sortOptions
pagenumber1Page number
limitnumber40Items per page
qstringSearch 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
}                                            // 429

A 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:

  1. Slack sends a slash command POST to your Vercel route (e.g., /api/integrations/slack/submit).
  2. Your route fetches the URL's OG metadata, fills in name and description, and POSTs to /api/projects using a service-role authenticated client.
  3. The new project lands in pending status. Your admin moderates as usual.
app/api/integrations/slack/submit/route.ts
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 see live projects. To list your own pending submissions, hit /api/me/submissions instead — it filters by ownership and ignores the public status filter.
  • Bypassing ProjectSubmissionSchema in 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 premium plan returns a Stripe URL, not a project ID. If your client doesn't redirect after POST /api/projects, the user pays nothing and you have no project. Always check the response shape — data.checkoutUrl for premium, data.id for standard.
  • Hot-reloading the rate-limit config without restarting. Changes to directoryConfig.rateLimits require 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.