Prisma Next is in early access.Read the docs

Authoring custom middleware

Build, register, and run your own Prisma Next middleware, step by step, starting with a query logger.

Introduction

In this guide, you build your own middleware from scratch: a query logger that prints every query with its row count and latency. You create one file, register it in one place, run a query, and see the output.

A custom middleware is a plain object with a name and the hooks you implement. There is no base class and no registration API beyond the middleware array you already know from How middleware works.

This entire guide was run end to end on a fresh create-prisma project against a live Prisma Postgres database; the outputs shown are from that run.

Prerequisites

  • A Prisma Next project with a working src/prisma/db.ts. The PostgreSQL quickstart sets one up in a few minutes:
npx create-prisma@next --provider postgres
cd my-app
npm run db:init

1. Create the middleware

Create a new file next to your database setup. The middleware implements one hook, afterExecute, which runs once after every query with the outcome:

src/prisma/query-logger.ts
import type { SqlMiddleware } from "@prisma-next/sql-runtime";

export function queryLogger(): SqlMiddleware {
  return {
    name: "query-logger",
    familyId: "sql",

    async afterExecute(plan, result) {
      console.log(
        `[query-logger] ${result.rowCount} rows in ${Math.round(result.latencyMs)}ms · ${plan.sql}`,
      );
    },
  };
}

Typing the object as SqlMiddleware gives you inferred parameter types on every hook, so you rarely import the plan or result types yourself. familyId: "sql" says this middleware is for SQL databases; step 5 explains when to set it.

2. Register it

Add the middleware to the middleware array in your client setup. This is the complete file after the change:

src/prisma/db.ts
import postgres from '@prisma-next/postgres/runtime';
import { queryLogger } from './query-logger';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };

export const db = postgres<Contract>({
  contractJson,
  url: process.env['DATABASE_URL']!,
  middleware: [queryLogger()],
});

That is the whole wiring. Every query on this client now passes through the logger, whether it comes from the ORM API or the SQL query builder.

3. Run a query and see the output

Put a small script in src/index.ts that writes and reads:

src/index.ts
import { db } from "./prisma/db";

await db.orm.public.User.create({
  email: `mia+${Date.now()}@prisma.io`,
  name: "Mia",
});

const users = await db.orm.public.User.select("id", "email").take(5).all();
console.log(`fetched ${users.length} users`);

await db.close();

Run it:

npm run dev

The logger reports both queries, with the SQL the runtime actually executed:

[query-logger] 1 rows in 18ms · INSERT INTO "public"."user" ("email", "name", "updatedAt") VALUES ($1, $2, $3) RETURNING "user"."createdAt", "user"."email", "user"."id", "user"."name", "user"."updatedAt", "user"."username"
[query-logger] 1 rows in 17ms · SELECT "user"."id" AS "id", "user"."email" AS "email" FROM "public"."user" LIMIT 5
fetched 1 users

If you see the query result but no [query-logger] lines, the query ran on a client that does not have the middleware registered; check that your script imports db from the file you edited in step 2.

4. Add an option

Real middleware usually takes options. Add a threshold so the logger only reports slow queries:

src/prisma/query-logger.ts
import type { SqlMiddleware } from "@prisma-next/sql-runtime";

export interface QueryLoggerOptions {
  /** Only log queries slower than this many milliseconds. Default: 0, log everything. */
  readonly thresholdMs?: number;
}

export function queryLogger(options?: QueryLoggerOptions): SqlMiddleware {
  const thresholdMs = options?.thresholdMs ?? 0;

  return {
    name: "query-logger",
    familyId: "sql",

    async afterExecute(plan, result) {
      if (result.latencyMs < thresholdMs) return;
      console.log(
        `[query-logger] ${result.rowCount} rows in ${Math.round(result.latencyMs)}ms · ${plan.sql}`,
      );
    },
  };
}

Register it with a threshold:

src/prisma/db.ts
middleware: [queryLogger({ thresholdMs: 250 })],

Run the script again and the logger goes quiet, because both queries finish in under 250ms. Drop the threshold back to see them again.

One placement rule worth knowing: hooks run in registration order, and a middleware that throws stops the ones after it. Register your logger before middleware that can throw, such as budgets, so the queries you most want to see still get logged.

5. Know which family to declare

Prisma Next groups databases into families: SQL (PostgreSQL) and document (MongoDB). Set familyId: 'sql' when your middleware touches anything SQL-shaped, like plan.sql in the logger above. The runtime then rejects it on MongoDB at startup with RUNTIME.MIDDLEWARE_FAMILY_MISMATCH, instead of failing at query time.

Leave familyId out only when the middleware sticks to family-neutral surfaces (intercept, onRow, afterExecute on the shared result shape). Such a middleware runs on PostgreSQL and MongoDB alike; the cache works this way.

You have a working, configurable middleware. The rest of this page is the surface you reach for as your middleware grows.

The hooks

afterExecute is one of five hooks. Implement any subset:

HookRunsUse it to
beforeCompile(draft, ctx)Before the query AST becomes SQLRewrite the query, for example add a tenant filter
beforeExecute(plan, ctx, params?)Before the driver, with plan.sql renderedValidate, throw to block, or adjust parameter values
intercept(plan, ctx)Right before the driverReturn { rows } to answer the query yourself
onRow(row, plan, ctx)Once per streamed rowCount or sample rows; throw to abort
afterExecute(plan, result, ctx)After the query finishesLog timing, row count, and outcome

How middleware works walks the order with an animation. Two hooks deserve a closer look here, because they change the query rather than observe it.

Rewrite queries with beforeCompile

beforeCompile sees the typed AST before it becomes SQL. Return a new draft to rewrite the query. This middleware adds a predicate to every SELECT on the user table:

src/prisma/scope-user-selects.ts
import type { SqlMiddleware } from '@prisma-next/sql-runtime';
import { AndExpr, type BinaryExpr } from '@prisma-next/sql-relational-core/ast';

export function scopeUserSelects(predicate: BinaryExpr): SqlMiddleware {
  return {
    name: 'scope-user-selects',
    familyId: 'sql',

    async beforeCompile(draft) {
      if (draft.ast.kind !== 'select') return undefined;
      if (draft.ast.from?.kind !== 'table-source') return undefined;
      if (draft.ast.from.name !== 'user') return undefined;
      const where = draft.ast.where ? AndExpr.of([draft.ast.where, predicate]) : predicate;
      return { ...draft, ast: draft.ast.withWhere(where) };
    },
  };
}

Return undefined to pass the query through unchanged. Because each middleware's returned draft feeds the next, rewrites compose in registration order.

Answer queries with intercept

Return rows from intercept and the driver never runs. Caches, test fixtures, rate limiters, and circuit breakers all fit this hook. Return raw row objects; the runtime decodes them normally, and afterExecute reports source: 'middleware' so the short-circuit stays visible to your logging. The cache middleware is the reference implementation.

The context object

Every hook receives a context (ctx) as its last argument:

FieldWhat it gives you
ctx.planExecutionIdThe same unique ID in every hook of one query, for correlating observations
ctx.now()The runtime's clock; prefer it over Date.now() so tests can control time
ctx.scope'runtime', 'connection', or 'transaction'; skip work in scopes you should not touch
ctx.mode'strict' or 'permissive'; throw in strict environments, warn elsewhere
ctx.contentHash(plan)A stable digest of statement plus parameters, for cache keys
ctx.contractThe runtime's contract, when a hook needs schema information
ctx.logStructured log sinks (info, warn, error) wired to the runtime's logger

One honest note on ctx.log: the postgres(...) client does not expose a way to attach a log sink yet, so events sent to ctx.log are not printed anywhere by default. For output you can see today, log directly to your own logger, as the query logger above does with console.log.

Common gotchas

Prompt your coding agent

Projects scaffolded with create-prisma@next install Prisma Next skills for your coding agent. Prompts that map to this guide:

  • "Write a Prisma Next middleware that logs every query slower than 250ms, and register it before budgets."
  • "Add a beforeCompile middleware that scopes every SELECT on the user table to the current tenant."
  • "Write an intercept middleware that returns fixture rows for the products table in tests."

Next steps

On this page