API Routes
API routes are route.ts files that export named async functions for each HTTP method you want to handle. They follow the same file-based routing as pages but produce JSON (or any Response) instead of HTML.
Aroute.tscan live anywhere underapp/, not just inside anapi/subdirectory.app/webhook/route.tsmaps to/webhook,app/stripe/checkout/route.tsmaps to/stripe/checkout, and so on.
Supported Methods
Export one or more named async functions from a route.ts file:
- GET: read a resource
- POST: create a resource
- PUT: replace a resource
- PATCH: partially update a resource
- DELETE: remove a resource
If a request arrives with a method that has no matching export, WebJs returns 405 Method Not Allowed with an Allow header listing the available methods.
Handler Signature
Every handler receives two arguments:
export async function GET(
req: Request,
{ params }: { params: Record<string, string> }
): Promise<Response | object>
- req: a standard Web API
Request. Read headers, cookies, URL, query params, body. - params: an object containing dynamic route segment values (from
[slug]folder names).
Return a Response for full control over status, headers, and body. Or return a plain object (or array, number, null) and WebJs wraps it with Response.json() automatically.
Basic Example
// app/api/hello/route.ts
export async function GET(req: Request) {
return Response.json({ message: 'Hello from webjs!' });
}
export async function POST(req: Request) {
const body = await req.json();
return Response.json({ received: body });
}
Routes Outside app/api/
There is nothing special about the api/ directory. A route.ts file anywhere in the app/ tree becomes an API endpoint at the corresponding URL path:
app/
api/
users/route.ts # GET /api/users
webhook/route.ts # POST /webhook
stripe/checkout/route.ts # POST /stripe/checkout
health/route.ts # GET /health
Dynamic Params via [slug] Folders
Dynamic route segments work identically to page routes. A folder named [slug] captures that segment into params.slug:
// app/api/posts/[slug]/route.ts
type Ctx = { params: { slug: string } };
export async function GET(_req: Request, { params }: Ctx) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(post);
}
export async function DELETE(_req: Request, { params }: Ctx) {
await db.post.delete({ where: { slug: params.slug } });
return Response.json({ deleted: true });
}
Catch-all segments ([...rest]) work too, with params.rest as the full remaining path as a string:
// app/api/files/[...path]/route.ts
type Ctx = { params: { path: string } };
export async function GET(_req: Request, { params }: Ctx) {
// params.path is "images/photo.jpg" for /api/files/images/photo.jpg
const file = await readFile(join(STORAGE_DIR, params.path));
return new Response(file, {
headers: { 'content-type': 'application/octet-stream' },
});
}
Returning Objects (Auto-JSON)
If a handler returns a plain object or array instead of a Response, WebJs wraps it with Response.json():
export async function GET() {
const posts = await db.post.findMany();
return posts; // Automatically becomes Response.json(posts)
}
export async function POST(req: Request) {
const data = await req.json();
const post = await db.post.create({ data });
return post; // { id: 1, title: "Hello", createdAt: "2026-04-15T..." }
}
When you need control over status code, headers, or streaming, return a Response directly.
json() Helper: Content Negotiation
The json() helper from @webjsdev/server adds smart content negotiation. It inspects the incoming request's Accept header and responds accordingly:
- If the client sent
Accept: application/vnd.webjs+json(e.g. viarichFetch()), the response is encoded with the WebJs serializer so thatDate,Map,Set,BigInt,TypedArray,Blob,File,FormData, and reference cycles all survive the round trip. - Otherwise, the response is plain
application/json, the standard for curl, mobile apps, and third-party consumers.
// app/api/posts/route.ts
import { json } from '@webjsdev/server';
export async function GET() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
});
return json(posts);
// External client: plain JSON, createdAt is an ISO string
// richFetch client: rich types, createdAt is a real Date object
}
export async function POST(req: Request) {
const input = await req.json();
const post = await db.post.create({ data: input });
return json(post, { status: 201 });
}
The helper reads the in-flight Request from an AsyncLocalStorage context set up by the request pipeline, so you do not need to pass the request explicitly.
readBody(): Parsing Rich Request Bodies
The readBody() helper from @webjsdev/server is the inverse of json(). It parses the request body with the WebJs rich serializer when the client sent the application/vnd.webjs+json content type, and as plain JSON otherwise:
import { json, readBody } from '@webjsdev/server';
export async function POST(req: Request) {
const data = await readBody(req);
// If client sent via richFetch: data.publishAt is a real Date
// If client sent plain JSON: data.publishAt is a string
const post = await db.post.create({ data });
return json(post, { status: 201 });
}
richFetch(): Typed Client Calls
On the client side, richFetch() from webjs is a drop-in replacement for fetch() that enables the rich-type round trip:
import { richFetch } from '@webjsdev/core';
// GET with rich types
const posts = await richFetch('/api/posts');
// posts[0].createdAt is a Date object, not a string
// POST with a rich body
const newPost = await richFetch('/api/posts', {
method: 'POST',
body: { title: 'Hello', publishAt: new Date(2026, 5, 1) },
// body is automatically encoded with the rich serializer
// Content-Type is set to application/vnd.webjs+json
});
// Error handling
try {
const data = await richFetch('/api/protected');
} catch (err) {
console.log(err.status); // e.g. 401
console.log(err.body); // parsed error response body
console.log(err.message); // error message from response or status fallback
}
richFetch automatically:
- Sets
Accept: application/vnd.webjs+jsonon outgoing requests - If
bodyis a plain object (not FormData, Blob, ArrayBuffer, or string), encodes it with the WebJs serializer and sets the content type - Parses the response with the WebJs serializer when the server responds with the vendor content type, or with plain
JSON.parseotherwise - Throws an
Errorwith.statusand.bodyproperties for non-2xx responses
WebSocket: Export WS
Any route.ts can also export a WS function to handle WebSocket connections at the same URL. See the WebSockets documentation for full details.
// app/api/chat/route.ts
import type { WebSocket } from 'ws';
export function GET() {
return Response.json({ status: 'WebSocket endpoint. Connect via ws://' });
}
export function WS(ws: WebSocket, req: Request) {
ws.on('message', (data) => ws.send('echo: ' + data));
}
Per-Segment Middleware on API Routes
API routes participate in the same per-segment middleware chain as pages. A middleware.ts file in a directory applies to all routes (page and API) under that directory:
// app/api/auth/middleware.ts
import { rateLimit } from '@webjsdev/server';
// 5 requests per 10 seconds per IP on all /api/auth/* routes
export default rateLimit({ window: '10s', max: 5 });
Middleware is a function (req: Request, next: () => Promise<Response>) => Promise<Response>. It can inspect the request, short-circuit with its own response, or call next() to continue to the handler:
// app/api/admin/middleware.ts
export default async function authGuard(
req: Request,
next: () => Promise<Response>,
) {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token || !await verifyToken(token)) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return next();
}
Middleware files nest. If you have app/middleware.ts, app/api/middleware.ts, and app/api/admin/middleware.ts, a request to /api/admin/users runs all three in outermost-to-innermost order.
Rate Limiting
WebJs ships a built-in in-memory fixed-window rate limiter, shaped as a middleware:
import { rateLimit } from '@webjsdev/server';
// In a middleware.ts file:
export default rateLimit({
window: '1m', // Window duration: number (ms), or "30s", "1m", "1h"
max: 60, // Max requests per window per key
key: (req) => { // Optional: custom key function (default: client IP)
return req.headers.get('x-forwarded-for') || 'anon';
},
message: 'Slow down!', // Optional: custom error message
});
When the limit is exceeded, the response is 429 Too Many Requests with headers:
Retry-After: seconds until the window resetsX-RateLimit-Limit: the configured maxX-RateLimit-Remaining: requests left in the current windowX-RateLimit-Reset: Unix timestamp when the window resets
These rate-limit headers are also added to successful responses so clients can monitor their usage. The default key function extracts the client IP from X-Forwarded-For, CF-Connecting-IP, or X-Real-IP headers (in that order). For multi-instance deployments, use an external rate limiter (Redis, nginx, Cloudflare).
CORS on REST Endpoints
CORS in WebJs comes from the cors() middleware (from @webjsdev/server):
- Wrap a single route.ts handler in
cors(...)for per-route CORS. - Apply it in middleware.ts for blanket coverage across all routes in a segment.
Example CORS middleware for route.ts files:
// app/api/public/middleware.ts
export default async function cors(
req: Request,
next: () => Promise<Response>,
) {
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
'access-control-allow-headers': 'Content-Type, Authorization',
'access-control-max-age': '86400',
},
});
}
const resp = await next();
resp.headers.set('access-control-allow-origin', '*');
return resp;
}
Backend-Only Usage
WebJs works as a pure API framework with no pages or components. If your app/ directory contains only route.ts and middleware.ts files (no page.ts, no layout.ts), WebJs serves only API routes. No SSR, no import maps, no client JS. This is ideal for microservices, backends for mobile apps, or REST APIs. See Backend-Only Mode for a full guide.
Complete CRUD Example
Here is a full route.ts implementing GET, POST, and DELETE for a resource:
// app/api/posts/route.ts
import { json, readBody } from '@webjsdev/server';
// GET /api/posts: list all posts, with pagination
export async function GET(req: Request) {
const url = new URL(req.url);
const page = Number(url.searchParams.get('page') || '1');
const limit = 20;
const [posts, total] = await Promise.all([
db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
db.post.count(),
]);
return json({
posts,
page,
totalPages: Math.ceil(total / limit),
total,
});
}
// POST /api/posts: create a new post
export async function POST(req: Request) {
const data = await readBody(req);
if (!data.title || typeof data.title !== 'string') {
return Response.json(
{ error: 'title is required' },
{ status: 400 },
);
}
const post = await db.post.create({
data: {
title: data.title,
body: data.body || '',
slug: data.title.toLowerCase().replace(/\s+/g, '-'),
},
});
return json(post, { status: 201 });
}
// DELETE /api/posts: delete posts by IDs
export async function DELETE(req: Request) {
const { ids } = await req.json();
if (!Array.isArray(ids) || ids.length === 0) {
return Response.json(
{ error: 'ids array is required' },
{ status: 400 },
);
}
const result = await db.post.deleteMany({
where: { id: { in: ids } },
});
return json({ deleted: result.count });
}
Single-Resource Route (Dynamic Params)
// app/api/posts/[slug]/route.ts
import { json, readBody } from '@webjsdev/server';
type Ctx = { params: { slug: string } };
export async function GET(_req: Request, { params }: Ctx) {
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true },
});
if (!post) return Response.json({ error: 'Not found' }, { status: 404 });
return json(post);
}
export async function PUT(req: Request, { params }: Ctx) {
const data = await readBody(req);
const post = await db.post.update({
where: { slug: params.slug },
data: { title: data.title, body: data.body },
});
return json(post);
}
export async function DELETE(_req: Request, { params }: Ctx) {
await db.post.delete({ where: { slug: params.slug } });
return new Response(null, { status: 204 });
}
Summary
route.tsfiles export named async functions:GET,POST,PUT,PATCH,DELETE- They can live anywhere under
app/, not just inapp/api/ - Handler signature:
(req: Request, { params }) => Response | object - Dynamic params via
[slug]folder names, catch-all via[...rest] - Return a
Responsefor full control, or return a plain object for auto-JSON json()from@webjsdev/serverprovides content negotiation (plain JSON vs WebJs rich JSON)readBody()parses incoming rich-format or plain JSON based on content typerichFetch()on the client for typed API calls with rich types- Export
WSfrom the sameroute.tsfor WebSocket support - Per-segment
middleware.tsapplies to all routes underneath rateLimit()from@webjsdev/serverfor built-in rate limiting- CORS via the
cors()middleware onroute.tsfiles or inmiddleware.ts - WebJs works as a backend-only API framework when no page files are present