spiceflow

type safe API and React Server Components framework for Node, Bun, and Cloudflare



Spiceflow is a type-safe API framework and full-stack React RSC framework focused on absolute simplicity. It works across all JavaScript runtimes: Node.js, Bun, and Cloudflare Workers. Read the source code on [GitHub](https://github.com/remorses/spiceflow). ## Features - Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting - Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code - Type safe schema based validation via Zod - Type safe fetch client with full inference on path params, query, body, and response - Simple and intuitive API using web standard Request and Response - Can easily generate OpenAPI spec based on your routes - Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs - Supports async generators for streaming via server sent events - Modular design with `.use()` for mounting sub-apps - Built-in [OpenTelemetry](https://opentelemetry.io/) tracing with zero overhead when disabled ## Installation ```bash npm install spiceflow@rsc ``` > [!IMPORTANT] > Spiceflow is still in pre-release. Install with `spiceflow@rsc`, not `spiceflow@latest`. ## AI Agents To let your AI coding agent know how to use spiceflow, run: ```bash npx -y skills add remorses/spiceflow ``` ## Basic Usage API routes return JSON automatically. React pages use `.page()` and `.layout()` for server-rendered UI with client interactivity: ```tsx import { Spiceflow } from 'spiceflow' import { Counter } from './counter' export const app = new Spiceflow() .get('/api/hello', () => { return { message: 'Hello, World!' } }) .layout('/*', async ({ children }) => { return ( {children} ) }) .page('/', async () => { return (

Home

) }) .page('/about', async () => { return

About

}) app.listen(3000) ```
When to use .route() vs .get()/.post() Use `.route()` instead of `.get()`/`.post()` when you want to pass Zod schemas for validation — it accepts `request`, `response`, `query`, and `params` schemas.
## Two Ways to Use Spiceflow Spiceflow works as a **standalone API framework** or as a **full-stack React framework** — same router, same type safety, same code. **API only** — no Vite, no React. Just install `spiceflow` and build type-safe APIs with Zod validation, streaming, OpenAPI, and a type-safe fetch client: ```ts import { Spiceflow } from 'spiceflow' const app = new Spiceflow() .get('/hello', () => ({ message: 'Hello!' })) app.listen(3000) ``` **Full-stack React (RSC)** — add the Vite plugin to get server components, client components, layouts, server actions, and automatic code splitting. All API features still work alongside React pages: ```ts // vite.config.ts import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import spiceflow from 'spiceflow/vite' export default defineConfig({ plugins: [react(), spiceflow({ entry: './src/main.tsx' })], }) ``` ## Route Chaining To preserve full type safety on the fetch client, routes must be chained in a single expression. Declaring the app separately and adding routes later loses the inferred types.
Why chaining matters When you declare routes separately, TypeScript can't infer the combined route types across multiple statements. The fetch client needs the full chain to infer path params, query params, body types, and response types. ```ts // 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 }) ```
## Returning JSON Spiceflow automatically serializes objects returned from handlers to JSON. Return plain objects directly — this is the preferred approach because the typed fetch client can infer the response type automatically: ```ts import { 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, } }) ``` When you need to return a non-200 status code, use the `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: ```ts import { Spiceflow, json } from 'spiceflow' // Preferred — type-safe, fetch client knows this is a 404 with { error: string } throw json({ error: 'Not found' }, { status: 404 }) // Avoid — Response.json() erases the type, fetch client sees unknown throw Response.json({ error: 'Not found' }, { status: 404 }) ``` ## Routes & Validation Define routes with Zod schemas for automatic request and response validation. Use `.route()` with `request`, `response`, `query`, and `params` schemas for full type safety. ### Request Validation ```ts import { 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}` }, }) ```
How body parsing works To get the body of the request, call `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`. The `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 away - **`request.json()` / `request.formData()`** — parse and validate the body against the route schema in one step, returning typed data instead of `any` - **`request.originalUrl`** — the raw transport URL before Spiceflow normalizes `.rsc` pathnames
### Response Schema ```ts import { 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 } }, }) ``` ### Typed Error Responses When a route declares a status-code response map, use the `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. ```ts import { 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({ error: z.string() }), }, handler({ params }) { const user = findUser(params.id) if (!user) { // TypeScript validates: 404 is in the response map, and { error: string } matches the 404 schema throw json({ error: 'not found' }, { status: 404 }) } return { id: user.id, name: user.name } }, }) ``` If you pass a status code that's not in the response map, or a body that doesn't match the schema for that status, `tsc` reports an error: ```ts // @ts-expect-error — 500 is not in the response schema throw json({ error: 'server error' }, { status: 500 }) // @ts-expect-error — number doesn't match { error: string } for 404 throw json(42, { status: 404 }) ``` The fetch client picks up these types automatically — each non-200 status becomes a typed `SpiceflowFetchError` with the exact body shape. See [Preserving Client Type Safety](./website/src/openapi.md#preserving-client-type-safety) for the full client-side pattern. ## Middleware Middleware functions run before route handlers. They can log, authenticate, modify responses, or short-circuit the request entirely. ```ts import { Spiceflow } from 'spiceflow' new Spiceflow().use(({ request }) => { console.log(`Received ${request.method} request to ${request.parsedUrl.pathname}`) }) ``` ### Mounted Apps Middleware is scoped to the app where you register it. **Parent app middleware runs for child sub-app routes too**, but **sub-app middleware does not run for parent or sibling routes**. ```ts import { 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" ``` If you want a mounted app's middleware to run for **every** request, create that mounted app with `scoped: false`: ```ts const globalMiddleware = new Spiceflow({ scoped: false }).use(({ request }) => { console.log(request.parsedUrl.pathname) }) new Spiceflow().use(globalMiddleware) ``` ### Response Modification Call `next()` to get the response from downstream handlers, then modify it before sending: ```ts import { 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!' } }, }) ``` ### Static Files Use `serveStatic()` to serve files from a directory: ```ts import { 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 }) }, }) ``` Static middleware only serves `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.
Priority rules - Concrete routes win over static files. A route like `/health` is handled by the route even if `public/health` exists. - Static files win over root catch-all routes like `/*` and `*`. - If static does not find a file, the request falls through to the next matching route. - When multiple static middlewares are registered, they are checked in registration order. The first middleware that finds a file wins. Example behavior: ```text request /logo.png -> router matches `/*` -> static checks `public/logo.png` -> if file exists, static serves it -> otherwise the `/*` route runs ``` Directory requests without an `index.html` fall through instead of throwing filesystem errors like `EISDIR`.
You can stack multiple static roots: ```ts export const app = new Spiceflow() .use(serveStatic({ root: './public' })) .use(serveStatic({ root: './uploads' })) ``` In this example, `./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 a `serveStatic` middleware for them. ### Static Routes (Pre-rendered) Use `.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: ```ts export 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' }, }), ) ``` In development, `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. For authorization, proxy, non-blocking auth, cookies, and graceful shutdown patterns, see [Middleware Patterns](./website/src/middleware-patterns.md). ## Error Handling When a route handler or middleware throws an error, Spiceflow catches it and returns a JSON response with the error message and stack trace. **By default, unhandled errors are also logged to the console** with `Spiceflow unhandled error:` so you can see what went wrong during development. Use `.onError()` to customize error handling. Registering an `.onError` callback **replaces the default logging**, so errors are only handled by your callback: ```ts import { 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 }) }) ``` If you return a `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). To silence error logs entirely (useful in tests), register a no-op handler: ```ts const app = new Spiceflow() .get('/test', () => { throw new Error('expected') }) .onError(() => {}) ``` Errors with a `status` property (or `statusCode`) are used as the HTTP status code. Invalid or out-of-range status codes are normalized to 500: ```ts // Returns 400 Bad Request throw Object.assign(new Error('Invalid input'), { status: 400 }) ``` ## Async Generators (Streaming) Async generators will create a server sent event response. ```ts // server.ts import { Spiceflow } from 'spiceflow' export const app = new Spiceflow().route({ method: 'GET', path: '/sseStream', async *handler() { yield { message: 'Start' } await new Promise((resolve) => setTimeout(resolve, 1000)) yield { message: 'Middle' } await new Promise((resolve) => setTimeout(resolve, 1000)) yield { message: 'End' } }, }) export type App = typeof app ``` Server-Sent Events (SSE) format — the server sends events as `data: {"message":"Start"}\n\n` chunks. ```ts // client.ts import { createSpiceflowFetch } from 'spiceflow/client' const safeFetch = createSpiceflowFetch('http://localhost:3000') async function fetchStream() { const stream = await safeFetch('/sseStream') if (stream instanceof Error) { console.error('Error fetching stream:', stream.message) return } for await (const chunk of stream) { console.log('Stream chunk:', chunk) } } fetchStream() ``` ## Not Found Handler For API routes (`.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](#redirects-and-not-found)). More specific routes always take precedence regardless of registration order: ```ts import { 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 ``` > [!IMPORTANT] > Do **not** use named wildcards like `*filePath`. Only bare `*` is supported. Named wildcards silently fail to match any request. Access the wildcard value via `params['*']` instead. ## Mounting Sub-Apps ```ts import { 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' }, }), ) ``` ## Base Path For standalone API servers (without Vite), set the base path in the constructor: ```ts import { 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 ``` ### Vite Base Path When using Spiceflow as a full-stack RSC framework with Vite, configure the base path via Vite's `base` option instead of the constructor: ```ts // 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' })], }) ```
Base path rules The base path must be an absolute path starting with `/`. CDN URLs and relative paths are not supported. Do not set `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.
What gets auto-prepended and what doesn't **What gets the base path auto-prepended:** - `Link` component `href` — `` automatically renders as ``. If the href already includes the base prefix, it is not added again (`` stays as-is). To disable auto-prepending entirely, use the `rawHref` prop: `` — useful when your path legitimately starts with the same string as the base - `redirect()` Location header — `redirect("/login")` sends `Location: /my-app/login` - `router.push()` and `router.replace()` — `router.push("/settings")` navigates to `/my-app/settings` - `router.pathname` — returns the path **without** the base prefix (e.g. `/dashboard`, not `/my-app/dashboard`) - Static asset URLs (`