<Button variant="ghost" />, but route data should usually come from useLoaderData(). This avoids prop drilling, keeps components movable, and stays type safe because the hook is inferred from the route loader path./dashboard/*, data that every project page needs in /dashboard/projects/:projectId/*, and page-only data next to the page route. Components can call useLoaderData() multiple times when they need data from multiple loader levels.useLoaderData() is cheap. It reads the already-loaded request data from React context, so different components and different React trees can call it directly without causing extra loader executions or extra network requests. Do not centralize loader data into a custom props object just to pass it back down through the tree. That pattern repeats the loader return type by hand and can drift out of sync. Prefer calling useLoaderData('/route/pattern') where the data is needed, so TypeScript keeps the component shape linked to the real loader return value.URLPattern API instead of a hand-written regex. This is most useful for parent chrome that must sit above several child layouts, like tabs that span both the sidebar and content frame. Prefer splitting layouts and loaders cleanly so you do not need this pattern in normal route trees.1234567891011121314151617export const app = new Spiceflow() .loader('/dashboard/*', async ({ request }) => { const projectId = new URLPattern({ pathname: '/dashboard/projects/:projectId/*' }) .exec(request.url)?.pathname.groups.projectId ?? null const user = await getUser(request) return { user, projectId } }) .layout('/dashboard/*', async ({ loaderData, children }) => { return ( <DashboardShell> {loaderData.projectId && <ProjectTabs projectId={loaderData.projectId} />} {children} </DashboardShell> ) })
.page() or .layout(). They are not standalone endpoints. If you want to serve content without rendering a page or layout, use .get(), .route(), or another API handler instead.1234567891011121314151617181920212223242526272829export const app = new Spiceflow() .page('/login', async () => <Login />) // Auth loader for the dashboard route .loader('/dashboard', async ({ request, redirect }) => { 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('/dashboard', async ({ loaderData, children }) => { // loaderData.user is available here from the dashboard 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 /> })
12345678910111213141516171819202122232425export const app = new Spiceflow() .loader('/dashboard/*', async ({ request }) => { const user = await getUser(request) const projects = await getProjects(user.id) return { user, projects } }) .layout('/dashboard/*', async ({ children }) => { return <DashboardShell>{children}</DashboardShell> }) .loader('/dashboard/projects/:projectId/*', async ({ params }) => { const project = await getProject(params.projectId) const environments = await getProjectEnvironments(params.projectId) return { project, environments } }) .loader('/dashboard/projects/:projectId/secrets', async ({ params }) => { const secrets = await getSecrets(params.projectId) return { secrets } }) .page('/dashboard/projects/:projectId/secrets', async () => { return <SecretsPage /> }) declare module 'spiceflow/react' { interface SpiceflowRegister { app: typeof app } }
123456789101112131415161718// src/app/dashboard-shell.tsx 'use client' import { useLoaderData } from 'spiceflow/react' import type { ReactNode } from 'react' export function DashboardShell({ children }: { children: ReactNode }) { const { user, projects } = useLoaderData('/dashboard/*') return ( <div> <aside> <p>{user.name}</p> {projects.map((project) => <a key={project.id} href={project.href}>{project.name}</a>)} </aside> <main>{children}</main> </div> ) }
12345678910111213141516171819202122// src/app/secrets-page.tsx 'use client' import { useLoaderData } from 'spiceflow/react' export function SecretsPage() { const { project, environments } = useLoaderData('/dashboard/projects/:projectId/*') const { secrets } = useLoaderData('/dashboard/projects/:projectId/secrets') return ( <section> <h1>{project.name}</h1> <p>{environments.length} environments</p> <SecretsTable /> </section> ) } export function SecretsTable() { const { secrets } = useLoaderData('/dashboard/projects/:projectId/secrets') return secrets.map((secret) => <div key={secret.id}>{secret.name}</div>) }
1234// Avoid this for route data. It grows brittle as pages get deeper. export function SecretsPage({ project, environments, secrets }) { return <SecretsTable secrets={secrets} /> }
secrets to secretRows in the loader, every useLoaderData('/dashboard/projects/:projectId/secrets') call that still reads secrets becomes a TypeScript error.12345// More specific loaders can still read merged data when that is simpler. export function ProjectHeader() { const { user, project } = useLoaderData('/dashboard/projects/:projectId/*') return <h1>{project.name} for {user.name}</h1> }
1234export function ProjectSwitcher() { const { projects } = useLoaderData('/dashboard/*') return projects.map((project) => <a href={project.href}>{project.name}</a>) }
123456789// Server routes can still read loaderData directly when rendering simple markup. export const app = new Spiceflow() .loader('/account', async ({ request }) => { const user = await getUser(request) return { user } }) .page('/account', async ({ loaderData }) => { return <h1>{loaderData.user.name}</h1> })
/* 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.SpiceflowRegister. useLoaderData('/dashboard/projects/:id') and router.getLoaderData('/dashboard/projects/:id') infer the merged object returned by every matching loader, so renaming a loader field or removing it becomes a TypeScript error in every component that reads it.Promise, async iterators, Map, Set, Date, BigInt, typed arrays, and any client component reference — all deserialized faithfully on the client. This means a loader can return a fully rendered <Sidebar user={user} /> element and another component can receive it as loaderData.sidebar and drop it into the tree.useLoaderData hook from spiceflow/react:1234567891011121314// src/app/sidebar.tsx 'use client' import { useLoaderData } from 'spiceflow/react' 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> ) }
router import. This works in client code outside React components and during active server render. Call it inside component scope, event handlers, or helper functions tied to the current render flow instead of binding request-sensitive access at module scope:12345678910111213141516171819// src/app/editor-toolbar.tsx 'use client' import { router, useLoaderData } from 'spiceflow/react' async function readCurrentDocument() { return router.getLoaderData('/editor/:id') } export function EditorToolbar() { const { document } = useLoaderData('/editor/:id') async function refresh() { const next = await readCurrentDocument() console.log(next.document.title) } return <button onClick={refresh}>{document.title}</button> }
use()Promise to start a slow fetch without blocking the page render. The promise travels through the RSC flight stream to the client, where a 'use client' component calls use(promise) inside a <Suspense> boundary. The page HTML is sent immediately with the fallback; the real content streams in once the promise settles.1234567891011121314151617181920// src/main.tsx import { Suspense } from 'react' import { HeavyStats } from './app/heavy-stats' app .loader('/dashboard', async ({ request }) => { const user = await getUser(request) // fast, awaited — blocks nothing const statsPromise = getExpensiveStats() // slow, NOT awaited — streams later return { user, statsPromise } }) .page('/dashboard', async ({ loaderData }) => { return ( <div> <h1>Welcome {loaderData.user.name}</h1> <Suspense fallback={<p>Loading stats…</p>}> <HeavyStats statsPromise={loaderData.statsPromise} /> </Suspense> </div> ) })
123456789// src/app/heavy-stats.tsx 'use client' import { use } from 'react' export function HeavyStats({ statsPromise }: { statsPromise: Promise<Stats> }) { const stats = use(statsPromise) // suspends here until the promise resolves return <div>{stats.totalViews} views</div> }
getUser(), so the page server component renders and the RSC flight stream starts. The statsPromise is serialized into the stream as a pending promise reference. On the client, use(statsPromise) suspends HeavyStats and React shows the <Suspense> fallback. When the promise resolves on the server, the result is flushed into the same flight stream and React replaces the fallback with the real component — no extra HTTP round-trip.await getExpensiveStats() in the loader because the page skeleton reaches the browser immediately instead of waiting for the slow fetch to finish before any HTML is sent.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.Promise.all for independent fetches instead of sequential awaits:12345678.page('/dashboard', async () => { const [user, posts, analytics] = await Promise.all([ getUser(), getPosts(), getStats(), ]) return <Dashboard user={user} posts={posts} analytics={analytics} /> })
<form action> with server functions marked "use server". They work before JavaScript loads (progressive enhancement).action is a string URL. This is standard HTML behavior in Spiceflow: the browser submits the form to the URL and performs a full document navigation.12345678.page('/search', async () => { return ( <form method="get" action="/results"> <input name="q" /> <button type="submit">Search</button> </form> ) })
action lets React handle submission in a transition instead of doing a full browser reload. A server action can mutate data, then automatically re-render the current page with fresh server data or throw the handler context redirect to navigate. A client action can update local state, call APIs, or schedule a client navigation with router.push() / router.replace().1234<form action={saveSettings}> <input name="name" /> <Button type="submit">Save</Button> </form>
router.refresh() afterward. Use it only when data changes outside a server action.useFormStatus from react-dom in your Button component to auto-detect pending forms — the button shows a spinner automatically when it's inside a <form> with a pending action:"use server" (a dedicated file like src/actions.tsx) over inline "use server" inside function bodies. Inline is fine for simple form actions defined directly in a server component page, or when the action needs the handler context redirect. If you find yourself passing actions as props to client components, import them from a "use server" file instead — it keeps action logic centralized and reusable.12345678910111213// src/app/button.tsx 'use client' import { useFormStatus } from 'react-dom' export function Button({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) { const { pending } = useFormStatus() const loading = props.type === 'submit' && pending return ( <button disabled={loading} {...props}> {loading ? 'Loading...' : children} </button> ) }
parseFormData to validate form fields with a Zod schema, and schema.keyof().enum for type-safe input name attributes (typos become compile errors):12345678910111213141516171819202122import { z } from 'zod' import { parseFormData } from 'spiceflow' import { Button } from './app/button' const subscribeSchema = z.object({ email: z.string().email() }) const fields = subscribeSchema.keyof().enum .page('/thank-you', async () => <ThankYou />) .page('/subscribe', async ({ redirect }) => { async function subscribe(formData: FormData) { 'use server' const { email } = parseFormData(subscribeSchema, formData) await addSubscriber(email) throw redirect('/thank-you') } return ( <form action={subscribe}> <input name={fields.email} type="email" required /> <Button type="submit">Subscribe</Button> </form> ) })
useActionState to display return values from the action. The action receives the previous state as its first argument and FormData as the second:12345678910111213// src/actions.tsx 'use server' import { z } from 'zod' import { parseFormData } from 'spiceflow' export const subscribeSchema = z.object({ email: z.string().email() }) export async function subscribe(prev: string, formData: FormData) { const { email } = parseFormData(subscribeSchema, formData) await addSubscriber(email) return `Subscribed ${email}!` }
12345678910111213141516171819202122// src/app/newsletter.tsx 'use client' import { useActionState } from 'react' import { Button } from './button' import { subscribeSchema } from '../actions' const fields = subscribeSchema.keyof().enum export function NewsletterForm({ action, }: { action: (prev: string, formData: FormData) => Promise<string> }) { const [message, formAction] = useActionState(action, '') return ( <form action={formAction}> <input name={fields.email} type="email" required /> <Button type="submit">Subscribe</Button> {message && <p>{message}</p>} </form> ) }
1234// In your server component page .page('/newsletter', async () => { return <NewsletterForm action={subscribe} /> })
123456// src/actions.ts 'use server' export async function deletePost(id: string) { await db.posts.delete(id) }
1234567891011121314151617// src/app/delete-button.tsx 'use client' import { deletePost } from '../actions' export function DeleteButton({ id }: { id: string }) { return ( <button onClick={async () => { await deletePost(id) // page re-renders automatically — no router.refresh() needed }} > Delete </button> ) }
FormData), skip the <form> wrapper entirely. Use useTransition for pending state instead of useFormStatus (which requires a parent <form>):123456789101112131415161718// src/app/checkout-button.tsx 'use client' import { useTransition } from 'react' import { startCheckout } from '../actions' export function CheckoutButton() { const [pending, startTransition] = useTransition() return ( <button type="button" disabled={pending} onClick={() => startTransition(() => startCheckout())} > {pending ? 'Redirecting...' : 'Subscribe'} </button> ) }
<form action={startCheckout}> with useFormStatus(), and avoids layout issues caused by the extra <form> element. Use <form> only when you actually need FormData (input fields) or progressive enhancement (works before JS loads).router.refresh() is fire-and-forget. Do not build awaitable navigation or refresh helpers and then use them inside a React client form action (<form action={async () => { ... }}>). React keeps that form action transition pending until the action returns, so awaiting the refresh or navigation commit from inside the action can deadlock the page."use server" to define functions that run on the server but can be called from client components (e.g. form actions).123456789101112131415// src/app/actions.tsx 'use server' import { z } from 'zod' import { getActionRequest, parseFormData } from 'spiceflow' export const contactSchema = z.object({ name: z.string().min(1) }) export async function submitForm(formData: FormData) { const { signal } = getActionRequest() const { name } = parseFormData(contactSchema, formData) // signal is aborted when the client disconnects or cancels — // pass it to any downstream work so it cancels automatically await saveToDatabase(name, { signal }) }
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..get(), .post(), etc.) and any middleware that modifies state. See the Security guide for patterns.Origin header of POST requests is checked against the app's origin. This check is disabled in development (when vite dev is running) so tunnels and proxies work without issues. In production, the origin check works automatically on any hosting platform (Cloudflare Workers, Node.js, Vercel, etc.) because the browser's Origin header matches the server's URL.request.url differs from the browser's origin), server actions return 403 Forbidden: origin mismatch. Use allowedActionOrigins to allow additional origins:123456const app = new Spiceflow({ allowedActionOrigins: [ 'https://my-app.example.com', /\.my-proxy\.dev$/, ], })
RegExp tested against the request's Origin header. You do not need this on Cloudflare Workers, Vercel, Fly.io, or any platform where the request URL already matches your domain.1234567891011121314151617181920212223242526272829// 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} /> } } }
123456789101112131415161718192021222324252627282930313233343536373839404142// src/app/chat.tsx 'use client' import { z } from 'zod' import { useState, useTransition, type ReactNode } from 'react' import { getActionAbortController } from 'spiceflow/react' import { parseFormData } from 'spiceflow' import { chat } from './actions' const chatSchema = z.object({ message: z.string().min(1) }) const fields = chatSchema.keyof().enum export function Chat() { const [parts, setParts] = useState<ReactNode[]>([]) const [isPending, startTransition] = useTransition() function send(formData: FormData) { const { message } = parseFormData(chatSchema, formData) 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={fields.message} placeholder="Ask something..." /> <button type="submit" disabled={isPending}>Send</button> {isPending && ( <button type="button" onClick={() => getActionAbortController(chat)?.abort()}> Stop </button> )} </form> </div> ) }
ErrorBoundary. The error message is preserved (sanitized to strip secrets) and displayed to the user in both development and production builds.ErrorBoundary from spiceflow/react to catch errors from form actions. It provides ErrorBoundary.ErrorMessage and ErrorBoundary.ResetButton sub-components that read the error and reset function from context — so they work as standalone elements anywhere in the fallback tree.parseFormData for validation — it throws a ValidationError when the schema fails, which ErrorBoundary catches automatically:12345678910111213// src/actions.ts 'use server' import { z } from 'zod' import { parseFormData } from 'spiceflow' export const postSchema = z.object({ title: z.string().min(1, 'Title is required') }) export async function createPost(formData: FormData) { const { title } = parseFormData(postSchema, formData) const post = await db.posts.create({ title }) return { id: post.id } }
12345678910111213141516171819202122232425// src/app/create-post.tsx 'use client' import { ErrorBoundary } from 'spiceflow/react' import { createPost, postSchema } from '../actions' const fields = postSchema.keyof().enum export function CreatePostForm() { return ( <ErrorBoundary fallback={ <div> <ErrorBoundary.ErrorMessage className="text-red-500" /> <ErrorBoundary.ResetButton>Try again</ErrorBoundary.ResetButton> </div> } > <form action={createPost}> <input name={fields.title} required /> <Button type="submit">Create</Button> </form> </ErrorBoundary> ) }
ErrorBoundary.ErrorMessage renders a <div> with white-space: pre-wrap and ErrorBoundary.ResetButton renders a <button>. Both accept all their respective HTML element props via ...props spread, so you can pass className, style, data-testid, etc. Long error messages are truncated to 10 lines by default with a "Show more" toggle. Override with <ErrorBoundary.ErrorMessage maxLines={5} />.| Source | Where it runs | ErrorBoundary catches? |
| Server action throws | Server | Yes |
parseFormData in a server action (no try/catch) | Server | Yes (ValidationError propagates) |
parseFormData in a client form action | Browser | Yes (thrown inside React's form action transition) |
ErrorBoundary catches the error, hides the form, and renders the fallback with the error message and a reset button. Clicking "Try again" restores the form. The error boundary also auto-resets when the user navigates to a different page.parseFormData client-side inside the form action, then call the server action with the validated data. This gives instant validation feedback without a server round-trip, and the ValidationError is still caught by ErrorBoundary:123456789101112131415161718192021222324// src/app/create-contact.tsx 'use client' import { parseFormData } from 'spiceflow' import { ErrorBoundary } from 'spiceflow/react' import { contactSchema } from '../schemas' import { createContact } from '../actions' const fields = contactSchema.keyof().enum export function CreateContactForm() { return ( <ErrorBoundary fallback={...}> <form action={async (formData: FormData) => { const data = parseFormData(contactSchema, formData) // client-side validation await createContact(data) // server action }}> <input name={fields.name} /> <input name={fields.email} type="email" /> <Button type="submit">Create</Button> </form> </ErrorBoundary> ) }
above and belowErrorBoundary replaces the form with the fallback when an error occurs. This causes layout shift and the user loses sight of their filled inputs. Use above or below to keep the form visible and interactive alongside the error message:1234567891011<ErrorBoundary below fallback={ <div className="text-red-500"> <ErrorBoundary.ErrorMessage /> <ErrorBoundary.ResetButton>Dismiss</ErrorBoundary.ResetButton> </div> }> <form action={submitForm}> <input name={fields.name} /> <Button type="submit">Save</Button> </form> </ErrorBoundary>
below puts the error below the form. above puts it above. The form stays fully interactive; the user can fix their inputs and resubmit directly without clicking reset first. This works because form action errors don't invalidate the children's render tree; the error comes from the action, not from rendering.startTransition if you want pending state (isPending) and non-blocking behavior while the server data loads:123456789101112131415161718192021import { useTransition } from 'react' function DeleteButton({ id }: { id: string }) { const [isPending, startTransition] = useTransition() return ( <button disabled={isPending} onClick={() => { startTransition(async () => { try { await deletePost({ id }) } catch (e) { alert(e.message) } }) }} > {isPending ? 'Deleting...' : 'Delete'} </button> ) }