Build or update the BlueBubbles external channel plugin for Moltbot (extension package, REST...
npx skills add aussiegingersnap/cursor-skills --skill "api-rest"
Install specific skill from multi-skill repository
# Description
REST API conventions for Next.js App Router with Zod validation and standardized error handling. This skill should be used when creating API routes, implementing CRUD operations, or establishing API patterns for a project.
# SKILL.md
name: api-rest
description: REST API conventions for Next.js App Router with Zod validation and standardized error handling. This skill should be used when creating API routes, implementing CRUD operations, or establishing API patterns for a project.
REST API Skill
Conventions and patterns for building REST APIs in Next.js App Router with type-safe validation and consistent error handling.
When to Use This Skill
- Creating new API routes
- Implementing CRUD operations
- Setting up validation with Zod
- Establishing error handling patterns
- Designing resource endpoints
Core Conventions
Resource Naming
Resources are always plural:
| Resource | Endpoint |
|---|---|
| Project | /api/projects |
| User | /api/users |
| Task | /api/tasks |
HTTP Methods
| Method | Purpose | Example |
|---|---|---|
| GET | Read | GET /api/projects |
| POST | Create | POST /api/projects |
| PATCH | Partial update | PATCH /api/projects/:id |
| PUT | Full replace | PUT /api/projects/:id |
| DELETE | Remove | DELETE /api/projects/:id |
URL Structure
/api/{resource} # Collection
/api/{resource}/{id} # Single item
/api/{resource}/{id}/{sub-resource} # Nested resource
Examples:
GET /api/projects # List all projects
POST /api/projects # Create project
GET /api/projects/123 # Get project 123
PATCH /api/projects/123 # Update project 123
DELETE /api/projects/123 # Delete project 123
GET /api/projects/123/tasks # List tasks for project 123
Directory Structure
src/app/api/
βββ projects/
β βββ route.ts # GET (list), POST (create)
β βββ [id]/
β βββ route.ts # GET, PATCH, DELETE
β βββ tasks/
β βββ route.ts # Nested resource
βββ users/
β βββ route.ts
β βββ [id]/
β βββ route.ts
βββ _lib/
βββ errors.ts # Error utilities
βββ validation.ts # Zod helpers
βββ response.ts # Response helpers
Response Format
Success Responses
// Single resource
{
"data": {
"id": "123",
"name": "Project Alpha",
"createdAt": "2025-01-15T10:30:00Z"
}
}
// Collection
{
"data": [
{ "id": "123", "name": "Project Alpha" },
{ "id": "124", "name": "Project Beta" }
],
"meta": {
"total": 42,
"page": 1,
"pageSize": 20
}
}
// Empty success (204 No Content for DELETE)
// No body
Error Responses
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
Setup
Error Utilities
Create src/app/api/_lib/errors.ts:
import { NextResponse } from 'next/server';
import { ZodError } from 'zod';
export type ApiErrorCode =
| 'VALIDATION_ERROR'
| 'NOT_FOUND'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'CONFLICT'
| 'INTERNAL_ERROR';
interface ApiError {
code: ApiErrorCode;
message: string;
details?: unknown;
}
export function errorResponse(
code: ApiErrorCode,
message: string,
status: number,
details?: unknown
) {
const error: ApiError = { code, message };
if (details) error.details = details;
return NextResponse.json({ error }, { status });
}
export function validationError(error: ZodError) {
const details = error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
return errorResponse('VALIDATION_ERROR', 'Invalid request', 400, details);
}
export function notFound(resource: string) {
return errorResponse('NOT_FOUND', `${resource} not found`, 404);
}
export function unauthorized(message = 'Unauthorized') {
return errorResponse('UNAUTHORIZED', message, 401);
}
export function forbidden(message = 'Forbidden') {
return errorResponse('FORBIDDEN', message, 403);
}
export function conflict(message: string) {
return errorResponse('CONFLICT', message, 409);
}
export function internalError(message = 'Internal server error') {
return errorResponse('INTERNAL_ERROR', message, 500);
}
Validation Helpers
Create src/app/api/_lib/validation.ts:
import { z, ZodSchema, ZodError } from 'zod';
import { NextRequest } from 'next/server';
export async function parseBody<T>(
request: NextRequest,
schema: ZodSchema<T>
): Promise<{ data: T; error: null } | { data: null; error: ZodError }> {
try {
const body = await request.json();
const data = schema.parse(body);
return { data, error: null };
} catch (error) {
if (error instanceof ZodError) {
return { data: null, error };
}
throw error;
}
}
export function parseQuery<T>(
request: NextRequest,
schema: ZodSchema<T>
): { data: T; error: null } | { data: null; error: ZodError } {
try {
const params = Object.fromEntries(request.nextUrl.searchParams.entries());
const data = schema.parse(params);
return { data, error: null };
} catch (error) {
if (error instanceof ZodError) {
return { data: null, error };
}
throw error;
}
}
// Common schemas
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(20),
});
export const idParamSchema = z.object({
id: z.string().min(1),
});
Response Helpers
Create src/app/api/_lib/response.ts:
import { NextResponse } from 'next/server';
export function json<T>(data: T, status = 200) {
return NextResponse.json({ data }, { status });
}
export function jsonList<T>(
data: T[],
meta: { total: number; page: number; pageSize: number }
) {
return NextResponse.json({ data, meta });
}
export function created<T>(data: T) {
return NextResponse.json({ data }, { status: 201 });
}
export function noContent() {
return new NextResponse(null, { status: 204 });
}
Route Implementations
Collection Route (List + Create)
Create src/app/api/projects/route.ts:
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { db, project } from '@/lib/db';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { eq, desc, sql } from 'drizzle-orm';
import { json, jsonList, created } from '../_lib/response';
import { parseBody, parseQuery, paginationSchema } from '../_lib/validation';
import { validationError, unauthorized, internalError } from '../_lib/errors';
// Query params schema
const listQuerySchema = paginationSchema.extend({
status: z.enum(['active', 'archived']).optional(),
search: z.string().optional(),
});
// Create body schema
const createSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
// GET /api/projects
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return unauthorized();
const { data: query, error } = parseQuery(request, listQuerySchema);
if (error) return validationError(error);
const { page, pageSize, status, search } = query;
const offset = (page - 1) * pageSize;
// Build where conditions
const conditions = [eq(project.userId, session.user.id)];
if (status) conditions.push(eq(project.status, status));
// Add search if needed
const [items, countResult] = await Promise.all([
db.query.project.findMany({
where: and(...conditions),
orderBy: desc(project.createdAt),
limit: pageSize,
offset,
}),
db.select({ count: sql<number>`count(*)` })
.from(project)
.where(and(...conditions)),
]);
return jsonList(items, {
total: Number(countResult[0]?.count ?? 0),
page,
pageSize,
});
} catch (error) {
console.error('GET /api/projects error:', error);
return internalError();
}
}
// POST /api/projects
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return unauthorized();
const { data: body, error } = await parseBody(request, createSchema);
if (error) return validationError(error);
const [newProject] = await db.insert(project).values({
...body,
userId: session.user.id,
}).returning();
return created(newProject);
} catch (error) {
console.error('POST /api/projects error:', error);
return internalError();
}
}
Item Route (Get + Update + Delete)
Create src/app/api/projects/[id]/route.ts:
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { db, project } from '@/lib/db';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { eq, and } from 'drizzle-orm';
import { json, noContent } from '../../_lib/response';
import { parseBody } from '../../_lib/validation';
import { validationError, unauthorized, notFound, forbidden, internalError } from '../../_lib/errors';
interface RouteParams {
params: Promise<{ id: string }>;
}
const updateSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
status: z.enum(['active', 'archived']).optional(),
});
// GET /api/projects/:id
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return unauthorized();
const item = await db.query.project.findFirst({
where: eq(project.id, id),
});
if (!item) return notFound('Project');
if (item.userId !== session.user.id) return forbidden();
return json(item);
} catch (error) {
console.error('GET /api/projects/:id error:', error);
return internalError();
}
}
// PATCH /api/projects/:id
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return unauthorized();
const { data: body, error } = await parseBody(request, updateSchema);
if (error) return validationError(error);
// Check ownership
const existing = await db.query.project.findFirst({
where: eq(project.id, id),
});
if (!existing) return notFound('Project');
if (existing.userId !== session.user.id) return forbidden();
const [updated] = await db.update(project)
.set({ ...body, updatedAt: new Date() })
.where(eq(project.id, id))
.returning();
return json(updated);
} catch (error) {
console.error('PATCH /api/projects/:id error:', error);
return internalError();
}
}
// DELETE /api/projects/:id
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return unauthorized();
// Check ownership
const existing = await db.query.project.findFirst({
where: eq(project.id, id),
});
if (!existing) return notFound('Project');
if (existing.userId !== session.user.id) return forbidden();
await db.delete(project).where(eq(project.id, id));
return noContent();
} catch (error) {
console.error('DELETE /api/projects/:id error:', error);
return internalError();
}
}
Query Parameters
Filtering
GET /api/projects?status=active&priority=high
const filterSchema = z.object({
status: z.enum(['active', 'archived']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
});
Sorting
GET /api/projects?sort=createdAt&order=desc
const sortSchema = z.object({
sort: z.enum(['createdAt', 'name', 'updatedAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
});
Search
GET /api/projects?search=alpha
// In query
if (search) {
conditions.push(
or(
ilike(project.name, `%${search}%`),
ilike(project.description, `%${search}%`)
)
);
}
Pagination
GET /api/projects?page=2&pageSize=20
Response includes meta:
{
"data": [...],
"meta": {
"total": 42,
"page": 2,
"pageSize": 20
}
}
HTTP Status Codes
| Code | Meaning | Usage |
|---|---|---|
| 200 | OK | Successful GET, PATCH, PUT |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Validation error |
| 401 | Unauthorized | No/invalid auth |
| 403 | Forbidden | No permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate, constraint violation |
| 500 | Internal Error | Unexpected server error |
Best Practices
Do
- Use plural resource names (
/projectsnot/project) - Return consistent response shapes
- Validate all inputs with Zod
- Use appropriate HTTP methods and status codes
- Include pagination for list endpoints
- Log errors server-side
Don't
- Don't use verbs in URLs (
/getProjectsβ) - Don't return different shapes for same endpoint
- Don't expose internal error details to clients
- Don't skip authentication checks
- Don't use GET for mutations
Type Safety
Export types from your schemas:
// In a shared types file
import { z } from 'zod';
export const createProjectSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
Use in frontend:
import type { CreateProjectInput } from '@/lib/api-types';
async function createProject(data: CreateProjectInput) {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
// ...
}
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.