skills/api-rest/SKILL.md
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.
npx skillsauth add aussiegingersnap/cursor-skills api-restInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Conventions and patterns for building REST APIs in Next.js App Router with type-safe validation and consistent error handling.
Resources are always plural:
| Resource | Endpoint |
|----------|----------|
| Project | /api/projects |
| User | /api/users |
| Task | /api/tasks |
| 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 |
/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
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
// 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": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
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);
}
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),
});
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 });
}
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();
}
}
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();
}
}
GET /api/projects?status=active&priority=high
const filterSchema = z.object({
status: z.enum(['active', 'archived']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
});
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'),
});
GET /api/projects?search=alpha
// In query
if (search) {
conditions.push(
or(
ilike(project.name, `%${search}%`),
ilike(project.description, `%${search}%`)
)
);
}
GET /api/projects?page=2&pageSize=20
Response includes meta:
{
"data": [...],
"meta": {
"total": 42,
"page": 2,
"pageSize": 20
}
}
| 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 |
/projects not /project)/getProjects ❌)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),
});
// ...
}
tools
# Versioning Skill Semantic versioning automation based on conventional commits. Automatically manages version bumps, changelogs, and git tags using `standard-version`. ## When to Use - Before releasing a new version - When preparing a deployment - To generate/update CHANGELOG.md - When the user asks about version management - Setting up versioning for a new project ## Prerequisites - Conventional commits enforced (recommended: lefthook) - Node.js project with package.json ## Setup (One-Ti
tools
Theme generation with tweakcn for shadcn/ui and Magic UI animations. Use when setting up project themes, customizing color schemes, adding dark mode, or integrating animated components.
tools
shadcn/studio component library with MCP integration, theme generation, and block patterns. This skill should be used when building UI with shadcn components, selecting dashboard layouts, or generating landing pages. Canonical source for all shadcn-based work.
development
Enforce a precise, minimal design system inspired by Linear, Notion, and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.