| name | api-route-conventions |
| description | Expert knowledge on Next.js API route patterns, authentication with getAuthOrTest, error handling, response formats, rate limiting, and webhook verification. Use this skill when user asks about "create api endpoint", "api route", "error handling", "authentication", "next.js api", or "route handler". |
| allowed-tools | Read, Write, Edit, Grep, Glob |
API Route Conventions Expert
You are an expert in Next.js API route conventions for this platform. This skill provides templates, patterns, and best practices for creating consistent, secure API endpoints.
When To Use This Skill
This skill activates when users:
- Need to create a new API endpoint
- Debug authentication issues in routes
- Implement error handling patterns
- Work with webhook endpoints (Stripe, Clerk, QStash)
- Need consistent response formats
- Implement rate limiting or validation
- Convert old API routes to new patterns
Core Knowledge
Standard API Route Template
Location: /app/api/[feature]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAuthOrTest } from '@/lib/auth/get-auth-or-test';
import { logger, LogCategory } from '@/lib/logging';
import { db } from '@/lib/db';
export async function GET(req: NextRequest) {
try {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const searchParams = req.nextUrl.searchParams;
const param = searchParams.get('param');
if (!param) {
return NextResponse.json(
{ error: 'Missing required parameter: param' },
{ status: 400 }
);
}
const data = await db.query.someTable.findMany({
where: eq(someTable.userId, auth.userId)
});
return NextResponse.json({
success: true,
data,
meta: {
count: data.length,
timestamp: new Date().toISOString()
}
});
} catch (error) {
logger.error('Failed to process request', error, {
endpoint: '/api/feature',
userId: auth?.userId
}, LogCategory.API);
return NextResponse.json(
{
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
if (!body.name || !body.type) {
return NextResponse.json(
{ error: 'Missing required fields: name, type' },
{ status: 400 }
);
}
logger.info('Creating resource', {
userId: auth.userId,
name: body.name
}, LogCategory.API);
const [resource] = await db.insert(someTable)
.values({
userId: auth.userId,
name: body.name,
type: body.type
})
.returning();
return NextResponse.json({
success: true,
data: resource
}, { status: 201 });
} catch (error) {
logger.error('Failed to create resource', error, {
endpoint: '/api/feature'
}, LogCategory.API);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Authentication Patterns
Primary Auth: /lib/auth/get-auth-or-test.ts
import { getAuthOrTest } from '@/lib/auth/get-auth-or-test';
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const auth = await getAuthOrTest();
const email = auth?.sessionClaims?.email as string | undefined;
Auth Resolution Order:
- Test headers (
x-test-user-id, x-test-email)
- Dev bypass header (
x-dev-auth: dev-bypass)
- Environment bypass (
ENABLE_AUTH_BYPASS=true)
- Clerk
backendAuth()
Dev Bypass Methods:
- Header-Based:
curl http://localhost:3000/api/endpoint \
-H "x-dev-auth: dev-bypass" \
-H "x-dev-user-id: user_xxx"
- Environment-Based:
ENABLE_AUTH_BYPASS=true
AUTH_BYPASS_USER_ID=user_xxx
- Test Headers:
curl http://localhost:3000/api/endpoint \
-H "x-test-user-id: user_xxx" \
-H "x-test-email: test@example.com"
Response Formats
Success Response:
return NextResponse.json({
success: true,
data: result,
meta: {
count: result.length,
page: 1,
timestamp: new Date().toISOString()
}
}, { status: 200 });
Error Response:
return NextResponse.json({
error: 'Error message',
code: 'ERROR_CODE',
details: { }
}, { status: 400 });
Status Codes:
200 - Success (GET, PUT, DELETE)
201 - Created (POST)
400 - Bad Request (validation failed)
401 - Unauthorized (no auth)
403 - Forbidden (auth but no permission, e.g., plan limits)
404 - Not Found
409 - Conflict (duplicate resource)
429 - Too Many Requests (rate limit)
500 - Internal Server Error
Webhook Pattern
Stripe Webhook: /app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { StripeService } from '@/lib/stripe/stripe-service';
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature provided' },
{ status: 400 }
);
}
const event = StripeService.validateWebhookSignature(body, signature);
switch (event.type) {
case 'customer.subscription.created':
await handleSubscriptionCreated(event.data.object);
break;
default:
logger.(, {
: event.
});
}
.({ : });
} (error) {
logger.(, error);
.(
{ : },
{ : }
);
}
}
QStash Webhook: /app/api/qstash/*/route.ts
import { Receiver } from '@upstash/qstash';
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('Upstash-Signature');
if (shouldVerifySignature()) {
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 401 });
}
const valid = await receiver.verify({
signature,
body: rawBody,
url: callbackUrl
});
if (!valid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: });
}
}
}
Validation Patterns
Zod Schema Validation:
import { z } from 'zod';
const CreateCampaignSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
searchType: z.enum(['instagram-reels', 'tiktok-keyword', 'youtube-keyword']),
keywords: z.array(z.string()).min(1).max(10)
});
export async function POST(req: NextRequest) {
const body = await req.json();
const validation = CreateCampaignSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json({
error: 'Validation failed',
details: validation.error.issues
}, { status: });
}
data = validation.;
}
Manual Validation:
function validateInput(data: any): { valid: boolean; error?: string } {
if (!data.name || typeof data.name !== 'string') {
return { valid: false, error: 'Invalid name' };
}
if (data.name.length > 100) {
return { valid: false, error: 'Name too long' };
}
return { valid: true };
}
const validation = validateInput(body);
if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
Plan Enforcement Integration
import { PlanEnforcementService } from '@/lib/services/plan-enforcement';
export async function POST(req: NextRequest) {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const validation = await PlanEnforcementService.validateCampaignCreation(
auth.userId
);
if (!validation.allowed) {
return NextResponse.json({
error: validation.reason,
usage: validation.usage,
upgrade_required: true
}, { status: 403 });
}
await PlanEnforcementService.trackCampaignCreated(auth.userId);
return NextResponse.({ : });
}
Common Patterns
Pattern 1: Dynamic Route with ID
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const campaignId = params.id;
const campaign = await db.query.campaigns.findFirst({
where: and(
eq(campaigns.id, campaignId),
eq(campaigns.userId, auth.userId)
)
});
if (!campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
}
return NextResponse.json({ data: campaign });
}
export async function () {
auth = ();
(!auth?.) {
.({ : }, { : });
}
campaign = db...({
: (
(campaigns., params.),
(campaigns., auth.)
)
});
(!campaign) {
.({ : }, { : });
}
db.(campaigns).((campaigns., params.));
.({ : });
}
Pattern 2: Pagination
export async function GET(req: NextRequest) {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const searchParams = req.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = (page - 1) * limit;
const [items, [{ total }]] = await Promise.all([
db.query.campaigns.findMany({
where: eq(campaigns.userId, auth.userId),
orderBy: [desc(campaigns.createdAt)],
limit,
offset
}),
db.({ : () })
.(campaigns)
.((campaigns., auth.))
]);
.({
: items,
: {
page,
limit,
total,
: .(total / limit)
}
});
}
Pattern 3: Admin-Only Endpoint
import { isAdmin } from '@/lib/auth/admin-utils';
export async function POST(req: NextRequest) {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!await isAdmin(auth.userId)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
Anti-Patterns (Avoid These)
Anti-Pattern 1: No Auth Check
export async function POST(req: NextRequest) {
const body = await req.json();
await db.insert(campaigns).values(body);
return NextResponse.json({ success: true });
}
Do this instead:
export async function POST(req: NextRequest) {
const auth = await getAuthOrTest();
if (!auth?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
Anti-Pattern 2: Exposing Internal Errors
catch (error) {
return NextResponse.json({ error: error.toString() }, { status: 500 });
}
Do this instead:
catch (error) {
logger.error('Operation failed', error, { userId: auth?.userId });
return NextResponse.json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
}, { status: 500 });
}
Anti-Pattern 3: No Input Validation
const { name, email } = await req.json();
await db.insert(users).values({ name, email });
Do this instead:
const body = await req.json();
if (!body.name || typeof body.name !== 'string' || body.name.length > 100) {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
Related Files
/lib/auth/get-auth-or-test.ts - Authentication resolver
/lib/auth/admin-utils.ts - Admin check
/lib/services/plan-enforcement.ts - Plan validation
/lib/logging/index.ts - Structured logging
/app/api/campaigns/route.ts - Example CRUD endpoint
/app/api/stripe/webhook/route.ts - Webhook pattern
/app/api/qstash/process-search/route.ts - QStash pattern
Testing API Endpoints
Test with curl:
curl -X POST http://localhost:3000/api/campaigns \
-H "x-dev-auth: dev-bypass" \
-H "x-dev-user-id: user_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"Test Campaign","searchType":"instagram-reels"}'
curl http://localhost:3000/api/campaigns \
-H "Authorization: Bearer $CLERK_SESSION_TOKEN"
Test script:
node scripts/simple-api-logger.js