CORE CONCEPTS

API Routes

Introduced in v2.0.0-beta, _api is a file-based convention for server-only HTTP endpoints: JSON APIs, webhooks, form actions that aren't a full page navigation. It mirrors app/_routes/ exactly in its segment rules ([param], [_param], (group), _optional), but instead of producing a React page tree, it resolves to a real HTTP router with no React/SSR machinery involved at all.

All API route definitions live inside app/_api/.

Directory Structure Example

app/ ├── _api/ │ ├── middleware.ts # applies to every route under _api │ ├── health.route.ts # → GET /api/health │ └── users/ │ ├── middleware.ts # applies to every route under /api/users │ ├── index.route.ts # → GET, POST /api/users │ └── [id].route.ts # → GET, DELETE /api/users/:id └── _routes/ # page routes, unaffected

No wrapper file is required. Unlike app/app.router.ts for pages, _api/'s existence alone is enough for Rasengan.js to detect it and wire everything up.

Route Handlers

Every file ending with .route.ts can export one function per HTTP method it handles: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Each handler receives a Context and must return a Response.

src/app/_api/users/[id].route.ts
import { json, NotFoundError } from 'rasengan/server'; import type { Context } from 'rasengan/server'; export async function GET(ctx: Context) { const user = await db.users.findById(ctx.params.id); if (!user) { throw new NotFoundError(`User ${ctx.params.id} not found`); } return json(user); } export async function DELETE(ctx: Context) { await db.users.delete(ctx.params.id); return new Response(null, { status: 204 }); }

ctx.params gives you access to dynamic segments ([id] maps to ctx.params.id), exactly like page routes. index.route.ts binds to its own folder's path: src/app/_api/users/index.route.ts serves /api/users.

Middleware

A middleware.ts file in any _api/ folder scopes middleware to that folder's routes and every folder nested beneath it, the _api equivalent of a page layout.tsx.

src/app/_api/users/middleware.ts
import type { Middleware } from 'rasengan/server'; const requireApiKey: Middleware = async (ctx, next) => { if (ctx.request.headers.get('x-api-key') !== 'demo') { return new Response('Missing or invalid x-api-key header', { status: 401, }); } return next(); }; export default [requireApiKey];

Middleware composes through nesting. A request to /api/users/42 runs the root _api/middleware.ts first, then _api/users/middleware.ts, then the matching route handler.

Dynamic Segments & Route Groups

Same rules as file-based page routing:

ConventionExampleMatches
Dynamic segment_api/users/[id].route.ts/api/users/:id
Optional segment_api/[_locale]/health.route.ts/api/health, /api/en/health
Route group_api/(webhooks)/stripe.route.ts/api/stripe (the (webhooks) folder is ignored in the URL)

Error Handling

Thrown errors are caught and formatted as JSON automatically, you don't need a try/catch in every handler.

throw new NotFoundError('User not found'); // → 404 { "error": { "message": "User not found", "status": 404 } } throw new Error('boom'); // → 500 { "error": { "message": "Internal Server Error", "status": 500 } } in production // → 500 { "error": { "message": "boom", "status": 500 } } outside production

NotFoundError and the rest of Futon's HttpError hierarchy report their own status/message directly. Anything else thrown defaults to 500. In production the response message is generic ("Internal Server Error") to avoid leaking internals; outside production you get the real error message for debugging.

A path under your API prefix that doesn't match any route also responds in JSON:

{ "error": { "message": "Not Found", "status": 404 } }

Changing the Prefix

By default, _api/ routes are mounted under /api. Configure a different prefix via api.prefix in rasengan.config.js, see the rasengan.config.js reference.

Build Requirement

File-based API routes need a live server to run on. If src/app/_api/ exists, your build must have ssr: true with prerender disabled: that's the only build shape that produces the server bundle API routes are compiled into. Any other configuration fails the build with an explicit error, rather than silently shipping API routes that can never be reached.

rasengan.config.js
import { defineConfig } from 'rasengan'; export default defineConfig({ ssr: true, // required if src/app/_api/ exists // prerender must stay disabled });

rasengan dev is never affected by this restriction. The dev server always has a live process, so _api/ works regardless of ssr/prerender while developing.

Persisting Data on Serverless Targets

A local file works fine in two cases: rasengan dev, and a persistent host, meaning a plain Node.js server (or Bun, see @rasenganjs/serve) running on a VM, container, or any platform where the process (and its filesystem) stays alive between requests.

For Vercel/Netlify, reach for a database that lives outside the function itself. If you specifically want to keep using SQLite's dialect and mental model, Turso (via @libsql/client) is a natural fit: it speaks the same SQL, and its file: URL scheme means the exact same client code also works locally against a plain file, so you don't need two different data layers for dev and production.

src/app/_api/users/db.ts
import { createClient } from '@libsql/client'; import path from 'node:path'; const db = createClient({ url: process.env.TURSO_DATABASE_URL ?? `file:${path.join(process.cwd(), 'app.sqlite')}`, authToken: process.env.TURSO_AUTH_TOKEN, });

Only production needs the two environment variables (TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, from turso db create and turso db tokens create) set on Vercel/Netlify. Locally, with neither set, the client just falls back to a local file.

Deploying

API routes work the same way on every deployment target Rasengan.js supports: Vercel, Netlify, and a plain Node.js server.

File-Based Routing
Linking and Navigation