routes/dashboard.tsx, routes/dashboard._index.tsx). Spiceflow uses explicit chained methods on a single app instance.1234567891011121314151617// src/main.tsx import { Spiceflow } from 'spiceflow' export const app = new Spiceflow() .page('/dashboard', async () => <DashboardPage />) .page('/dashboard/settings', async () => <SettingsPage />) .get('/api/health', async () => ({ status: 'ok' })) .post('/api/webhooks', async ({ request }) => { const body = await request.json() await processWebhook(body) return { success: true } }) // Register the app type globally for type-safe routing declare module 'spiceflow/react' { interface SpiceflowRegister { app: typeof app } }
| Remix | Spiceflow |
routes/dashboard.tsx | .page('/dashboard', ...) |
routes/api.health.ts (GET) | .get('/api/health', ...) |
routes/api.webhooks.ts (POST) | .post('/api/webhooks', ...) |
routes/dashboard.tsx layout | .layout('/dashboard/*', ...) |
Dynamic $id segments | :id segments |
useLoaderData.loader() support. Data loaded on the server is available in any client component via useLoaderData(), fully typed.12345678910111213// routes/dashboard.tsx import { json, useLoaderData } from 'react-router' export async function loader({ request }) { const user = await getUser(request) const projects = await getProjects(user.id) return json({ user, projects }) } export default function Dashboard() { const { user, projects } = useLoaderData<typeof loader>() return <div>{user.name} has {projects.length} projects</div> }
123456789101112131415// src/main.tsx import { Spiceflow } from 'spiceflow' import { DashboardPage } from './app/dashboard-page' export const app = new Spiceflow() .loader('/dashboard', async ({ request }) => { const user = await getUser(request) const projects = await getProjects(user.id) return { user, projects } }) .page('/dashboard', async () => <DashboardPage />) declare module 'spiceflow/react' { interface SpiceflowRegister { app: typeof app } }
12345678910// src/app/dashboard-page.tsx 'use client' import { useLoaderData } from 'spiceflow/react' export function DashboardPage() { const { user, projects } = useLoaderData('/dashboard') // ^--- fully typed from the loader return value return <div>{user.name} has {projects.length} projects</div> }
declare module register at the bottom of your app entry. TypeScript infers the loader return type from your code and maps it to the route path, so useLoaderData('/dashboard') knows the exact shape without generics or typeof loader.1234567891011121314151617export const app = new Spiceflow() // Runs for /dashboard and all /dashboard/* routes .loader('/dashboard/*', async ({ request }) => { const user = await getUser(request) return { user } }) // Runs only for /dashboard/projects/:id/* .loader('/dashboard/projects/:id/*', async ({ params }) => { const project = await getProject(params.id) return { project } }) .layout('/dashboard/*', async ({ children }) => { return <DashboardShell>{children}</DashboardShell> }) .page('/dashboard/projects/:id', async () => { return <ProjectPage /> })
useLoaderData at any depth. No prop drilling needed.123456789'use client' import { useLoaderData } from 'spiceflow/react' export function ProjectHeader() { // Read data from two different loader levels const { user } = useLoaderData('/dashboard/*') const { project } = useLoaderData('/dashboard/projects/:id/*') return <h1>{project.name} — {user.name}</h1> }
redirect() to protect routes, same as Remix.12345678910111213// Remix export async function loader({ request }) { const user = await getUser(request) if (!user) return redirect('/login') return json({ user }) } // Spiceflow .loader('/dashboard/*', async ({ request, redirect }) => { const user = await getUser(request) if (!user) throw redirect('/login') return { user } })
throw redirect() (not return). Throwing short-circuits the entire request immediately.action() exports with <Form> from react-router, useActionData(), and useNavigation(). Spiceflow uses React 19 server actions with <form action>, useActionState, and useFormStatus.1234567891011121314151617181920212223242526// routes/contact.tsx import { Form, useActionData, useNavigation } from 'react-router' export async function action({ request }) { const form = await request.formData() const email = form.get('email') const result = await subscribe(email) if (result.error) return json({ error: result.error }, { status: 400 }) return redirect('/thank-you') } export default function ContactPage() { const actionData = useActionData<typeof action>() const nav = useNavigation() const isLoading = nav.state !== 'idle' return ( <Form method="post"> <input name="email" type="email" required /> <button disabled={isLoading}> {isLoading ? 'Subscribing...' : 'Subscribe'} </button> {actionData?.error && <p className="text-red-500">{actionData.error}</p>} </Form> ) }
"use server" file and import it directly in the client component. No prop drilling needed.1234567891011121314151617// src/actions.ts 'use server' import { z } from 'zod' import { parseFormData, redirect } from 'spiceflow' const subscribeSchema = z.object({ email: z.string().email() }) export async function subscribeAction( prev: { error: string } | null, formData: FormData, ) { const { email } = parseFormData(subscribeSchema, formData) const result = await subscribe(email) if (result.error) return { error: result.error } throw redirect('/thank-you') }
123// src/main.tsx export const app = new Spiceflow() .page('/contact', async () => <ContactForm />)
1234567891011121314151617181920212223242526272829303132333435363738// src/app/contact-form.tsx 'use client' import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { ErrorBoundary } from 'spiceflow/react' import { subscribeAction } from '../actions' function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? 'Subscribing...' : 'Subscribe'} </button> ) } export function ContactForm() { const [state, formAction] = useActionState(subscribeAction, null) return ( <ErrorBoundary below fallback={ <div className="text-red-500"> <ErrorBoundary.ErrorMessage /> <ErrorBoundary.ResetButton>Try again</ErrorBoundary.ResetButton> </div> } > <form action={formAction}> <input name="email" type="email" required /> <SubmitButton /> {state?.error && <p className="text-red-500">{state.error}</p>} </form> </ErrorBoundary> ) }
| Remix | Spiceflow |
export async function action() | "use server" file, imported directly in components |
<Form method="post"> | <form action={formAction}> |
useActionData() | useActionState(action, initialState) |
useNavigation().state !== 'idle' | useFormStatus().pending |
return json({ error }) in action | return { error } (plain object) |
return redirect('/path') in action | throw redirect('/path') |
| Manual error display | ErrorBoundary from spiceflow/react catches throws |
parseFormDataparseFormData(schema, formData) which validates form fields against a Zod schema. It throws a ValidationError on failure, which ErrorBoundary catches automatically. No manual error handling needed for validation.123456789101112import { z } from 'zod' import { parseFormData } from 'spiceflow' const schema = z.object({ email: z.string().email(), name: z.string().min(1, 'Name is required'), }) const fields = schema.keyof().enum // type-safe input names // In a form: <input name={fields.email} type="email" /> <input name={fields.name} />
schema.keyof().enum for input name attributes means typos become compile errors._layout.tsx, pathless layouts) become .layout() calls.123456789101112131415161718// Remix: routes/_app.tsx wraps all routes/_app.*.tsx children // Spiceflow: export const app = new Spiceflow() .layout('/*', async ({ children }) => { return ( <html lang="en"> <body> <ProgressBar /> <nav>...</nav> {children} <footer>...</footer> </body> </html> ) }) .layout('/app/*', async ({ children, loaderData }) => { return <AppShell user={loaderData.user}>{children}</AppShell> })
/app/* wraps both /app and /app/settings..page(), children is null in the layout. Render a custom 404:123.layout('/*', async ({ children }) => { return <AppLayout>{children ?? <NotFound />}</AppLayout> })
12345678// Remix import { redirect } from 'react-router' return redirect('/login') return redirect('/login', { headers }) // Spiceflow (in loaders, page handlers, server actions) throw redirect('/login') throw redirect('/login', { headers: { 'Set-Cookie': '...' } })
.use():123456789import { Spiceflow } from 'spiceflow' const apiApp = new Spiceflow({ basePath: '/api' }) .get('/users', async () => getUsers()) .post('/users', async ({ request }) => createUser(await request.json())) const mainApp = new Spiceflow() .page('/', async () => <Home />) .use(apiApp) // mounts at /api/*
1234567891011121314// Remix import { Link, useNavigate } from 'react-router' <Link to="/dashboard">Dashboard</Link> // Spiceflow import { Link, router } from 'spiceflow/react' <Link href={router.href('/dashboard')}>Dashboard</Link> // Type-safe dynamic paths <Link href={router.href('/users/:id', { id: '42' })}>User 42</Link> // Programmatic navigation router.push('/dashboard') router.replace('/settings')
router.href() validates paths against the route table at compile time. If you rename a route, every stale href() call becomes a TypeScript error.| Remix / React Router import | Spiceflow replacement |
useLoaderData from react-router | useLoaderData from spiceflow/react |
useActionData | useActionState from react |
useNavigation | useFormStatus from react-dom |
Form from react-router | <form action={serverAction}> |
useSearchParams | Props from .page() handler, or useRouterState |
redirect from react-router | redirect from spiceflow |
json / data from react-router | json from spiceflow (typed) |
Link from react-router | Link from spiceflow/react |
href from react-router | router.href() from spiceflow/react |
useParams | useLoaderData (params available in loaders) |
useSubmit | Server actions called directly |
useFetcher | Server actions + useTransition |
"use server" files"use server" file instead of defining them inline. This keeps action logic centralized and testable.1234567891011121314151617// src/actions.ts 'use server' import { z } from 'zod' import { parseFormData, redirect } from 'spiceflow' import { getActionRequest } from 'spiceflow' import { router } from 'spiceflow/react' export const postSchema = z.object({ title: z.string().min(1) }) export async function createPost(formData: FormData) { const { signal } = getActionRequest() const { title } = parseFormData(postSchema, formData) const post = await db.posts.create({ title }, { signal }) // router.href is type-safe in standalone action files throw redirect(router.href('/posts/:id', { id: post.id })) }
getActionRequest() gives access to the request signal, which is aborted when the client disconnects. Pass it to downstream work so long operations cancel automatically.getActionAbortController() from spiceflow/react lets users cancel in-flight actions.query schema on pages using the object notation. This gives you typed query access and compile-time validation on router.href().1234567891011121314151617// Remix const [searchParams] = useSearchParams() const q = searchParams.get('q') || '' // Spiceflow .page({ path: '/search', query: z.object({ q: z.string(), page: z.coerce.number().optional(), }), handler: async ({ query }) => { // query.q is string, query.page is number | undefined const results = await search(query.q, query.page) return <SearchResults results={results} /> }, })
123<Link href={router.href('/search', { q: 'docs', page: 2 })}>Search</Link> // @ts-expect-error — 'color' is not in the schema <Link href={router.href('/search', { color: 'red' })}>Red</Link>
defer() to stream slow data. Spiceflow uses loaders with unawaited promises. The loader returns immediately, the page renders with a <Suspense> fallback, and the slow data streams in via the RSC flight stream.1234567891011121314151617// src/main.tsx app .loader('/dashboard', async ({ request }) => { const user = await getUser(request) // fast, awaited const statsPromise = getExpensiveStats() // slow, NOT awaited 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> ) })
12345678// src/app/heavy-stats.tsx 'use client' import { use } from 'react' export function HeavyStats({ statsPromise }: { statsPromise: Promise<Stats> }) { const stats = use(statsPromise) return <div>{stats.totalViews} views</div> }
response.status to set HTTP status codes, and children === null in layouts to detect unmatched routes.12345678910111213141516// Remix export function loader() { const post = await getPost(id) if (!post) throw new Response('Not Found', { status: 404 }) return json({ post }) } // Spiceflow .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} /> })