Project structure
A React Router or Remix route module exports a loader, an action and a component from one file. The loader and action run on the server; the component renders on both sides. Database access goes in the first two, through a client module the bundler is not allowed to ship:
app/
db/schema.ts tables and relations
db.server.ts the client (".server" keeps it off the client)
routes/posts.tsx loader, action and component
routes/posts.$id.tsx one post
routes.ts route config (React Router 7)
kastor.config.ts schema: './app/db/schema.ts'
kastor/migrations/
.env DATABASE_URLnpm install kastor-orm pg
npx kastor-orm init --db postgres
npx kastor migrate dev --name initThe client in app/db.server.ts
The dev server reloads server modules on change. Keep the client on globalThis so every reload reuses one pool.
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './db/schema'
const create = () =>
kastor({
adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 })),
schema,
relations,
})
const g = globalThis as unknown as { db?: ReturnType<typeof create> }
export const db = g.db ?? create()
if (process.env.NODE_ENV !== 'production') g.db = dbReading in loaders
A loader returns data; the component reads it with useLoaderData, typed from the loader's return value. In React Router 7 you can also use the generated Route.LoaderArgs types; the plain LoaderFunctionArgs type below works in both versions.
import { Form, redirect, useActionData, useLoaderData, type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router'
import { isKastorError } from 'kastor-orm'
import { db } from '../db.server'
export async function loader({ request }: LoaderFunctionArgs) {
const q = new URL(request.url).searchParams.get('q') ?? ''
const posts = await db.posts.findMany({
where: { published: true, title: { contains: q, mode: 'insensitive' } },
include: { author: { select: { name: true } }, _count: { select: { tags: true } } },
orderBy: { id: 'desc' },
take: 20,
})
return { posts, q }
}
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData()
const title = String(form.get('title') ?? '').trim()
if (!title) return { error: 'A title is required.' }
try {
const post = await db.posts.create({ data: { title, authorId: Number(form.get('authorId')) } })
return redirect(`/posts/${post.id}`)
} catch (e) {
if (isKastorError(e) && e.code === 'FOREIGN_KEY_VIOLATION') return { error: 'Unknown author.' }
throw e
}
}
export default function Posts() {
const { posts, q } = useLoaderData<typeof loader>()
const result = useActionData<typeof action>()
return (
<main>
<Form method="get"><input name="q" defaultValue={q} /></Form>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title} by {p.author.name} ({p._count.tags} tags)</li>
))}
</ul>
<Form method="post">
<input name="title" />
<input name="authorId" type="hidden" value="1" />
<button>Create</button>
{result?.error ? <p>{result.error}</p> : null}
</Form>
</main>
)
}Actions, params and 404s
Actions receive the submitted form; after a successful write, return a redirect so a refresh does not submit twice. For a detail page, read the param, and throw a Response with status 404 when the row is missing: the nearest error boundary renders it.
import { type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router'
import { db } from '../db.server'
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.posts.findUnique({
where: { id: Number(params.id) },
include: { author: true, tags: true },
})
if (!post) throw new Response('Not found', { status: 404 })
return { post }
}
export async function action({ params, request }: ActionFunctionArgs) {
const intent = (await request.formData()).get('intent')
const id = Number(params.id)
if (intent === 'publish') return db.posts.update({ where: { id }, data: { published: true } })
if (intent === 'delete') return db.posts.delete({ where: { id } })
throw new Response('Unknown intent', { status: 400 })
}Multi step writes belong in one transaction. Inside an action, db.$transaction(async (tx) => ...) commits when the callback resolves and rolls back if it throws, including when you throw a redirect or a Response.
Environment variables and migrations on deploy
react-router build produces build/server and build/client, and react-router-serve runs the server build as one long lived Node process, so one pool per process is the right shape. Read secrets from process.env (the server never inlines them into the client build) and run migrations before the new server starts:
{
"scripts": {
"build": "react-router build",
"start": "kastor migrate deploy && react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
}
}On Vercel or Netlify, loaders run as functions: keep the pool small, use a pooled URL, and run npx kastor migrate deploy in CI with the direct URL. On Cloudflare, read context.cloudflare.env.DB in each loader and action and create the client with d1().
Patterns that keep loaders fast
Every navigation runs the loaders of the matched routes in parallel, and every action triggers revalidation of the loaders on the page. That makes loader cost visible quickly. Three habits help. Select only what the component renders, using select on the root and on includes. Load nested data with include in the same call instead of awaiting several queries one after another, since kastor builds one statement per call. And move independent reads into the same Promise.all, or into the array form of $transaction when they must see the same snapshot.
For forms that write several rows, keep the writes in one db.$transaction(async (tx) => ...) inside the action, and return or redirect after it resolves. If validation fails, return the errors before opening the transaction so no connection is held while the user fixes the form.
Production checklist
Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:
- The client lives in a
.servermodule. - Loaders select only the fields the component renders.
- Actions redirect after successful writes.
- Function deployments use a pooled URL and a small pool.
- The start script or CI applies migrations before the new server takes traffic.
Pitfalls
- Importing db in components. Only loaders, actions and other
.servermodules may importdb.server.ts. A component import breaks the build, which is the point. - clientLoader.
clientLoaderruns in the browser and cannot query the database; call your server loader or an API route instead. - Revalidation. After an action, React Router revalidates every loader on the page. Keep loaders cheap with
selectandtake, and useshouldRevalidatewhere a loader does not depend on the action. - Bigint keys. turbo-stream keeps
bigint, but anything you send throughResponse.jsondoes not; convert withString(id).
Questions people ask
Q1How do I keep the database client out of the browser bundle?
Name the module db.server.ts (or put it in a .server folder). React Router and Remix refuse to include .server modules in client code, and they strip loaders and actions from the client build.
Q2Is this guide for Remix or React Router?
Both. Remix 2 apps and React Router 7 framework mode apps use the same loader and action model. Imports come from react-router in v7 and from @remix-run/node and @remix-run/react in Remix 2.
Q3Can I return Date values from a loader?
With single fetch (the default in React Router 7), loader data is serialized with turbo-stream, which keeps Date and bigint. Older Remix setups that use json() turn dates into strings.
Q4Does it run on Cloudflare?
Yes. With the Cloudflare template, the D1 binding arrives on context.cloudflare.env.DB in loaders and actions; create the client with d1() per request.