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.

Features

Installation


npm install spiceflow@rsc

AI Agents

To let your AI coding agent know how to use spiceflow, run:


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:


import { Spiceflow } from 'spiceflow'
import { Counter } from './counter'
const app = new Spiceflow()
.get('/api/hello', () => {
return { message: 'Hello, World!' }
})
.layout('/*', async ({ children }) => {
return (
<html>
<body>{children}</body>
</html>
)
})
.page('/', async () => {
return (
<div>
<h1>Home</h1>
<Counter />
</div>
)
})
.page('/about', async () => {
return <h1>About</h1>
})
app.listen(3000)

Always define a root .layout('/*', ...) and put the document shell with <html>, <head>, and <body> there. More specific layouts should not render another shell - they should only return shared parent UI like sidebars, nav, wrappers, or section chrome. If a nested layout also renders <html>, the shell repeats and you end up nesting full HTML documents inside each other. Only add scoped layouts when many pages share the same parent components. Wildcard layouts also match their base path, so /app/* wraps both /app and /app/settings, and /docs/* wraps both /docs and /docs/getting-started.


const app = new Spiceflow()
.layout('/*', async ({ children }) => {
return (
<html>
<body>{children}</body>
</html>
)
})
.layout('/app/*', async ({ children }) => {
return <section className="app-shell">{children}</section>
})
.layout('/docs/*', async ({ children }) => {
return <section className="docs-shell">{children}</section>
})
.page('/app', async () => {
return <h1>App home</h1>
})
.page('/app/settings', async () => {
return <h1>App settings</h1>
})
.page('/docs', async () => {
return <h1>Docs home</h1>
})
.page('/docs/getting-started', async () => {
return <h1>Getting started</h1>
})


// counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>
}

If you need to shut the server down later, listen() always returns an object with port, server, and stop():


const 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, but port and server are undefined and stop() is a noop, so cleanup code can stay unconditional.

React pages require Vite and the spiceflow Vite plugin. See nodejs-example/vite.config.ts for setup. API-only apps don't need Vite.

Use .route() instead of .get()/.post() when you want to pass Zod schemas for validation — it accepts request, response, query, and params schemas.

Returning JSON

Spiceflow automatically serializes objects returned from handlers to JSON, so you don't need to wrap them in a Response object:


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow()
.get('/user', () => {
// Return object directly - no need for new Response()
return { id: 1, name: 'John', email: 'john@example.com' }
})
.post('/data', async ({ request }) => {
const body = await request.json()
// Objects are automatically serialized to JSON
return {
received: body,
timestamp: new Date().toISOString(),
processed: true,
}
})

Type Safety for RPC

To maintain type safety when using the fetch client, throw Response objects for errors and return objects directly for success cases. The fetch client returns Error | Data directly — use instanceof Error to narrow the type:


import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
const app = new Spiceflow()
.route({
method: 'GET',
path: '/users/:id',
query: z.object({
q: z.string(),
}),
response: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
handler({ params }) {
const user = getUserById(params.id)
if (!user) {
throw new Response('User not found', { status: 404 })
}
return {
id: user.id,
name: user.name,
email: user.email,
}
},
})
.route({
method: 'POST',
path: '/users',
request: z.object({
name: z.string(),
email: z.string().email(),
}),
response: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
async handler({ request }) {
const body = await request.json()
if (await userExists(body.email)) {
throw new Response('User already exists', { status: 409 })
}
const newUser = await createUser(body)
return {
id: newUser.id,
name: newUser.name,
email: newUser.email,
}
},
})
export type App = typeof app


// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'
import type { App } from './server'
const safeFetch = createSpiceflowFetch<App>('http://localhost:3000')
// Path params are type-safe — TypeScript requires { id: string }
const user = await safeFetch('/users/:id', {
params: { id: '123' },
query: { q: 'something' },
})
if (user instanceof Error) {
console.error('Error:', user.message)
return
}
// user is typed as { id: string, name: string, email: string }
console.log('User:', user.name, user.email)
// Body is type-safe — TypeScript requires { name: string, email: string }
const newUser = await safeFetch('/users', {
method: 'POST',
body: { name: 'John', email: 'john@example.com' },
})
if (newUser instanceof Error) return newUser
console.log('Created:', newUser.id)

With this pattern:

Comparisons

Elysia

This project was born as a fork of Elysia with several changes:

Hono

This project shares many inspirations with Hono with many differences

Requests and Responses

POST Request with Body Schema


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}`
},
})

Notice that to get the body of the request, you need to call request.json() to parse the body as JSON. Spiceflow does not parse the Body automatically, there is no body field in the Spiceflow route argument, instead you call either request.json() or request.formData() to get the body and validate it at the same time. This works by wrapping the request in a SpiceflowRequest instance, which has a json() and formData() method that parse the body and validate it. The returned data will have the correct schema type instead of any.

Response Schema


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 }
},
})

Type-Safe Fetch Client

createSpiceflowFetch provides a type-safe fetch(path, options) interface for calling your Spiceflow API. It gives you full type safety on path params, query params, request body, and response data — all inferred from your route definitions.

Export the app type from your server code:


// server.ts
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
const app = new Spiceflow()
.route({
method: 'GET',
path: '/hello',
handler() {
return 'Hello, World!'
},
})
.route({
method: 'POST',
path: '/users',
request: z.object({
name: z.string(),
email: z.string().email(),
}),
async handler({ request }) {
const body = await request.json()
return { id: '1', name: body.name, email: body.email }
},
})
.route({
method: 'GET',
path: '/users/:id',
handler({ params }) {
return { id: params.id }
},
})
.route({
method: 'GET',
path: '/search',
query: z.object({ q: z.string(), page: z.coerce.number().optional() }),
handler({ query }) {
return { results: [], query: query.q, page: query.page }
},
})
.route({
method: 'GET',
path: '/stream',
async *handler() {
yield 'Start'
yield 'Middle'
yield 'End'
},
})
export type App = typeof app

Then use the App type on the client side without importing server code:


// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'
import type { App } from './server'
const safeFetch = createSpiceflowFetch<App>('http://localhost:3000')
// GET request — returns Error | Data, check with instanceof Error
const greeting = await safeFetch('/hello')
if (greeting instanceof Error) return greeting
console.log(greeting) // 'Hello, World!' — TypeScript knows the type
// POST with typed body — TypeScript requires { name: string, email: string }
const user = await safeFetch('/users', {
method: 'POST',
body: { name: 'John', email: 'john@example.com' },
})
if (user instanceof Error) return user
console.log(user.id, user.name, user.email) // fully typed
// Path params — type-safe, TypeScript requires { id: string }
const foundUser = await safeFetch('/users/:id', {
params: { id: '123' },
})
if (foundUser instanceof Error) return foundUser
console.log(foundUser.id) // typed as string
// Query params — typed from the route's Zod schema
const searchResults = await safeFetch('/search', {
query: { q: 'hello', page: 1 },
})
if (searchResults instanceof Error) return searchResults
console.log(searchResults.results, searchResults.query) // fully typed
// Streaming — async generator routes return an AsyncGenerator
const stream = await safeFetch('/stream')
if (stream instanceof Error) return stream
for await (const chunk of stream) {
console.log(chunk) // 'Start', 'Middle', 'End'
}

The fetch client returns Error | Data directly following the errore convention — use instanceof Error to check for errors with Go-style early returns, then the happy path continues with the narrowed data type. No { data, error } destructuring, no null checks. On error, the returned SpiceflowFetchError has status, value (the parsed error body), and response (the raw Response object) properties.

The fetch client supports configuration options like headers, retries, onRequest/onResponse hooks, and custom fetch.

You can also pass a Spiceflow app instance directly for server-side usage without network requests:


const safeFetch = createSpiceflowFetch(app)
const greeting = await safeFetch('/hello')
if (greeting instanceof Error) throw greeting

Path Matching - Supported Features

Path Matching - Unsupported Features

Not Found Handler

Use /* as a catch-all route to handle 404 errors. More specific routes always take precedence regardless of registration order:


import { Spiceflow } from 'spiceflow'
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.url}`, {
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

Chaining Routes for Type Safety

Never declare app and add routes separately — that way you lose the type safety. Instead always append routes in a single chained expression:


// 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
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
})

Storing Spiceflow in Class Instances

If you need to store a Spiceflow router as a property in a class instance, use the AnySpiceflow type:

Important: Do not use this 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:


import { 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)
}
}

Type-Safe Path Building

The href method provides a type-safe way to build URLs with parameters. It helps prevent runtime errors by ensuring all required parameters are provided and properly substituted into the path.


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow()
.route({
method: 'GET',
path: '/users/:id',
handler({ params }) {
return { id: params.id }
},
})
.route({
method: 'GET',
path: '/users/:id/posts/:postId',
handler({ params }) {
return { userId: params.id, postId: params.postId }
},
})
// Building URLs with required parameters
const userPath = app.href('/users/:id', { id: '123' })
// Result: '/users/123'
// Building URLs with required parameters
const userPostPath = app.href('/users/:id/posts/:postId', {
id: '456',
postId: 'abc',
})
// Result: '/users/456/posts/abc'

Query Parameters

When a route has a query schema, href accepts query parameters alongside path parameters in the same flat object. Query parameters are appended as a query string, and unknown keys are rejected at the type level:


const app = new Spiceflow()
.route({
method: 'GET',
path: '/search',
query: z.object({ q: z.string(), page: z.coerce.number() }),
handler({ query }) {
return { results: [], q: query.q }
},
})
.route({
method: 'GET',
path: '/users/:id',
query: z.object({ fields: z.string() }),
handler({ params, query }) {
return { id: params.id, fields: query.fields }
},
})
app.href('/search', { q: 'hello', page: 1 })
// Result: '/search?q=hello&page=1'
app.href('/users/:id', { id: '42', fields: 'name' })
// Result: '/users/42?fields=name'
// @ts-expect-error - 'invalid' is not a known query key
app.href('/search', { invalid: 'x' })

Standalone createHref

If you need a path builder on the client side where you can't import server app code, use createHref with the App type:


import { createHref } from 'spiceflow'
import type { App } from './server' // import only the type, not the runtime app
const href = createHref<App>()
href('/users/:id', { id: '123' })
// Result: '/users/123'
href('/search', { q: 'hello', page: 1 })
// Result: '/search?q=hello&page=1'

The returned function has the same type safety as app.href — it infers paths, params, and query schemas from the app type. The app argument is optional and not used at runtime, so you can call createHref<App>() without passing any value.

OAuth Callback Example

The href method is particularly useful when building callback URLs for OAuth flows, where you need to construct URLs dynamically based on user data or session information:


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow()
.route({
method: 'GET',
path: '/auth/callback/:provider/:userId',
handler({ params, query }) {
const { provider, userId } = params
const { code, state } = query
// Handle OAuth callback logic here
return {
provider,
userId,
authCode: code,
state,
}
},
})
.route({
method: 'POST',
path: '/auth/login',
handler({ request }) {
const userId = '12345'
const provider = 'google'
// Build the OAuth callback URL safely
const callbackUrl = new URL(
app.href('/auth/callback/:provider/:userId', {
provider,
userId,
}),
'https://myapp.com',
).toString()
// Redirect to OAuth provider with callback URL
const oauthUrl =
`https://accounts.google.com/oauth/authorize?` +
`client_id=your-client-id&` +
`redirect_uri=${encodeURIComponent(callbackUrl)}&` +
`response_type=code&` +
`scope=openid%20profile%20email`
return { redirectUrl: oauthUrl }
},
})

In this example:

Mounting Sub-Apps


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:


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow({ basePath: '/api/v1' })
app.route({
method: 'GET',
path: '/hello',
handler() {
return 'Hello'
},
}) // Accessible at /api/v1/hello

Base Path with Vite (RSC apps)

When using Spiceflow as a full-stack RSC framework with Vite, configure the base path via Vite's base option instead of the constructor:


// vite.config.ts
import { defineConfig } from 'vite'
import { spiceflowPlugin } from 'spiceflow/vite'
export default defineConfig({
base: '/my-app',
plugins: [spiceflowPlugin({ entry: 'src/main.tsx' })],
})

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 the base path auto-prepended:

What does NOT get auto-prepended:

Async Generators (Streaming)

Async generators will create a server sent event response.


// 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.


// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'
import type { App } from './server'
const safeFetch = createSpiceflowFetch<App>('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()

Error Handling


import { Spiceflow } from 'spiceflow'
new Spiceflow().onError(({ error }) => {
console.error(error)
return new Response('An error occurred', { status: 500 })
})

Middleware


import { Spiceflow } from 'spiceflow'
new Spiceflow().use(({ request }) => {
console.log(`Received ${request.method} request to ${request.url}`)
})

Static Middleware

Use serveStatic() to serve files from a directory:


import { Spiceflow, serveStatic } from 'spiceflow'
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:

Example behavior:


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:


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.

How errors are handled in the fetch client

The fetch client returns Error | Data directly. When the server responds with a non-2xx status code, the client returns a SpiceflowFetchError instead of the data. Use instanceof Error to check:


// server.ts
import { Spiceflow } from 'spiceflow'
export const app = new Spiceflow()
.route({
method: 'GET',
path: '/error',
handler() {
throw new Error('Something went wrong')
},
})
.route({
method: 'GET',
path: '/unauthorized',
handler() {
return new Response('Unauthorized access', { status: 401 })
},
})
.route({
method: 'GET',
path: '/success',
handler() {
throw new Response('Success message', { status: 200 })
return ''
},
})
export type App = typeof app


// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'
import type { App } from './server'
const safeFetch = createSpiceflowFetch<App>('http://localhost:3000')
async function handleErrors() {
const errorResult = await safeFetch('/error')
if (errorResult instanceof Error) {
console.error('Error occurred:', errorResult.message)
}
const unauthorizedResult = await safeFetch('/unauthorized')
if (unauthorizedResult instanceof Error) {
console.error(
'Unauthorized:',
unauthorizedResult.message,
'Status:',
unauthorizedResult.status,
)
}
const successResult = await safeFetch('/success')
if (successResult instanceof Error) return
console.log('Success:', successResult) // 'Success message'
}

Using the fetch client server side, without network requests

You can pass the Spiceflow app instance directly to createSpiceflowFetch() instead of providing a URL. This makes "virtual" requests handled directly by the app without actual network requests. Useful for testing, generating documentation, or interacting with your API programmatically without setting up a server.


import { Spiceflow } from 'spiceflow'
import { createSpiceflowFetch } from 'spiceflow/client'
import { openapi } from 'spiceflow/openapi'
import { writeFile } from 'node:fs/promises'
const app = new Spiceflow()
.use(openapi({ path: '/openapi' }))
.route({
method: 'GET',
path: '/users',
handler() {
return [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]
},
})
.route({
method: 'POST',
path: '/users',
handler({ request }) {
return request.json()
},
})
// Create fetch client by passing app instance directly
const safeFetch = createSpiceflowFetch(app)
// Get OpenAPI schema and write to disk
const data = await safeFetch('/openapi')
if (data instanceof Error) throw data
await writeFile('openapi.json', JSON.stringify(data, null, 2))
console.log('OpenAPI schema saved to openapi.json')

Modifying Response with Middleware

Middleware in Spiceflow can be used to modify the response before it's sent to the client. This is useful for adding headers, transforming the response body, or performing any other operations on the response.

Here's an example of how to modify the response using middleware:


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!' }
},
})

Caching React Pages with Cloudflare KV

Use middleware to cache full-page HTML in Cloudflare KV. The deployment ID is included in the cache key so each deploy gets its own cache namespace — this prevents serving stale HTML that references old CSS/JS filenames with different content hashes.

This example uses import { env } from 'cloudflare:workers' to access KV bindings directly from anywhere in your code, without threading env through .state().


import { Spiceflow, getDeploymentId } from 'spiceflow'
import { env } from 'cloudflare:workers'
const app = new Spiceflow()
.use(async ({ request, waitUntil }, next) => {
if (request.method !== 'GET') {
return next()
}
const url = new URL(request.url)
const deploymentId = await getDeploymentId()
const cacheKey = `${deploymentId}:${url.pathname}${url.search}` // IMPORTANT. cache key must always include search to distinguish html and rsc responses
const cached = await env.PAGE_CACHE.get(cacheKey)
if (cached) {
return new Response(cached, {
headers: {
'content-type': 'text/html; charset=utf-8',
'x-cache': 'HIT',
},
})
}
const response = await next()
if (!response || response.status !== 200) {
return response
}
const html = await response.text()
// Write to KV in the background so the response is not delayed
waitUntil(
env.PAGE_CACHE.put(cacheKey, html, {
expirationTtl: 60 * 60 * 24 * 7, // 7 days
}),
)
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'x-cache': 'MISS',
},
})
})
.page('/', async () => {
return (
<div>
<h1>Home</h1>
</div>
)
})
export default {
fetch(request: Request) {
return app.handle(request)
},
}

When a new version is deployed the build timestamp changes, so getDeploymentId() returns a different value and all cache keys are effectively new. Old entries expire naturally after 7 days.

Generating OpenAPI Schema


import { openapi } from 'spiceflow/openapi'
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
const app = new Spiceflow()
.use(openapi({ path: '/openapi.json' }))
.route({
method: 'GET',
path: '/hello',
handler() {
return 'Hello, World!'
},
query: z.object({
name: z.string(),
age: z.number(),
}),
response: z.string(),
})
.route({
method: 'POST',
path: '/user',
handler() {
return new Response('Hello, World!')
},
request: z.object({
name: z.string(),
email: z.string().email(),
}),
})
const openapiSchema = await (
await app.handle(new Request('http://localhost:3000/openapi.json'))
).json()

Adding CORS Headers


import { cors } from 'spiceflow/cors'
import { Spiceflow } from 'spiceflow'
const app = new Spiceflow().use(cors()).route({
method: 'GET',
path: '/hello',
handler() {
return 'Hello, World!'
},
})

Proxy requests


import { Spiceflow } from 'spiceflow'
import type { MiddlewareHandler } from 'spiceflow'
const app = new Spiceflow()
function createProxyMiddleware({
target,
changeOrigin = false,
}): MiddlewareHandler {
return async ({ request }) => {
const url = new URL(request.url)
const proxyReq = new Request(
new URL(url.pathname + url.search, target),
request,
)
if (changeOrigin) {
proxyReq.headers.set('origin', new URL(target).origin || '')
}
console.log('proxying', proxyReq.url)
const res = await fetch(proxyReq)
return res
}
}
app.use(
createProxyMiddleware({
target: 'https://api.openai.com',
changeOrigin: true,
}),
)
// or with a basePath
app.use(
new Spiceflow({ basePath: '/v1/completions' }).use(
createProxyMiddleware({
target: 'https://api.openai.com',
changeOrigin: true,
}),
),
)
app.listen(3030)

Authorization Middleware

You can handle authorization in a middleware, for example here the code checks if the user is logged in and if not, it throws an error. You can use the state to track request data, in this case the state keeps a reference to the session.


import { z } from 'zod'
import { Spiceflow } from 'spiceflow'
new Spiceflow()
.state('session', null as Session | null)
.use(async ({ request: req, state }, next) => {
const res = new Response()
const { session } = await getSession({ req, res })
if (!session) {
return
}
state.session = session
const response = await next()
const cookies = res.headers.getSetCookie()
for (const cookie of cookies) {
response.headers.append('Set-Cookie', cookie)
}
return response
})
.route({
method: 'POST',
path: '/protected',
async handler({ state }) {
const { session } = state
if (!session) {
throw new Error('Not logged in')
}
return { ok: true }
},
})

Non blocking authentication middleware

Sometimes authentication is only required for specific routes, and you don't want to block public routes while waiting for authentication. You can use Promise.withResolvers() to start fetching user data in parallel, allowing public routes to respond immediately while protected routes wait for authentication to complete.

The example below demonstrates this pattern - the /public route responds instantly while /protected waits for authentication:


import { Spiceflow } from 'spiceflow'
new Spiceflow()
.state('userId', Promise.resolve(''))
.state('userEmail', Promise.resolve(''))
.use(async ({ request, state }, next) => {
const sessionKey = request.headers.get('sessionKey')
const userIdPromise = Promise.withResolvers<string>()
const userEmailPromise = Promise.withResolvers<string>()
state.userId = userIdPromise.promise
state.userEmail = userEmailPromise.promise
async function resolveUser() {
if (!sessionKey) {
userIdPromise.resolve('')
userEmailPromise.resolve('')
return
}
const user = await getUser(sessionKey)
userIdPromise.resolve(user?.id ?? '')
userEmailPromise.resolve(user?.email ?? '')
}
resolveUser()
})
.route({
method: 'GET',
path: '/protected',
async handler({ state }) {
const userId = await state.userId
if (!userId) throw new Error('Not authenticated')
return { message: 'Protected data' }
},
})
.route({
method: 'GET',
path: '/public',
handler() {
return { message: 'Public data' }
},
})
async function getUser(sessionKey: string) {
await new Promise((resolve) => setTimeout(resolve, 100))
return sessionKey === 'valid'
? { id: '123', email: 'user@example.com' }
: null
}

Model Context Protocol (MCP)

Spiceflow includes a Model Context Protocol (MCP) plugin that exposes your API routes as tools and resources that can be used by AI language models like Claude. The MCP plugin makes it easy to let AI assistants interact with your API endpoints in a controlled way.

When you mount the MCP plugin (default path is /mcp), it automatically:

This makes it simple to let AI models like Claude discover and call your API endpoints programmatically.

Basic MCP Usage

Here's an example:


// Import the MCP plugin and client
import { mcp } from 'spiceflow/mcp'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Spiceflow } from 'spiceflow'
import {
ListToolsResultSchema,
CallToolResultSchema,
ListResourcesResultSchema,
} from '@modelcontextprotocol/sdk/types.js'
// Create a new app with some example routes
const app = new Spiceflow()
// Mount the MCP plugin at /mcp (default path)
.use(mcp())
// These routes will be available as tools
.route({
method: 'GET',
path: '/hello',
handler() {
return 'Hello World'
},
})
.route({
method: 'GET',
path: '/users/:id',
handler({ params }) {
return { id: params.id }
},
})
.route({
method: 'POST',
path: '/echo',
async handler({ request }) {
const body = await request.json()
return body
},
})
// Start the server
app.listen(3000)
// Example client usage:
const transport = new SSEClientTransport(new URL('http://localhost:3000/mcp'))
const client = new Client(
{ name: 'example-client', version: '1.0.0' },
{ capabilities: {} },
)
await client.connect(transport)
// List available tools
const tools = await client.request(
{ method: 'tools/list' },
ListToolsResultSchema,
)
// Call a tool
const result = await client.request(
{
method: 'tools/call',
params: {
name: 'GET /hello',
arguments: {},
},
},
CallToolResultSchema,
)
// List available resources (only GET /hello is exposed since it has no params)
const resources = await client.request(
{ method: 'resources/list' },
ListResourcesResultSchema,
)

Adding MCP Tools to Existing Server

If you already have an existing MCP server and want to add Spiceflow route tools to it, you can use the addMcpTools helper function:


import { addMcpTools } from 'spiceflow/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Spiceflow } from 'spiceflow'
// Your existing MCP server
const existingServer = new Server(
{ name: 'my-server', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } },
)
// Your Spiceflow app
const app = new Spiceflow()
.use(mcp()) // Required for MCP configuration
.route({
method: 'GET',
path: '/hello',
handler() {
return 'Hello from Spiceflow!'
},
})
// Add Spiceflow tools to your existing server
const mcpServer = await addMcpTools({
mcpServer: existingServer,
app,
ignorePaths: ['/mcp', '/sse'],
})
// Now your existing server has access to all Spiceflow routes as tools

Passing state during handle, passing Cloudflare env bindings

You can use bindings type safely using a .state method and then passing the state in the handle method in the second argument. This pattern is useful for dependency injection — you can swap the env with mocks when testing with Node.js:


import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
interface Env {
KV: KVNamespace
QUEUE: Queue
SECRET: string
}
const app = new Spiceflow()
.state('env', {} as Env)
.route({
method: 'GET',
path: '/kv/:key',
async handler({ params, state }) {
const value = await state.env!.KV.get(params.key)
return { key: params.key, value }
},
})
.route({
method: 'POST',
path: '/queue',
async handler({ request, state }) {
const body = await request.json()
await state.env!.QUEUE.send(body)
return { success: true, message: 'Added to queue' }
},
})
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
// Pass the env bindings to the app
return app.handle(request, { state: { env } })
},
}

Alternative: On Cloudflare Workers you can also import { env } from 'cloudflare:workers' to access bindings directly from anywhere in your code, without threading env through .state(). See the KV caching example above for this approach.

Working with Cookies

Spiceflow works with standard Request and Response objects, so you can use any cookie library like the cookie npm package to handle cookies:


import { Spiceflow } from 'spiceflow'
import { parse, serialize } from 'cookie'
const app = new Spiceflow()
.route({
method: 'GET',
path: '/set-cookie',
handler({ request }) {
// Read existing cookies from the request
const cookies = parse(request.headers.get('Cookie') || '')
// Create response with a new cookie
const response = new Response(
JSON.stringify({
message: 'Cookie set!',
existingCookies: cookies,
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
// Set a new cookie
response.headers.set(
'Set-Cookie',
serialize('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
}),
)
return response
},
})
.route({
method: 'GET',
path: '/get-cookie',
handler({ request }) {
// Parse cookies from the request
const cookies = parse(request.headers.get('Cookie') || '')
return {
sessionId: cookies.session || null,
allCookies: cookies,
}
},
})
.route({
method: 'POST',
path: '/clear-cookie',
handler({ request }) {
const response = new Response(
JSON.stringify({ message: 'Cookie cleared!' }),
{
headers: {
'Content-Type': 'application/json',
},
},
)
// Clear a cookie by setting it with an expired date
response.headers.set(
'Set-Cookie',
serialize('session', '', {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: new Date(0),
path: '/',
}),
)
return response
},
})
app.listen(3000)

You can also use cookies in middleware for authentication or session handling:


import { Spiceflow } from 'spiceflow'
import { parse, serialize } from 'cookie'
const app = new Spiceflow()
.state('userId', null as string | null)
.use(async ({ request, state }, next) => {
// Parse cookies from incoming request
const cookies = parse(request.headers.get('Cookie') || '')
// Extract user ID from session cookie
if (cookies.session) {
// In a real app, you'd verify the session token
state.userId = cookies.session
}
const response = await next()
// Optionally refresh the session cookie
if (state.userId && response) {
response.headers.set(
'Set-Cookie',
serialize('session', state.userId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
}),
)
}
return response
})
.route({
method: 'GET',
path: '/profile',
handler({ state }) {
if (!state.userId) {
return new Response('Unauthorized', { status: 401 })
}
return { userId: state.userId, message: 'Welcome back!' }
},
})

Background tasks with waitUntil

Spiceflow provides a waitUntil function in the handler context that allows you to schedule tasks in the background in a cross platform way. It will use the Cloudflare workers waitUntil if present. It's currently a no op in Node.js.

Basic Usage


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow().route({
method: 'POST',
path: '/process',
async handler({ request, waitUntil }) {
const data = await request.json()
// Schedule background task
waitUntil(
fetch('https://analytics.example.com/track', {
method: 'POST',
body: JSON.stringify({ event: 'data_processed', data }),
}),
)
// Return response immediately
return { success: true, id: Math.random().toString(36) }
},
})

Cloudflare Workers Integration

In Cloudflare Workers, waitUntil is automatically detected from the global context:


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow().route({
method: 'POST',
path: '/webhook',
async handler({ request, waitUntil }) {
const payload = await request.json()
// Process webhook data in background
waitUntil(
processWebhookData(payload)
.then(() => console.log('Webhook processed'))
.catch((err) => console.error('Webhook processing failed:', err)),
)
// Respond immediately to webhook sender
return new Response('OK', { status: 200 })
},
})
async function processWebhookData(payload: any) {
// Simulate time-consuming processing
await new Promise((resolve) => setTimeout(resolve, 1000))
// Save to database, send notifications, etc.
}
export default {
fetch(request: Request, env: any, ctx: ExecutionContext) {
return app.handle(request, { state: { env } })
},
}

Next.js pages router integration


// 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 handleNode that could read the request body!
await mcpAuthApp.handleNode(req, res)
}
export const config = {
api: {
bodyParser: false,
},
}

Custom waitUntil Function

You can also provide your own waitUntil implementation:


import { Spiceflow } from 'spiceflow'
const app = new Spiceflow({
waitUntil: (promise) => {
// Custom implementation for non-Cloudflare environments
promise.catch((err) => console.error('Background task failed:', err))
},
}).route({
method: 'GET',
path: '/analytics',
async handler({ waitUntil }) {
// Schedule analytics tracking
waitUntil(trackPageView('/analytics'))
return { message: 'Analytics page loaded' }
},
})
async function trackPageView(path: string) {
// Track page view in analytics system
console.log(`Page view tracked: ${path}`)
}

Note: In non-Cloudflare environments, if no custom waitUntil function is provided, the default implementation is a no-op function that doesn't wait for the promises to complete.

Graceful Shutdown

The preventProcessExitIfBusy middleware prevents platforms like Fly.io from killing your app while processing long requests (e.g., AI payloads). Fly.io can wait up to 5 minutes for graceful shutdown.


import { Spiceflow, preventProcessExitIfBusy } from 'spiceflow'
const app = new Spiceflow()
.use(
preventProcessExitIfBusy({
maxWaitSeconds: 300, // 5 minutes max wait (default: 300)
checkIntervalMs: 250, // Check interval (default: 250ms)
}),
)
.route({
method: 'POST',
path: '/ai/generate',
async handler({ request }) {
const prompt = await request.json()
// Long-running AI generation
const result = await generateAIResponse(prompt)
return result
},
})
app.listen(3000)

When receiving SIGTERM during deployment, the middleware waits for all active requests to complete before exiting. Perfect for AI workloads that may take minutes to process.

Tracing (OpenTelemetry)

Spiceflow has built-in OpenTelemetry tracing. Pass a tracer to the constructor and every request gets automatic spans for middleware, handlers, loaders, layouts, pages, and RSC serialization — no monkey-patching, no plugins.

Setup

Install the OTel SDK packages alongside spiceflow:


npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/api

Create a tracing setup file that runs before your app starts. This registers the OTel SDK globally so spans are collected and exported:


// tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
const sdk = new NodeSDK({
serviceName: 'my-app',
traceExporter: new OTLPTraceExporter({
// Send traces to your collector or observability backend
url: 'http://localhost:4318/v1/traces',
}),
})
sdk.start()

Then pass a tracer to your Spiceflow app:


// main.ts
import './tracing' // must be imported first
import { trace } from '@opentelemetry/api'
import { Spiceflow } from 'spiceflow'
const app = new Spiceflow({ tracer: trace.getTracer('my-app') }).get(
'/api/users/:id',
({ params }) => {
return { id: params.id, name: 'Alice' }
},
)

What you get

Every request produces a span tree. For API routes:


GET /api/users/:id [server]
├── middleware - cors
├── middleware - auth
└── handler - /api/users/:id

For React routes with loaders and layouts:


GET /dashboard [server]
├── middleware - auth
├── loader - /dashboard
├── loader - /sidebar
├── layout - /
├── page - /dashboard
└── rsc.serialize

Each span includes standard HTTP attributes (http.request.method, http.route, http.response.status_code, url.full) following OTel semantic conventions. Errors are recorded with recordException and set the span status to ERROR. If your errors use errore tagged errors, the stable fingerprint is propagated as an error.fingerprint attribute for consistent error grouping.

Custom spans and attributes

Every handler receives span and tracer on its context. These work whether or not you configured a tracer — when no tracer is passed, they use no-op implementations that do nothing, so you never need conditional checks.

Add attributes to the current span:


.get('/api/users/:id', ({ params, span }) => {
const user = db.findUser(params.id)
span.setAttribute('user.plan', user.plan)
return user
})

Record a caught exception without re-throwing:


.post('/api/webhook', async ({ request, span }) => {
const body = await request.json()
try {
await processWebhook(body)
} catch (err) {
span.recordException(err)
}
return { ok: true }
})

Create child spans for DB calls or external APIs:


.get('/api/data', async ({ tracer, params }) => {
return tracer.startActiveSpan('db.query', async (dbSpan) => {
const data = await db.query(params.id)
dbSpan.setAttribute('db.rows', data.length)
dbSpan.end()
return data
})
})

You can also import withSpan as a convenience wrapper that handles errors and span.end() automatically:


import { withSpan } from 'spiceflow'
.get('/api/data', async ({ tracer, params }) => {
return withSpan(tracer, 'db.query', {}, async (dbSpan) => {
dbSpan.setAttribute('db.table', 'users')
return db.query(params.id)
})
})

Zero overhead without tracer

When no tracer is passed, every instrumentation point is skipped entirely — no strings allocated, no objects created, no extra async wrappers. The span and tracer on the handler context use no-op implementations whose empty methods V8 inlines away.

When using createSpiceflowFetch and getting typescript error The inferred type of '...' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)

You can resolve this issue by adding an explicit type for the client:


import type { SpiceflowFetch } from 'spiceflow/client'
export const f: SpiceflowFetch<App> = createSpiceflowFetch<App>(PUBLIC_URL)

React Framework (RSC)

Spiceflow includes a full-stack React framework built on React Server Components (RSC). It uses Vite with @vitejs/plugin-rsc under the hood. Server components run on the server by default, and you use "use client" to mark interactive components that need to run in the browser.

Setup

Install the dependencies and create a Vite config:


npm install spiceflow@rsc react react-dom


// vite.config.ts
import { defineConfig } from 'vite'
import { spiceflowPlugin } from 'spiceflow/vite'
export default defineConfig({
plugins: [
spiceflowPlugin({
entry: './src/main.tsx',
}),
],
})

Cloudflare RSC setup

For Cloudflare Workers, keep the worker-specific SSR output and child environment wiring in Vite, then let your Worker default export delegate to app.handle(request).


// wrangler.jsonc
{
"main": "spiceflow/cloudflare-entrypoint",
}


// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { spiceflowPlugin } from 'spiceflow/vite'
export default defineConfig({
plugins: [
react(),
spiceflowPlugin({ entry: './app/main.tsx' }),
cloudflare({
viteEnvironment: {
name: 'rsc',
childEnvironments: ['ssr'],
},
}),
],
})


// app/main.tsx
import { Spiceflow } from 'spiceflow'
export const app = new Spiceflow().page('/', async () => {
return <div>Hello from Cloudflare RSC</div>
})
export type App = typeof app
export default {
fetch(request: Request) {
return app.handle(request)
},
}

See cloudflare-example/ for a complete working example.

Deploying with wrangler environments

The @cloudflare/vite-plugin resolves and flattens your wrangler.json config at build time and writes it into dist/rsc/wrangler.json. When wrangler deploy runs, it reads this generated config — not your top-level wrangler.json. This means wrangler deploy --env preview alone is not enough if the build was done without specifying the environment.

Set the CLOUDFLARE_ENV env var during vite build so the plugin resolves the correct environment section:


# Build for preview environment
CLOUDFLARE_ENV=preview vite build && wrangler deploy --env preview
# Build for production (default, no env var needed)
vite build && wrangler deploy

Without CLOUDFLARE_ENV=preview, the generated dist/rsc/wrangler.json will contain the top-level config (production name, routes, KV namespaces, etc.) and --env preview will be ignored at deploy time.

Docker Deployment

The build output is self-contained — dist/ includes all traced runtime dependencies in dist/node_modules/, so you can copy it directly into a Docker image without installing packages at deploy time. The dependency tracing uses @vercel/nft to find exactly which files from node_modules/ are needed at runtime, copying only those into dist/node_modules/. This keeps the image small — typically 5-50MB of dependencies instead of hundreds of megabytes. On Vercel and Cloudflare, this step is skipped since those platforms have their own bundling.

The traced dist/node_modules/ comes from whatever is currently installed in your local node_modules/ at build time. NFT copies those files directly — no npm install runs during the Docker build.

IMPORTANT: Package managers only install native modules for your current OS and CPU by default. If you develop on macOS and deploy to Linux (Docker), native packages like esbuild, @swc/core, or lightningcss will be macOS binaries and won't work in the container. You must install dependencies for all platforms before running build.

Install the Linux native modules before building. Both pnpm and bun --os/--cpu flags are additive — they keep your current platform and add the target:


# pnpm
pnpm install --os linux --cpu x64
# bun
bun install --os linux --cpu x64

Then run the build:


pnpm build

You can add a convenience script in package.json so you don't forget this step:


{
"scripts": {
// installs linux native modules alongside current platform, then builds
"build:docker": "pnpm install --os linux --cpu x64 && pnpm build"
}
}

Example Dockerfile using node:24-slim:


FROM --platform=linux/amd64 node:24-slim
WORKDIR /app
# IMPORTANT: Before building, install Linux native modules (both flags are
# additive — they keep your current platform and add the target):
# pnpm install --os linux --cpu x64
# bun install --os linux --cpu x64
COPY dist/ ./dist/
COPY public/ ./public/
EXPOSE 3000
CMD ["node", "dist/rsc/index.js"]


docker build --platform linux/amd64 -t my-app .
docker run -p 3000:3000 my-app

App Entry (Server Component)

The entry file defines your routes using .page() for pages and .layout() for layouts. This file runs in the RSC environment on the server.

All routes registered with .page(), .get(), etc. are available in app.href() for type-safe URL building — including path params and query params.


// src/main.tsx
import { Spiceflow, serveStatic } from 'spiceflow'
import { Head, Link } from 'spiceflow/react'
import { z } from 'zod'
import { Counter } from './app/counter'
import { Nav } from './app/nav'
export const app = new Spiceflow()
.use(serveStatic({ root: './public' }))
.layout('/*', async ({ children }) => {
return (
<html>
<Head>
<Head.Meta charSet="UTF-8" />
</Head>
<body>
<Nav />
{children}
</body>
</html>
)
})
.page('/', async () => {
const data = await fetchSomeData()
return (
<div>
<h1>Welcome</h1>
<p>Server-rendered data: {data.message}</p>
<Counter />
<Link href={app.href('/users/:id', { id: '42' })}>View User 42</Link>
<Link href={app.href('/search', { q: 'spiceflow' })}>Search</Link>
</div>
)
})
.page('/about', async () => {
return (
<div>
<h1>About</h1>
<Link href={app.href('/')}>Back to Home</Link>
</div>
)
})
.page('/users/:id', async ({ params }) => {
return (
<div>
<h1>User {params.id}</h1>
</div>
)
})
// Object-style .page() with query schema — enables type-safe query params
.page({
path: '/search',
query: z.object({ q: z.string(), page: z.number().optional() }),
handler: async ({ query }) => {
const results = await search(query.q, query.page)
return (
<div>
<h1>Results for "{query.q}"</h1>
{results.map((r) => (
<p key={r.id}>{r.title}</p>
))}
</div>
)
},
})
.listen(3000)
// Export the app type for use in client components
export type App = typeof app

app.href() gives you type-safe links — TypeScript validates that the path exists, params are correct, and query values match the schema. Invalid paths or missing params are caught at compile time. The closure over app sees all routes, including ones defined later in the chain.

SEO: Titles & Descriptions

Always use <Head>, <Head.Title>, and <Head.Meta> from spiceflow/react instead of raw <head>, <title>, and <meta> tags. The Head components are type-safe, automatically deduplicated (page tags override layout tags with the same key), and correctly injected into the document head during SSR.

Every page should always have a <Head.Title> and a <Head.Meta name="description">. These are the two most important tags for SEO — they control what appears in search engine results.

Title: Keep titles under 60 characters so they don't get truncated in search results. Put the most important keywords first. Use a consistent format like Page Name | Site Name.

Description: Keep descriptions between 120–160 characters. Summarize the page content clearly — this is the snippet shown below the title in search results. Each page should have a unique description that accurately reflects its content.


.page('/', async () => {
return (
<div>
<Head>
<Head.Title>Spiceflow – Build Type-Safe APIs</Head.Title>
<Head.Meta name="description" content="A fast, type-safe API and RSC framework for TypeScript." />
</Head>
<h1>Welcome</h1>
</div>
)
})

If you want a consistent title prefix or suffix across all pages, create a wrapper component:


function PageHead({ title, description }: { title: string; description: string }) {
return (
<Head>
<Head.Title>{title} | My App</Head.Title>
<Head.Meta name="description" content={description} />
</Head>
)
}
// Then use it in any page
.page('/about', async () => {
return (
<div>
<PageHead title="About" description="Learn more about our team and mission." />
<h1>About</h1>
</div>
)
})

Type-Safe Query Params

Always define a query schema on routes and pages that accept query parameters — even when all params are optional. Use the object notation for .page() and .route() so the query requirements are documented in the route definition and accessible with full type safety in the handler:


import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
const app = new Spiceflow()
// Object notation gives you typed query access
.page({
path: '/products',
query: z.object({
category: z.string().optional(),
sort: z.enum(['price', 'name', 'date']).optional(),
page: z.coerce.number().optional(),
}),
handler: async ({ query }) => {
// query.category is string | undefined — fully typed
// query.sort is 'price' | 'name' | 'date' | undefined
// query.page is number | undefined
const products = await getProducts(query)
return (
<div>
<h1>Products</h1>
{products.map((p) => <p key={p.id}>{p.name}</p>)}
</div>
)
},
})

Without a query schema, query is Record<string, string | undefined> — you lose autocomplete, typos go unnoticed, and there's no documentation of what the page accepts.

Use href() to build links to these pages. When a route has a query schema, href enforces the correct query keys at compile time. If you rename or remove a query param from the schema, every href() call that references it becomes a type error — no stale links:


'use client'
import { Link } from 'spiceflow/react'
import { href } from './router'
export function ProductFilters() {
return (
<nav>
{/* TypeScript validates these query keys against the schema */}
<Link href={href('/products', { category: 'shoes', sort: 'price' })}>
Shoes by Price
</Link>
<Link href={href('/products', { sort: 'date', page: 2 })}>
Page 2, newest first
</Link>
{/* @ts-expect-error — 'color' is not in the query schema */}
<Link href={href('/products', { color: 'red' })}>Red</Link>
</nav>
)
}

The same pattern works for API routes with .route(). Query params are automatically coerced from strings to match the schema type — you don't need z.coerce.number(), just use z.number() directly:


const app = new Spiceflow()
.route({
method: 'GET',
path: '/api/search',
query: z.object({
q: z.string(),
limit: z.number().optional(),
offset: z.number().optional(),
}),
handler({ query }) {
// query.q is string, query.limit is number | undefined
return searchDatabase(query.q, query.limit, query.offset)
},
})

Array query params use repeated keys in the URL: ?tag=a&tag=b (not comma-separated). Single values are automatically wrapped into arrays when the schema expects z.array():


// URL: /api/posts?tag=react or /api/posts?tag=react&tag=typescript
const app = new Spiceflow().route({
method: 'GET',
path: '/api/posts',
query: z.object({
tag: z.array(z.string()),
limit: z.number().optional(),
}),
handler({ query }) {
// query.tag is always string[], even with a single ?tag=react
// query.limit is number | undefined, coerced from the string automatically
return getPostsByTags(query.tag)
},
})

Client Components

Mark interactive components with "use client" at the top of the file. These are hydrated in the browser and can use hooks like useState.


// src/app/counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}

Loaders

Loaders run on the server before page and layout handlers. They solve a common problem: when you need the same data in both server components and client components, or in both a layout and a page, without prop drilling or React context.


const app = new Spiceflow()
// Auth loader for all routes — wildcard pattern matches everything
.loader('/*', async ({ request }) => {
const user = await getUser(request.headers.get('cookie'))
if (!user) throw redirect('/login')
return { user }
})
// Page-specific loader
.loader('/dashboard', async () => {
const stats = await getStats()
return { stats }
})
.layout('/*', async ({ loaderData, children }) => {
// loaderData.user is available here from the wildcard loader
return (
<html>
<body>
<nav>{loaderData.user.name}</nav>
{children}
</body>
</html>
)
})
.page('/dashboard', async ({ loaderData }) => {
// Both loaders matched — data is merged by specificity
// loaderData = { user: ..., stats: ... }
return <Dashboard user={loaderData.user} stats={loaderData.stats} />
})

When multiple loaders match a route (e.g. /* and /dashboard both match /dashboard), their return values are merged into a single flat object. More specific loaders override less specific ones on key conflicts.

Reading loader data in client components uses the useLoaderData hook from createRouter:


// src/app/sidebar.tsx
'use client'
import { useLoaderData } from './router'
export function Sidebar() {
// Type-safe: path narrows the return type to the loaders matching '/dashboard'
const { user, stats } = useLoaderData('/dashboard')
return (
<aside>
{user.name} — {stats.totalViews} views
</aside>
)
}

Loader data updates automatically on client-side navigation — when the user navigates to a new route, the server re-runs the matching loaders and the new data arrives atomically with the new page content via the RSC flight stream.

Reading loader data outside React with getLoaderData is useful when you need data before React starts rendering, for example to initialize a ProseMirror editor, a canvas, or a WebGL scene. It reads synchronously from a global set by the server during SSR — available at module scope before any component mounts:


// src/app/editor.tsx
'use client'
import { useCallback } from 'react'
import { getLoaderData, router } from './router'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
// Top-level await — module pauses until loader data resolves from the RSC
// flight payload. Supports Date, Map, Set etc (RSC encoding, not JSON).
const { document } = await getLoaderData('/editor/:id')
const state = EditorState.create({ doc: document.content })
const view = new EditorView(null, { state })
// Update editor when loader data changes on navigation
router.subscribe(async (event) => {
if (event.action !== 'LOADER_DATA') return
const { document } = await getLoaderData('/editor/:id')
view.updateState(EditorState.create({ doc: document.content }))
})
export function Editor() {
// Mount the existing EditorView into the DOM — no useEffect needed
const ref = useCallback((node: HTMLDivElement | null) => {
if (node && !node.firstChild) node.appendChild(view.dom)
}, [])
return <div ref={ref} />
}

Error handling: if a loader throws a redirect() or notFound(), the entire request short-circuits — the page handler never runs. If a loader throws any other error, it renders through the nearest error boundary instead of showing a blank page.

Forms & Server Actions

Forms use React 19's <form action> with server functions marked "use server". They work before JavaScript loads (progressive enhancement). After a server action completes, all matching loaders re-run automatically — no manual revalidation needed.


// src/app/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
// useFormStatus must be in a component rendered inside the <form>
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}


import { redirect } from 'spiceflow'
import { SubmitButton } from './app/submit-button'
.page('/subscribe', async () => {
async function subscribe(formData: FormData) {
'use server'
const email = formData.get('email') as string
await addSubscriber(email)
throw redirect('/thank-you')
}
return (
<form action={subscribe}>
<input name="email" type="email" required />
<SubmitButton />
</form>
)
})

Use useActionState to display return values from the action. The action receives the previous state as its first argument and FormData as the second:


// src/app/newsletter.tsx
'use client'
import { useActionState } from 'react'
import { SubmitButton } from './submit-button'
export function NewsletterForm({
action,
}: {
action: (prev: string, formData: FormData) => Promise<string>
}) {
const [message, formAction] = useActionState(action, '')
return (
<form action={formAction}>
<input name="email" type="email" required />
<SubmitButton />
{message && <p>{message}</p>}
</form>
)
}


// In your server component page
.page('/newsletter', async () => {
async function subscribe(prev: string, formData: FormData) {
'use server'
const email = formData.get('email') as string
await addSubscriber(email)
return `Subscribed ${email}!`
}
return <NewsletterForm action={subscribe} />
})

If a server action throws, the error is caught by the nearest error boundary. The error message is preserved (sanitized to strip secrets) and displayed to the user in both development and production builds.

Type-Safe Client Router

Use createRouter with your app type for type-safe navigation, URL building, and loader data access in client components. Bind the app type once — all paths, params, query schemas, and loader data are inferred from arguments.


// src/app/router.ts
'use client'
import { createRouter } from 'spiceflow/react'
import type { App } from '../main'
export const { router, useRouterState, useLoaderData, getLoaderData, href } =
createRouter<App>()


// src/app/nav.tsx
'use client'
import { Link } from 'spiceflow/react'
import { href } from './router'
export function Nav() {
return (
<nav>
<Link href={href('/')}>Home</Link>
<Link href={href('/about')}>About</Link>
<Link href={href('/users/:id', { id: '1' })}>User 1</Link>
<Link href={href('/search', { q: 'docs', page: 1 })}>Search Docs</Link>
</nav>
)
}

Client-Side Navigation and Router State

The router object from createRouter handles type-safe client-side navigation. router.push and router.replace accept typed paths with autocomplete — params and query values are validated at compile time:


// src/app/search-filters.tsx
'use client'
import { router, useRouterState } from './router'
export function SearchFilters() {
const { pathname, searchParams } = useRouterState()
const query = searchParams.get('q') ?? ''
const page = Number(searchParams.get('page') ?? '1')
const sort = searchParams.get('sort') ?? 'relevance'
function setPage(n: number) {
router.push({
search: '?' + new URLSearchParams({ q: query, page: String(n), sort }),
})
}
function setSort(newSort: string) {
router.push({
search: '?' + new URLSearchParams({ q: query, page: '1', sort: newSort }),
})
}
return (
<div>
<p>
Showing results for "{query}" — page {page}, sorted by {sort}
</p>
<button onClick={() => setSort('date')}>Sort by Date</button>
<button onClick={() => setPage(page + 1)}>Next Page</button>
</div>
)
}

useRouterState() subscribes to navigation changes and re-renders the component when the URL changes. It returns the current pathname, search, hash, and a parsed searchParams (a read-only URLSearchParams).

You can also navigate to a different pathname with search params, or use router.replace to update without adding a history entry:


// Navigate to a new path with search params
router.push({
pathname: '/search',
search: '?' + new URLSearchParams({ q: 'spiceflow' }),
})
// Replace current history entry (back button skips this)
router.replace({
search: '?' + new URLSearchParams({ tab: 'settings' }),
})
// Or just use a plain string
router.push('/search?q=spiceflow&page=1')

Server Actions

Use "use server" to define functions that run on the server but can be called from client components (e.g. form actions).


// src/app/actions.tsx
'use server'
import { getActionRequest } from 'spiceflow'
export async function submitForm(formData: FormData) {
const { signal } = getActionRequest()
const name = formData.get('name')
// signal is aborted when the client disconnects or cancels —
// pass it to any downstream work so it cancels automatically
await saveToDatabase(name, { signal })
}

On the client, getActionAbortController() returns the AbortController for the most recent in-flight call to a server action, or undefined if nothing is in-flight. Call .abort() to cancel the fetch.

Streaming UI from Server Actions

Server actions can return JSX directly — including via async generators that stream React elements to the client incrementally. The RSC flight protocol serializes each yielded element as it arrives, and the client deserializes them into real React elements you can render.

This is useful for AI chat interfaces where the model generates structured output with tool calls. Instead of streaming raw text, you stream rendered UI:


// src/app/actions.tsx
'use server'
import { getActionRequest } from 'spiceflow'
import { WeatherCard } from './weather-card'
import { StockChart } from './stock-chart'
export async function* chat(
messages: { role: string; content: string }[],
): AsyncGenerator<React.ReactElement> {
// Pass the request signal to downstream work so the LLM call
// is cancelled when the client aborts (e.g. clicks "Stop")
const { signal } = getActionRequest()
const stream = await callLLM(messages, { signal })
for await (const event of stream) {
if (event.type === 'text') {
yield <p>{event.content}</p>
}
if (event.type === 'tool_call' && event.name === 'get_weather') {
const weather = await fetchWeather(event.args.city)
yield <WeatherCard city={event.args.city} weather={weather} />
}
if (event.type === 'tool_call' && event.name === 'get_stock') {
const data = await fetchStock(event.args.symbol)
yield <StockChart symbol={event.args.symbol} data={data} />
}
}
}


// src/app/chat.tsx
'use client'
import { useState, useTransition, type ReactNode } from 'react'
import { getActionAbortController } from 'spiceflow/react'
import { chat } from './actions'
export function Chat() {
const [parts, setParts] = useState<ReactNode[]>([])
const [isPending, startTransition] = useTransition()
function send(formData: FormData) {
const message = formData.get('message') as string
setParts([])
startTransition(async () => {
const stream = await chat([{ role: 'user', content: message }])
for await (const jsx of stream) {
setParts((prev) => [...prev, jsx])
}
})
}
return (
<div>
<div>{parts.map((part, i) => <div key={i}>{part}</div>)}</div>
<form action={send}>
<input name="message" placeholder="Ask something..." />
<button type="submit" disabled={isPending}>Send</button>
{isPending && (
<button type="button" onClick={() => getActionAbortController(chat)?.abort()}>
Stop
</button>
)}
</form>
</div>
)
}

Each yielded element — whether a text paragraph, a weather card, or a stock chart — arrives as a fully rendered React component. The client doesn't need to know how to render tool calls; it just accumulates whatever JSX the server sends.

Redirects and Not Found

Use redirect() and response.status inside .page() and .layout() handlers to control navigation and HTTP status codes:


import { Spiceflow, redirect } from 'spiceflow'
export const app = new Spiceflow()
.page('/dashboard', async ({ request }) => {
const user = await getUser(request)
if (!user) {
throw redirect('/login')
}
return <Dashboard user={user} />
})
.page('/posts/:id', async ({ params, response }) => {
const post = await getPost(params.id)
if (!post) {
response.status = 404
return <NotFound message={`Post ${params.id} not found`} />
}
return <Post post={post} />
})
// Catch-all page for any unmatched route — works as a custom 404 page.
// More specific routes always win over /* regardless of registration order.
.page('/*', async ({ response, params }) => {
response.status = 404
return <NotFound message={`Page not found: ${params['*']}`} />
})
// Layouts can throw redirect — useful for auth guards that protect
// an entire section of your app
.layout('/admin/*', async ({ children, request }) => {
const user = await getUser(request)
if (!user?.isAdmin) {
throw redirect('/login')
}
return <AdminLayout>{children}</AdminLayout>
})
export type App = typeof app

redirect() accepts an optional second argument for custom status codes and headers:


// 301 permanent redirect
throw redirect('/new-url', { status: 301 })
// Redirect with custom headers
throw redirect('/login', {
headers: { 'set-cookie': 'session=; Max-Age=0' },
})

response.status and response.headers — every page and layout handler receives a mutable response object on the context. Set response.status to control the HTTP status code (defaults to 200). Set response.headers to add custom headers like cache-control or set-cookie.

Correct HTTP status codes. Unlike Next.js, where redirects always return a 200 status with client-side handling, Spiceflow returns the actual HTTP status code in the response — 307 for redirects (with a Location header) and whatever you set via response.status for pages. This works even when the throw happens after an await, because the SSR layer intercepts the error from the RSC stream before flushing the HTML response. Search engines see correct status codes, and fetch() calls with redirect: "manual" get the real 307 response.

Client-side navigation. When a user clicks a <Link> that navigates to a page throwing redirect(), the router performs the redirect client-side without a full page reload.

Client Code Splitting

Code splitting of client components is automatic — you don't need React.lazy() or dynamic import(). Each "use client" file becomes a separate chunk, and the browser only loads the chunks needed for the current page.

How it works: when the RSC flight stream is sent to the browser, it contains references to client component chunks rather than the actual code. The browser resolves and loads only the chunks referenced on the current page. If route /about uses <Map /> and route /dashboard uses <Chart />, visiting /about will never download the Chart component's JavaScript.

Avoid barrel files with "use client". If you have a single file with "use client" that re-exports many components, all of them end up in one chunk — defeating code splitting. Instead, put "use client" in each individual component file:


// BAD — one big chunk for everything
// src/components/index.tsx
'use client'
export { Chart } from './chart'
export { Map } from './map'
export { Table } from './table'


// GOOD — each component is its own chunk
// src/components/chart.tsx
'use client'
export function Chart() {
/* ... */
}
// src/components/map.tsx
;('use client')
export function Map() {
/* ... */
}
// Re-export barrel has no directive, just passes through
// src/components/index.tsx
export { Chart } from './chart'
export { Map } from './map'

Do Not Manually Convert Node Requests

In user-facing code, you should almost never convert a Node.js 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.


import { Spiceflow } from 'spiceflow'
import type { IncomingMessage, ServerResponse } from 'node:http'
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)
},
}

If you find yourself writing manual request-conversion glue in app code, that is usually a sign that you should use one of these Spiceflow entrypoints instead.

Written by @__morse
Edit on GitHub