Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use Prisma with DaloyJS

Prisma is a schema-first ORM with first-class migrations and a generated, fully typed client. It pairs well with DaloyJS's contract-first routes: Zod validates the wire, Prisma validates the database. This guide targets Prisma ORM 7.

One request through Prisma
  1. 01clientHTTP requestGET /users/:id
  2. 02zodValidated inputparams.id is a uuid
  3. 03prismaTyped querystate.db.user.findUnique(...)
  4. 04responseTyped body200 UserSchema | 404
Zod validates the request before your handler runs, Prisma runs the typed query off state.db, and the result is checked against the response schema on the way out. Two validation layers guard the wire and the database.

1. Install

Pin the CLI, the client, and the driver adapter to the same major:

bash
pnpm add @prisma/client@^7 @prisma/adapter-pg@^7 dotenv
pnpm add -D prisma@^7
pnpm prisma init --datasource-provider postgresql

2. Define your schema

prisma/schema.prismaprisma
datasource db {
  provider = "postgresql"
}

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

model User {
  id    String  @id @default(uuid())
  email String  @unique
  name  String?
}
prisma.config.tsts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: { path: "prisma/migrations" },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

Prisma's current prisma-client generator writes the client to the configured output path. Connection URLs live in prisma.config.ts, and application code imports PrismaClient from the generated path instead of @prisma/client.

bash
pnpm prisma migrate dev --name init
pnpm prisma generate

migrate dev is for development. In production, run prisma migrate deploy as a deploy step, never at app boot (see migrations).

3. Create a Prisma plugin

Instantiate one PrismaClient per process, decorate the app, and disconnect on shutdown.

src/db/prisma.tsts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.ts";
import type { App } from "@daloyjs/core";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
export const prisma = new PrismaClient({
  adapter,
  log: process.env.NODE_ENV === "production" ? ["error"] : ["query", "error"],
});

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export async function prismaPlugin(app: App) {
  await prisma.$connect();
  app.decorate("db", prisma);
  app.onClose(async () => {
    await prisma.$disconnect();
  });
}

onClose() runs on the Node.js, Bun, and Deno adapters only. On Lambda, Vercel, and Azure Functions keep the client at module scope with a small pool, and see Serverless and edge lifecycle.

4. Augment app state types

Add the declare module block to the same module that creates the client, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.

src/db/prisma.tsts
// same module as the plugin above
declare module "@daloyjs/core" {
  interface AppState {
    db: PrismaClient;
  }
}

5. Wire the plugin and route

The route below demonstrates Prisma wiring, not ownership. If a record is user-owned or tenant-owned, do not query by URL ID alone. Constrain the same query with the trusted principal and follow the resource authorization guide.

src/server.tsts
import { z } from "zod";
import { App, NotFoundError, secureHeaders, requestId } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { prismaPlugin } from "./db/prisma.ts";

const UserSchema = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string().nullable(),
});

const app = new App();
// Register middleware before the routes: app.use() only applies to routes
// added after it.
app.use(requestId());
app.use(secureHeaders());
await prismaPlugin(app);

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    request: { params: z.object({ id: z.uuid() }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    const user = await state.db.user.findUnique({ where: { id: params.id } });
    if (!user) throw new NotFoundError("User not found");
    return { status: 200, body: user };
  },
);

app.post(
  "/users",
  {
    operationId: "createUser",
    request: { body: z.object({ email: z.email(), name: z.string().optional() }) },
    responses: { 201: { description: "Created", body: UserSchema } },
  },
  // Passing `body` straight to create() is safe only because z.object()
  // strips unknown keys (no mass assignment).
  async ({ body, state }) => ({
    status: 201,
    body: await state.db.user.create({ data: body }),
  }),
);

await app.ready();
serve(app, { port: 3000 });

Transactions

Use $transaction for atomic units of work. Throwing inside the callback rolls back. A successful return commits. This example assumes Order and Inventory models in your schema and a route that declares 201.

ts
handler: async ({ body, state }) => {
  const order = await state.db.$transaction(async (tx) => {
    const created = await tx.order.create({ data: { sku: body.sku, qty: body.qty } });
    await tx.inventory.update({
      where: { sku: body.sku },
      data: { stock: { decrement: body.qty } },
    });
    return created;
  });
  return { status: 201, body: order };
}

Edge runtimes

For Cloudflare Workers, set the generator runtime to workerd (alias cloudflare) and use a Prisma driver adapter (Neon, PlanetScale, D1, and others). The prisma-client generator also accepts moduleFormat (esm or cjs) and importFileExtension (for example ts, matching the .ts imports a scaffolded DaloyJS app uses).

prisma/schema.prismaprisma
generator client {
  provider            = "prisma-client"
  output              = "../src/generated/prisma"
  runtime             = "workerd"
  moduleFormat        = "esm"
  importFileExtension = "ts"
}

A Worker cannot reuse a database connection across requests, so build the client per request in a hook, registered before the routes:

src/worker.tsts
import { env } from "cloudflare:workers";
import { PrismaNeon } from "@prisma/adapter-neon";
import { App } from "@daloyjs/core";
import { toFetchHandler } from "@daloyjs/core/cloudflare";
import { PrismaClient } from "./generated/prisma/client.ts";

const app = new App();

app.use({
  preBody({ state }) {
    const adapter = new PrismaNeon({ connectionString: env.DATABASE_URL });
    state.db = new PrismaClient({ adapter });
  },
});

// ...register routes that read state.db

export default toFetchHandler(app);

Operator injection: validate your filter shapes

Prisma always emits parameterized SQL, but the filter object you pass to where is interpreted by Prisma. If a field annotated as string arrives at runtime as an object like { "not": "" }, Prisma treats it as an operator and an attacker can bypass equality checks, the "NoSQL-injection-in-Prisma" pattern documented by Aikido. Daloy's contract-first routes neutralize this when you keep the request body typed with primitive Zod schemas (z.string(), z.number(), ...) instead of z.any(), z.unknown(), or a pass-through z.record(). The same applies to decoded pagination cursors. See Security, SQL injection for the full pattern and review-time rules.

Mapping errors to problem+json

Keep the title generic: it is always sent to the client, and Prisma's messages name tables and constraints. The full code table lives in the ORM overview.

ts
import { Prisma } from "../generated/prisma/client.ts";
import { ConflictError, NotFoundError } from "@daloyjs/core";

try {
  const user = await state.db.user.update({ where: { id: params.id }, data: body });
  return { status: 200, body: user };
} catch (err) {
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    if (err.code === "P2002") throw new ConflictError("Email already in use");
    if (err.code === "P2025") throw new NotFoundError("User not found");
  }
  throw err;
}

Declare 404 and 409 in that route's responses.

Compare with Drizzle, TypeORM, MikroORM, Sequelize, Kysely, Supabase, or the ODM overview for document databases.

For serverless or edge deployments, see the database hosting overview: Prisma supports Neon, PlanetScale, and Cloudflare D1 through driver adapters.

Edit this page on GitHub

Last updated