Skip to main content
Prisma Documentation Docs

Search documentation

Type to search this documentation.

On this pageOverview

React Router

This guide shows you how to use Prisma ORM 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 ORM 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.

  • 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

To delegate this guide to your coding agent, copy the prompt below and hand it over:

with
Create a new React Router app in framework mode, add Prisma ORM 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 ORM 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 contract emit` and `npx prisma 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.

Create a new React Router app in framework mode:

title="bun"
bunx create-react-router@latest my-app
pnpm
pnpm dlx create-react-router@latest my-app
yarn
yarn dlx create-react-router@latest my-app
npm
npx 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

orm init adds Prisma ORM 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
Bash
pnpm dlx prisma@latest orm init --yes --target postgres --authoring psl
Bash
yarn dlx prisma@latest orm init --yes --target postgres --authoring psl
Bash
npx 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.

no-copy
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.6.1
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 ORM 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.

--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.6.1

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 ORM 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.

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:

title=".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.

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:

title="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 ORM 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 contract emit
Bash
pnpm prisma contract emit
Bash
yarn prisma contract emit
Bash
npx prisma contract emit
no-copy
"files": {
  "json": "src/prisma/contract.json",
  "dts": "src/prisma/contract.d.ts"
}
"files": {

  "json": "src/prisma/contract.json",

  "dts": "src/prisma/contract.d.ts"

}

db init creates the tables from the contract and signs the database, so later commands can tell whether it still matches:

bunx prisma db init
Bash
pnpm prisma db init
Bash
yarn prisma db init
Bash
npx prisma db init
no-copy
"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 contract emit followed by npx prisma db update, or plan a checked-in migration with migration plan. There is no prisma migrate dev.

"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 contract emit followed by npx prisma db update, or plan a checked-in migration with migration plan. There is no prisma migrate dev.

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:

title="tsconfig.json"
{

  "compilerOptions": {

    "resolveJsonModule": true,

    "allowImportingTsExtensions": true, 

    "skipLibCheck": true,

    "strict": true

  }

}

Create src/prisma/seed.ts:

title="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)

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:

title="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.

Replace app/routes/home.tsx so the home page lists users from the database:

title="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
Bash
pnpm run dev
Bash
yarn dev
Bash
npm run dev
no-copy
  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

Open http://localhost:5173. The page lists Alice and Bob.

[!NOTE] 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.

  ➜  Local:   http://localhost:5173/

  ➜  Network: use --host to expose

Open http://localhost. The page lists Alice and Bob.

Create app/routes/posts/home.tsx. The loader includes each post's author so the page can show the name:

title="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:

title="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.

Create app/routes/posts/post.tsx. The loader reads the post by primary key and throws a 404 when there is none:

title="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:

title="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.

Create app/routes/posts/new.tsx. The action receives the form submission, inserts the post, and redirects to its detail page:

title="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:

title="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.

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
Bash
pnpm run typecheck
Bash
yarn typecheck
Bash
npm 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):

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
Bash
pnpm run build
pnpm run start
Bash
yarn build
yarn run start
Bash
npm run build
npm run start
no-copy
[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.

[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.

Run npx prisma@latest init once to install the Prisma ORM 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."
Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu