.get(), .post(), and .route() calls on a single app expression. .route() accepts Zod schemas for request, response, query, and params, giving you runtime validation and full type inference in one place.12345678910111213141516// This is an example of what NOT to do when using Spiceflow import { Spiceflow } from 'spiceflow' // DO NOT declare the app separately and add routes later export const app = new Spiceflow() // Do NOT do this! Defining routes separately will lose type safety app.get('/hello', () => { return 'Hello, World!' }) // Do NOT do this! Adding routes separately like this will lose type safety app.post('/echo', async ({ request }) => { const body = await request.json() return body })
123456789101112131415import { Spiceflow } from 'spiceflow' export const app = new Spiceflow() .get('/user', () => { // Preferred — return type is inferred by the typed fetch client return { id: 1, name: 'John', email: 'john@example.com' } }) .post('/data', async ({ request }) => { const body = await request.json() return { received: body, timestamp: new Date().toISOString(), processed: true, } })
json() helper instead of Response.json(). It works the same way at runtime but preserves the data type and status code in the type system — so the fetch client gets full type safety for each status code:1234567import { Spiceflow, json } from 'spiceflow' // Preferred — type-safe, fetch client knows this is a 404 with { message: string } throw json({ message: 'Not found' }, { status: 404 }) // Avoid — Response.json() erases the type, fetch client sees unknown throw Response.json({ message: 'Not found' }, { status: 404 })
.route() with request, response, query, and params schemas for full type safety.123456789101112131415import { z } from 'zod' import { Spiceflow } from 'spiceflow' new Spiceflow().route({ method: 'POST', path: '/users', request: z.object({ name: z.string(), email: z.string().email(), }), async handler({ request }) { const body = await request.json() // here body has type { name: string, email: string } return `Created user: ${body.name}` }, })
request.json() to parse the body as JSON. Spiceflow does not parse the body automatically — there is no body field in the route argument. Instead you call either request.json() or request.formData() to get the body and validate it at the same time. The returned data will have the correct schema type instead of any.request object in every handler and middleware is a SpiceflowRequest, which extends the standard Web Request. On top of the standard API, it adds:request.parsedUrl — a lazily cached URL object, so you don't need to write new URL(request.url) yourself. Accessing .pathname, .searchParams, etc. is one property access awayrequest.json() / request.formData() — parse and validate the body against the route schema in one step, returning typed data instead of anyrequest.originalUrl — the raw transport URL before Spiceflow normalizes .rsc pathnames123456789101112131415161718import { z } from 'zod' import { Spiceflow } from 'spiceflow' new Spiceflow().route({ method: 'GET', path: '/users/:id', request: z.object({ name: z.string(), }), response: z.object({ id: z.number(), name: z.string(), }), async handler({ request, params }) { const typedJson = await request.json() // this body will have the correct type return { id: Number(params.id), name: typedJson.name } }, })
json() helper from spiceflow to return or throw non-200 responses with full type safety. Unlike Response.json(), json() carries the data type and status code through the type system — so TypeScript validates that the status code exists in the response schema and the body matches the declared shape.12345678910111213141516171819import { Spiceflow, json } from 'spiceflow' import { z } from 'zod' new Spiceflow().route({ method: 'GET', path: '/users/:id', response: { 200: z.object({ id: z.string(), name: z.string() }), 404: z.object({ message: z.string() }), }, handler({ params }) { const user = findUser(params.id) if (!user) { // TypeScript validates: 404 is in the response map, and { message: string } matches the 404 schema throw json({ message: 'not found' }, { status: 404 }) } return { id: user.id, name: user.name } }, })
message, never error. SpiceflowFetchError builds its .message from that key, so callers read err.message like on any other Error. With any other key (error, detail, title) the client falls back to JSON.stringify(body) and err.message becomes a raw blob like {"error":"Not found"}. Machine-readable data goes in sibling fields such as code or retryAfter.123456789if (!session) { throw json( { message: 'Not logged in. Sign in at https://app.example.com/login or run: mycli login', }, { status: 401 }, ) }
tsc reports an error:12345// @ts-expect-error — 500 is not in the response schema throw json({ message: 'server error' }, { status: 500 }) // @ts-expect-error — number doesn't match { message: string } for 404 throw json(42, { status: 404 })
SpiceflowFetchError with the exact body shape. See Preserving Client Type Safety for the full client-side pattern..route(), .get(), etc.), use /* as a catch-all to handle unmatched requests. For React pages, use children === null in a layout instead (see Redirects and Not Found). More specific routes always take precedence regardless of registration order:12345678910111213141516171819202122232425262728293031323334353637383940import { Spiceflow } from 'spiceflow' export const app = new Spiceflow() .route({ method: 'GET', path: '/users', handler() { return { users: [] } }, }) .route({ method: 'GET', path: '/users/:id', handler({ params }) { return { id: params.id } }, }) // Catch-all for unmatched GET requests .route({ method: 'GET', path: '/*', handler() { return new Response('Page not found', { status: 404 }) }, }) // Or use .all() to catch any method .route({ method: '*', path: '/*', handler({ request }) { return new Response(`Cannot ${request.method} ${request.parsedUrl.pathname}`, { status: 404, }) }, }) // Specific routes work as expected // GET /users returns { users: [] } // GET /users/123 returns { id: '123' } // GET /unknown returns 'Page not found' with 404 status
*filePath. Only bare * is supported. Named wildcards silently fail to match any request. Access the wildcard value via params['*'] instead.1234567891011121314151617181920212223import { Spiceflow } from 'spiceflow' import { z } from 'zod' const mainApp = new Spiceflow() .route({ method: 'POST', path: '/users', async handler({ request }) { return `Created user: ${(await request.json()).name}` }, request: z.object({ name: z.string(), }), }) .use( new Spiceflow().route({ method: 'GET', path: '/', handler() { return 'Users list' }, }), )
12345678910import { Spiceflow } from 'spiceflow' export const app = new Spiceflow({ basePath: '/api/v1' }) app.route({ method: 'GET', path: '/hello', handler() { return 'Hello' }, }) // Accessible at /api/v1/hello
base option instead of the constructor:123456789// vite.config.ts import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import spiceflow from 'spiceflow/vite' export default defineConfig({ base: '/my-app', plugins: [react(), spiceflow({ entry: 'src/main.tsx' })], })
/. CDN URLs and relative paths are not supported.basePath in the Spiceflow constructor when using Vite — Spiceflow will throw an error if both are set. The Vite base option is the single source of truth.Link component href — <Link href="/dashboard" /> automatically renders as <a href="/my-app/dashboard">. If the href already includes the base prefix, it is not added again (<Link href="/my-app/dashboard" /> stays as-is). To disable auto-prepending entirely, use the rawHref prop: <Link rawHref href="/docs/docs" /> — useful when your path legitimately starts with the same string as the baseredirect() Location header — redirect("/login") sends Location: /my-app/loginrouter.push() and router.replace() — router.push("/settings") navigates to /my-app/settingsrouter.pathname — returns the path without the base prefix (e.g. /dashboard, not /my-app/dashboard)<script>, <link> CSS tags) — handled automatically by ViteserveStatic file resolution — strips the base prefix before looking up files on disk<a href="/path"> tags (not using the Link component) — use Link instead//cdn.com/...) — left as-isfetch() calls inside your app code — you need to construct the URL yourselfrequest.url and request.parsedUrl in middleware — contain the full URL including the base prefixapp.getRoutes() to inspect routes registered on the app and its mounted sub-apps. Each item contains only path, method, and kind. Pages appear once with the GET method. Dynamic parameters stay in the path pattern, so you can filter them or replace them with values from your database:12345const staticPagePaths = app .getRoutes() .filter((route) => route.kind === 'page') .filter((route) => !route.path.includes(':') && !route.path.includes('*')) .map((route) => route.path)
sitemap.xml and llms.txt small. Define the pages first so their route metadata is available to the static route handler:123456789101112131415161718192021222324252627282930313233343536import { Spiceflow } from 'spiceflow' import llmsText from './llms.md?raw' const origin = 'https://example.com' const site = new Spiceflow() .page('/', async () => <h1>Home</h1>) .page('/about', async () => <h1>About</h1>) .page('/posts/:slug', async ({ params }) => <h1>{params.slug}</h1>) export const app = site .staticGet('/sitemap.xml', () => { const paths = site .getRoutes() .filter((route) => route.kind === 'page') .filter((route) => !route.path.includes(':') && !route.path.includes('*')) .map((route) => route.path) const urls = paths .map((path) => ` <url><loc>${new URL(path, origin).href}</loc></url>`) .join('\n') const xml = [ '<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', urls, '</urlset>', ].join('\n') return new Response(xml, { headers: { 'content-type': 'application/xml; charset=utf-8' }, }) }) .staticGet('/llms.txt', () => { return new Response(llmsText, { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }) })
/posts/:slug yourself before adding them to the sitemap. app.href('/posts/:slug', { slug }) can replace the parameters after you load the values.12345import { Spiceflow } from 'spiceflow' new Spiceflow().use(({ request }) => { console.log(`Received ${request.method} request to ${request.parsedUrl.pathname}`) })
1234567891011121314151617import { Spiceflow } from 'spiceflow' const admin = new Spiceflow({ basePath: '/admin' }) .use(() => { console.log('admin only') }) .get('/users', () => 'users') new Spiceflow() .use(() => { console.log('root') }) .use(admin) .get('/health', () => 'ok') // GET /admin/users -> runs "root" and "admin only" // GET /health -> runs only "root"
scoped: false:12345const globalMiddleware = new Spiceflow({ scoped: false }).use(({ request }) => { console.log(request.parsedUrl.pathname) }) new Spiceflow().use(globalMiddleware)
next() to get the response from downstream handlers, then modify it before sending:123456789101112131415161718import { Spiceflow } from 'spiceflow' new Spiceflow() .use(async ({ request }, next) => { const response = await next() if (response) { // Add a custom header to all responses response.headers.set('X-Powered-By', 'Spiceflow') } return response }) .route({ method: 'GET', path: '/example', handler() { return { message: 'Hello, World!' } }, })
serveStatic() to serve files from a directory:123456789101112131415161718import { Spiceflow, serveStatic } from 'spiceflow' export const app = new Spiceflow() .use(serveStatic({ root: './public' })) .route({ method: 'GET', path: '/health', handler() { return { ok: true } }, }) .route({ method: 'GET', path: '/*', handler() { return new Response('Not Found', { status: 404 }) }, })
GET and HEAD requests. It checks the exact file path first, and if the request points to a directory it tries index.html inside that directory./health is handled by the route even if public/health exists./* and *.12345request /logo.png -> router matches `/*` -> static checks `public/logo.png` -> if file exists, static serves it -> otherwise the `/*` route runs
index.html fall through instead of throwing filesystem errors like EISDIR.123export const app = new Spiceflow() .use(serveStatic({ root: './public' })) .use(serveStatic({ root: './uploads' }))
./public/logo.png wins over ./uploads/logo.png because ./public is registered first.Vite client build assets (dist/client) are served automatically in production — no need to register aserveStaticmiddleware for them.
.staticGet() to define API routes that are pre-rendered at build time and served as static files. The handler runs once during vite build, and the response body is written to dist/client/ so it can be served directly without hitting the server at runtime:1234567891011export const app = new Spiceflow() .staticGet('/api/manifest.json', () => ({ name: 'my-app', version: '1.0.0', features: ['rsc', 'streaming'], })) .staticGet('/robots.txt', () => new Response('User-agent: *\nAllow: /', { headers: { 'content-type': 'text/plain' }, }), )
staticGet routes behave like normal .get() handlers — the handler runs on every request. At build time, Spiceflow calls each handler and writes the output to disk. The route path should include a file extension (.json, .xml, .txt) so the static file server can detect the correct MIME type.12345678910import { cors } from 'spiceflow/cors' import { Spiceflow } from 'spiceflow' export const app = new Spiceflow().use(cors()).route({ method: 'GET', path: '/hello', handler() { return 'Hello, World!' }, })
Spiceflow unhandled error: so you can see what went wrong during development..onError() to customize error handling. Registering an .onError callback replaces the default logging, so errors are only handled by your callback:12345678910111213import { Spiceflow } from 'spiceflow' const app = new Spiceflow() .get('/users/:id', async ({ params }) => { const user = await findUser(params.id) if (!user) throw Object.assign(new Error('User not found'), { status: 404 }) return user }) .onError(({ error, path }) => { // Custom error handling replaces default console.error logging console.error(`Error on ${path}:`, error.message) return new Response('Something went wrong', { status: 500 }) })
Response from .onError, it becomes the response for that request. If you don't return anything, Spiceflow falls back to its default JSON error response (but skips the default logging since you have a handler registered).123const app = new Spiceflow() .get('/test', () => { throw new Error('expected') }) .onError(() => {})
status property (or statusCode) are used as the HTTP status code. Invalid or out-of-range status codes are normalized to 500:12// Returns 400 Bad Request throw Object.assign(new Error('Invalid input'), { status: 400 })
yield sends a data: ...\n\n chunk to the client. Works with .get(), .post(), and .route().1234567891011121314// server.ts import { Spiceflow } from 'spiceflow' export const app = new Spiceflow().route({ method: 'GET', path: '/api/progress', async *handler() { yield { status: 'generating', progress: 0.5 } await someWork() yield { status: 'done', progress: 1.0 } }, }) export type App = typeof app
AsyncGenerator instead of a plain object. TypeScript infers the yield type automatically via ReplaceGeneratorWithAsyncGenerator in the type system.123456789101112// client.ts import { createSpiceflowFetch } from 'spiceflow/client' const safeFetch = createSpiceflowFetch('http://localhost:3000') const stream = await safeFetch('/api/progress') if (stream instanceof Error) throw stream // stream is AsyncGenerator<{ status: string, progress: number }> for await (const event of stream) { console.log(event.status, event.progress) }
AbortController signal to cancel the stream. The server stops the generator and cleans up.1234567891011121314const controller = new AbortController() // abort after 5 seconds setTimeout(() => controller.abort(), 5000) const stream = await safeFetch('/api/progress', { signal: controller.signal, }) if (stream instanceof Error) throw stream for await (const event of stream) { console.log(event) } // loop exits cleanly on abort, no error thrown
-N to disable buffering and stream events line by line:1curl -N http://localhost:3000/api/progress
listen() returns an object with port, server, and stop() for programmatic control:12345const listener = await app.listen(3000) console.log(`Listening on port ${listener.port}`) await listener.stop()
In Vite dev and during prerender, Spiceflow skips starting a real server.listen()still returns an object, butportandserverareundefinedandstop()is a noop, so cleanup code can stay unconditional.
preventProcessExitIfBusy middleware prevents platforms like Fly.io from killing your app while processing long requests. See Middleware Patterns for usage.waitUntil)waitUntil from the handler context. Never do void somePromise() or somePromise().catch(...) directly; the runtime may kill the process before the promise settles.12345678910111213141516export const app = new Spiceflow().route({ method: 'POST', path: '/process', async handler({ request, waitUntil }) { const data = await request.json() waitUntil( fetch('https://analytics.example.com/track', { method: 'POST', body: JSON.stringify({ event: 'processed', data }), }), ) return { success: true } }, })
waitUntil automatically delegates to the Workers ExecutionContext.waitUntil. On Node.js it is a no-op by default; pass a custom implementation via new Spiceflow({ waitUntil: (p) => { ... } }) if you need background work to be tracked. See Cloudflare docs for full examples including Cloudflare integration and custom implementations.req/res pair into a standard Request yourself. Spiceflow already exposes the right adapter for each situation, so this conversion should stay inside Spiceflow rather than in app code.app.listen(3000). Spiceflow sets up the server adapter for you. Cloudflare Workers are the main exception because there is no port-based server to listen on there.req and res (for example a Next.js pages API route), use app.handleForNode(req, res).Request, just delegate with return app.handle(request).123456789101112131415161718192021import { Spiceflow } from 'spiceflow' import type { IncomingMessage, ServerResponse } from 'node:http' export const app = new Spiceflow().get('/hello', () => { return { hello: 'world' } }) // Run directly on Node.js or Bun app.listen(3000) // Use inside a classic Node.js req/res handler export async function nodeHandler(req: IncomingMessage, res: ServerResponse) { await app.handleForNode(req, res) } // Use inside a standard Request handler export default { fetch(request: Request) { return app.handle(request) }, }
1234567891011121314151617// pages/api/[...path].ts import { getJwt } from '@app/utils/ssr' // exasmple session function import type { NextApiRequest, NextApiResponse } from 'next' export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { // IMPORTANT! nothing should be run before calling handleForNode that could read the request body! await mcpAuthApp.handleForNode(req, res) } export const config = { api: { bodyParser: false, }, }
AnySpiceflow type.this in route handlersthis inside route handlers to reference the parent class. The this context inside handlers always refers to the Spiceflow instance, not your class instance. Instead, capture the parent class reference in a variable outside the handlers.123456789101112131415161718192021222324252627282930313233343536373839import { Spiceflow, AnySpiceflow } from 'spiceflow' export class ChatDurableObject { private router: AnySpiceflow private state: DurableObjectState constructor(state: DurableObjectState, env: Env) { this.state = state const self = this // Capture parent class reference - IMPORTANT! this.router = new Spiceflow() .route({ method: 'GET', path: '/messages', async handler() { // Use 'self' instead of 'this' to access parent class // this.state would NOT work here - 'this' refers to Spiceflow instance const messages = (await self.state.storage.get('messages')) || [] return { messages } }, }) .route({ method: 'POST', path: '/messages', async handler({ request }) { const { message } = await request.json() // Use 'self' to access parent class properties const messages = (await self.state.storage.get('messages')) || [] messages.push({ id: Date.now(), text: message }) await self.state.storage.put('messages', messages) return { success: true } }, }) } fetch(request: Request) { return this.router.handle(request) } }