Prisma ORM 8 is here.Read the docs
Frameworks

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.

Prerequisites

  • Node.js 24 or later
  • A PostgreSQL connection string, or nothing at all: npx create-db@latest can 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:

Use with your agent
Use with your agent
Create a new React Router app in framework mode, add Prisma 8 to it, and build a small blog on top.

1. Scaffold: `npx create-react-router@latest my-app --yes`, then `cd my-app`. Run `npx prisma@latest orm init --yes --target postgres --authoring psl` so Prisma 8 is added to the existing project, then `npx prisma@latest init` so the Prisma agent skills are installed and stay current, and use them.
2. Get a database connection string: use the one I give you, or create a Prisma Postgres database with `npx create-db@latest` and show me the claim URL it prints. Write it to `.env` as `DATABASE_URL`; the scaffolded `src/prisma/db.ts` and `prisma.config.ts` both load `.env`.
3. Edit `src/prisma/contract.prisma` so `User` has `id`, `email`, `name`, `posts`, `createdAt`, `updatedAt` and `Post` has `id`, `title`, `content`, `published`, `author`, `authorId`, `createdAt`, `updatedAt`, following https://www.prisma.io/docs/guides/frameworks/react-router-7.md. Run `npx prisma@latest contract emit` and `npx prisma@latest db init`.
4. Add `allowImportingTsExtensions: true` to `tsconfig.json`, write `src/prisma/seed.ts` that creates two users with posts and closes the client, and run it with `node src/prisma/seed.ts`.
5. Create `app/lib/db.server.ts` that re-exports `db` from `src/prisma/db.ts`. Build these routes in `app/routes.ts`: `/` lists users, `/posts` lists posts with their author, `/posts/new` has a form whose action creates a post and redirects to it, `/posts/:postId` shows one post or a 404.
6. Start `npm run dev` in the background, wait until it reports ready, verify every route with curl including a POST to `/posts/new`, then stop the dev server. Run `npm run typecheck` and fix anything it reports.

1. Set up your project

Create a new React Router app in framework mode:

bunx create-react-router@latest my-app

Accept 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-app

2. 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: true

The 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 .env and points at your contract.
  • src/prisma/contract.prisma: your schema, with starter User and Post models.
  • src/prisma/db.ts: the Prisma 8 client your loaders import.
  • src/prisma/contract.json and src/prisma/contract.d.ts: emitted from the contract; the runtime validates against the first and your queries are typed by the second.
  • A contract:emit script in package.json, and "module": "preserve" in tsconfig.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:

.env
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:

src/prisma/contract.prisma
// 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:

tsconfig.json
{
  "compilerOptions": {
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true, 
    "skipLibCheck": true,
    "strict": true
  }
}

Create src/prisma/seed.ts:

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.ts
Created 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:

app/lib/db.server.ts
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:

app/routes/home.tsx
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 expose

Open http://localhost

. The page lists Alice and Bob.

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:

app/routes/posts/home.tsx
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:

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:

app/routes/posts/post.tsx
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:

app/routes.ts
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:

app/routes/posts/new.tsx
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:

app/routes.ts
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/4

Follow 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 typecheck

A 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 ms

The 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

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/edit whose action updates the post with db.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 a where predicate on published."
  • "Add a Comment model to src/prisma/contract.prisma, emit the contract, plan a migration, and render comments under each post."

Next steps

On this page