React Router
Add Prisma 8 to a React Router app in framework mode with orm init, then build user and post pages with loaders and a form action.
Introduction
This guide shows you how to use Prisma 8 in a React Router app running in framework mode. There is no create-prisma template for React Router, so you start from create-react-router and add Prisma 8 to the existing project with orm init. You then define a contract for users and posts, seed the database, read it from route loaders, and write to it from a form action.
create-react-router@latest scaffolds React Router 8. The route APIs this guide uses (loader, action, routes.ts, and the generated Route types) are the same in React Router 7, and the finished app was run on both majors.
Every command, page, and response below was run end to end against a local PostgreSQL database.
Prisma 8 is the current release of Prisma ORM. Prisma 7 remains fully supported; the Prisma 7 version of this guide is at /guides/v7/frameworks/react-router-7.
Prerequisites
- Node.js 24 or later
- A PostgreSQL connection string, or nothing at all:
npx create-db@latestcan create a Prisma Postgres database for you
Use with your agent
To delegate this guide to your coding agent, copy the prompt below and hand it over:
1. Set up your project
Create a new React Router app in framework mode:
bunx create-react-router@latest my-appAccept the defaults at the prompts: initialize a git repository and install dependencies with npm.
create-react-router v8.3.1
◼ Directory: Using my-app as project directory
◼ Using default template See https://github.com/remix-run/react-router-templates for more
✔ Template copied
✔ Dependencies installed
✔ Git initialized
done That's it!Move into the project:
cd my-app2. Add Prisma 8 to the project
2.1. Run orm init
orm init adds Prisma 8 to a project that already exists. Run it after the scaffold has installed dependencies so it picks up npm from the lockfile:
bunx prisma@latest orm init --yes --target postgres --authoring psl--target postgres selects PostgreSQL, --authoring psl keeps the schema in Prisma Schema Language, and --yes accepts the default contract path. Run orm init without flags for the guided version that asks the same questions.
Updated tsconfig.json with required compiler options.
npm add @prisma/orm-postgres dotenv
npm add -D prisma@latest
npm add -D @prisma/cli-engine@0.3.0
Emit the contract
filesWritten: src/prisma/contract.prisma, prisma.config.ts, src/prisma/db.ts,
prisma-8.md, .env.example, tsconfig.json, .gitignore,
.gitattributes, package.json
contractEmitted: trueThe command installs @prisma/orm-postgres (the Postgres runtime) and dotenv, adds the prisma CLI as a dev dependency, and writes:
prisma.config.ts: the CLI configuration. It loads.envand points at your contract.src/prisma/contract.prisma: your schema, with starterUserandPostmodels.src/prisma/db.ts: the Prisma 8 client your loaders import.src/prisma/contract.jsonandsrc/prisma/contract.d.ts: emitted from the contract; the runtime validates against the first and your queries are typed by the second.- A
contract:emitscript inpackage.json, and"module": "preserve"intsconfig.json.
There is no prisma generate step and no driver adapter package. The emitted contract is what @prisma/client and the adapter used to provide.
2.2. Set your database connection string
Create a .env file at the project root. Use your own PostgreSQL connection string, or run npx create-db@latest to get a Prisma Postgres one:
DATABASE_URL="postgres://user:password@localhost:5432/mydb"Both prisma.config.ts and src/prisma/db.ts import dotenv/config, so the CLI, the seed script, and the dev server all read this file.
2.3. Define your contract
Replace the starter models in src/prisma/contract.prisma with the blog schema this guide builds. User loses the username column and Post gains a published flag:
// use prisma-8
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt TimestamptzString @default(now())
updatedAt temporal.updatedAtString()
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
createdAt TimestamptzString @default(now())
updatedAt temporal.updatedAtString()
}TimestamptzString and temporal.updatedAtString() are the Prisma 8 idioms for a timestamptz column that reaches your code as an ISO string, which is what a React Router loader can hand to the browser without extra serialization.
Emit the contract again so contract.json and contract.d.ts match:
bunx prisma@latest contract emit"files": {
"json": "src/prisma/contract.json",
"dts": "src/prisma/contract.d.ts"
}2.4. Initialize the database
db init creates the tables from the contract and signs the database, so later commands can tell whether it still matches:
bunx prisma@latest db init"summary": "Applied 5 operation(s) across 1 space(s), database signed"The five operations are the user and post tables, the unique constraint on email, the index on authorId, and the foreign key. If you change the contract later, run npx prisma@latest contract emit followed by npx prisma@latest db update, or plan a checked-in migration with migration plan. There is no prisma migrate dev.
2.5. Seed the database
The seed is a plain script that Node.js 24 runs directly, so it imports the client with its .ts extension. Tell TypeScript to allow that:
{
"compilerOptions": {
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"strict": true
}
}Create src/prisma/seed.ts:
import { db } from "./db.ts";
const users = [
{
email: "alice@prisma.io",
name: "Alice",
posts: [
{ title: "Join the Prisma Discord", content: "https://pris.ly/discord", published: true },
{ title: "Prisma on YouTube", content: "https://pris.ly/youtube" },
],
},
{
email: "bob@prisma.io",
name: "Bob",
posts: [
{ title: "Follow Prisma on Twitter", content: "https://www.twitter.com/prisma", published: true },
],
},
];
for (const { posts, ...data } of users) {
const user = await db.orm.public.User.create(data);
for (const post of posts) {
await db.orm.public.Post.create({ ...post, authorId: user.id });
}
console.log(`Created ${user.name} with ${posts.length} post(s)`);
}
await db.close();create returns the inserted row, so the user's generated id is available for the posts without a second query. The script closes the client at the end; without that call the connection pool keeps the process alive.
Run it:
node src/prisma/seed.tsCreated Alice with 2 post(s)
Created Bob with 1 post(s)3. Integrate Prisma into React Router
3.1. Create a server-only db helper
React Router bundles route modules for the browser as well as the server. Loaders and actions are stripped from the browser build, but the safest way to keep the database client out of it is a module with a .server suffix, which React Router refuses to include in client code. Create app/lib/db.server.ts and re-export the scaffolded client from it:
export { db } from "../../src/prisma/db";Routes import it as ~/lib/db.server through the ~ alias the template configures. The client is created once per process and shared by every request; never close it in a loader or action.
3.2. Query your database from a loader
Replace app/routes/home.tsx so the home page lists users from the database:
import type { Route } from "./+types/home";
import { Link } from "react-router";
import { db } from "~/lib/db.server";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Superblog" },
{ name: "description", content: "A React Router app on Prisma 8" },
];
}
export async function loader() {
const users = await db.orm.public.User.select("id", "name", "email").all();
return { users };
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { users } = loaderData;
return (
<div className="min-h-screen flex flex-col items-center justify-center -mt-16">
<h1 className="text-4xl font-bold mb-8">Superblog</h1>
<ol className="list-decimal list-inside">
{users.map((user) => (
<li key={user.id} className="mb-2">
{user.name} ({user.email})
</li>
))}
</ol>
<Link to="/posts" className="mt-8 underline">
View posts
</Link>
</div>
);
}Model access is namespace-qualified on PostgreSQL: db.orm.public.User. select narrows the columns and all() runs the query. The Route.ComponentProps type carries the loader's return type into the component, so users is typed without any annotation.
The template's app/welcome directory is no longer imported; delete it.
Start the dev server:
bun run dev ➜ Local: http://localhost:5173/
➜ Network: use --host to exposeOpen http://localhost. The page lists Alice and Bob.
If your editor reports an error on import type { Route } from "./+types/home", run npm run dev or npm run typecheck once so React Router generates the route types.
4. Add a posts list page
Create app/routes/posts/home.tsx. The loader includes each post's author so the page can show the name:
import type { Route } from "./+types/home";
import { Link } from "react-router";
import { db } from "~/lib/db.server";
export async function loader() {
const posts = await db.orm.public.Post.include("author").orderBy((p) => p.id.asc()).all();
return { posts };
}
export default function Posts({ loaderData }: Route.ComponentProps) {
const { posts } = loaderData;
return (
<div className="min-h-screen flex flex-col items-center justify-center -mt-16">
<h1 className="text-4xl font-bold mb-8">Posts</h1>
<ul className="max-w-2xl space-y-4">
{posts.map((post) => (
<li key={post.id}>
<Link to={`/posts/${post.id}`} className="font-semibold underline">
{post.title}
</Link>
<span className="text-sm text-gray-600 ml-2">by {post.author.name}</span>
</li>
))}
</ul>
<Link to="/posts/new" className="mt-8 underline">
Create a post
</Link>
</div>
);
}Register the route in app/routes.ts:
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("posts", "routes/posts/home.tsx"),
] satisfies RouteConfig;Open http://localhost/posts: three posts, each with its author. include("author") fetches the relation in the same query, and the result type gains an author object, so post.author.name type-checks.
5. Add a post detail page
Create app/routes/posts/post.tsx. The loader reads the post by primary key and throws a 404 when there is none:
import type { Route } from "./+types/post";
import { data } from "react-router";
import { db } from "~/lib/db.server";
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.postId);
const post = Number.isInteger(id)
? await db.orm.public.Post.include("author").first({ id })
: null;
if (!post) {
throw data("Post Not Found", { status: 404 });
}
return { post };
}
export default function Post({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
return (
<div className="min-h-screen flex flex-col items-center justify-center -mt-16">
<article className="max-w-2xl space-y-4">
<h1 className="text-4xl font-bold mb-8">{post.title}</h1>
<p className="text-gray-600 text-center">by {post.author.name}</p>
<div className="prose prose-gray mt-8">{post.content || "No content available."}</div>
</article>
</div>
);
}first({ id }) is the primary-key lookup; it returns the row or null. Route params are strings, so the loader converts postId and refuses anything that is not an integer before it queries.
Add the route:
export default [
index("routes/home.tsx"),
route("posts", "routes/posts/home.tsx"),
route("posts/:postId", "routes/posts/post.tsx"),
] satisfies RouteConfig;Open http://localhost/posts/1 and http://localhost/posts/2. Then try http://localhost/posts/999: the template's root ErrorBoundary renders the 404.
6. Add a create page with an action
Create app/routes/posts/new.tsx. The action receives the form submission, inserts the post, and redirects to its detail page:
import type { Route } from "./+types/new";
import { Form, redirect } from "react-router";
import { db } from "~/lib/db.server";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const title = String(formData.get("title") ?? "").trim();
const content = String(formData.get("content") ?? "").trim();
if (!title) {
return { error: "Title is required" };
}
const post = await db.orm.public.Post.create({
title,
content: content || null,
authorId: 1,
});
return redirect(`/posts/${post.id}`);
}
export default function NewPost({ actionData }: Route.ComponentProps) {
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">Create New Post</h1>
{actionData?.error && <p className="text-red-600 mb-4">{actionData.error}</p>}
<Form method="post" className="space-y-6">
<div>
<label htmlFor="title" className="block text-lg mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
placeholder="Enter your post title"
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label htmlFor="content" className="block text-lg mb-2">
Content
</label>
<textarea
id="content"
name="content"
placeholder="Write your post content here..."
rows={6}
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<button type="submit" className="w-full bg-blue-500 text-white py-3 rounded-lg hover:bg-blue-600">
Create Post
</button>
</Form>
</div>
);
}create returns the inserted row with its generated id, published: false default, and timestamps, so the redirect target is known without another query. The post is attributed to user 1 (Alice from the seed) to keep the example short; a real app takes the author from the session.
Register the route. React Router ranks static segments above dynamic ones, so /posts/new reaches this route and not posts/:postId, whichever order you list them in:
export default [
index("routes/home.tsx"),
route("posts", "routes/posts/home.tsx"),
route("posts/:postId", "routes/posts/post.tsx"),
route("posts/new", "routes/posts/new.tsx"),
] satisfies RouteConfig;Open http://localhost/posts/new and submit the form, or post to it from the terminal:
curl -i -X POST http://localhost:5173/posts/new \
--data-urlencode "title=Hello from curl" \
--data-urlencode "content=Posted with a form action"HTTP/1.1 302
location: /posts/4Follow the redirect and the new post renders with Alice as its author. Submitting without a title returns the page with the Title is required message instead.
7. Type-check and build
React Router's typecheck script generates the route types and runs tsc across the app, the seed script, and the Prisma files:
bun run typecheckA production build works the same way as before Prisma was added. react-router build bundles the server, and react-router-serve runs it on port 3000 (set PORT to change it):
bun run build
bun run start[react-router-serve] http://localhost:3000 (http://192.168.1.16:3000)
GET /posts 200 - - 7.514 msThe server reads DATABASE_URL the same way the dev server does, through the dotenv/config import in src/prisma/db.ts, so a .env file next to the build is enough locally. On a host, set the variable in the environment instead.
Common gotchas
Keep the client out of the browser bundle. Import db from ~/lib/db.server only in loader, action, or other server code. If a component or a shared module reaches it, the build stops with Error: Server-only module referenced by client from the react-router:dot-server plugin, naming the offending import. That is the .server suffix doing its job.
Do not call db.close() in a loader or action. The client in src/prisma/db.ts is a module-level singleton whose pool serves every request; closing it after one request breaks the next. Close it only in short scripts such as the seed.
orm init installs prisma@latest as the dev dependency, so npm run contract:emit runs the same CLI as the npx prisma@latest steps in this guide until a newer release ships. Pin the prisma dev dependency if you want the package script to stay on one version.
Prompt your coding agent
Run npx prisma@latest init once to install the Prisma 8 skills for your coding agent and keep them matching your installed packages. Prompts that map to this guide:
- "Using the prisma-8 skill, add an edit page at
/posts/:postId/editwhose action updates the post withdb.orm.public.Post.where({ id }).update(...)." - "Add a delete button to the post page that removes the post in an action and redirects to
/posts." - "Only list published posts on
/posts, using awherepredicate onpublished." - "Add a
Commentmodel tosrc/prisma/contract.prisma, emit the contract, plan a migration, and render comments under each post."
Next steps
- Change the schema in
src/prisma/contract.prisma, then runnpx prisma@latest contract emitandnpx prisma@latest db update, or plan a checked-in migration withmigration plan. - Learn the fundamentals: filtering, sorting, pagination, and writes.
- Relations and joins covers
includein depth. - Read the Prisma 8 overview for the concepts behind contracts and typed queries.
- React Router documentation for loaders, actions, and routing.
