zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Client applications

Next.jsSupported

Next.js and zmdb divide cleanly: Next owns rendering and routing, while zmdb owns the schema, generated HTTP contract, queries, and validation. There are two supported boundaries:

Generated HTTP clients#

Install the request-scoped adapter and framework peers:

yarn add @zmdb/next@1.0.0-beta.2 next@16 react@19 react-dom@19

The server entry reads the current Next header and cookie stores only while creating a request scope. Nothing is forwarded by default:

import { createNextServerClient } from '@zmdb/next/server';
import { createApiClient } from '@/generated/http-client.generated.js';

const apiOrigin = process.env['API_ORIGIN'];
if (apiOrigin === undefined) throw new Error('API_ORIGIN is required');

const request = await createNextServerClient({
  createClient: createApiClient,
  baseUrl: apiOrigin,
  fetch: globalThis.fetch,
  forward: {
    headers: ['authorization', 'x-tenant-id'],
    cookies: ['session'],
  },
  fetchPolicy: { next: { revalidate: 60, tags: ['accounts'] } },
});

Create that scope inside the server component, route handler, or server action that owns the request. request.memoize(load, key) shares duplicate work only inside that scope; it never shares a client, credential, or result map across requests. cache: 'no-store', cache: 'force-cache', and next: { revalidate, tags } pass through to the supplied Next fetch unchanged.

Client components import only @zmdb/next/client. It reuses the @zmdb/client/react provider and hooks; the package has no mixed root barrel, and the guarded server entry cannot enter a client component. See Next.js Client for the complete client binding, cancellation, error, SSR, and testing path.

Direct database access#

One module for the driver#

// src/server/db.ts   — server only
import 'server-only';
import { Pool } from 'pg';
import { defineRepository } from '@zmdb/orm';
import { users, posts } from '@/schema.js';

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });

const driver = {
  async execute(query) {
    const result = await pool.query(query.text, [...query.parameters]);
    return result.rows;
  },
};

export const userRepo = defineRepository(users, driver);
export const postRepo = defineRepository(posts, driver);

import 'server-only' is the guard that matters. Without it, importing this from a client component is a build-time-successful, runtime-broken bundle — and it would ship your connection string to the browser. Add it before you write anything else.

The schema file itself is safe to import anywhere: schemaOf<Post>() compiles to a plain object literal and the DTO types are types, so a client component can use Entity<Post> with no runtime cost.

Server components#

// app/posts/page.tsx
import { postRepo } from '@/server/db';

export default async function PostsPage() {
  const { items } = await postRepo.list({
    orderBy: [{ column: 'createdAt', dir: 'desc' }],
    page: { limit: 20 },
  });

  return (
    <ul>
      {items.map(p => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

Query directly. No API route, no fetch, no serialisation — and p.title is typed from the schema.

Watch for N+1s: a server component that renders a list of children, each fetching its own row, is a query per child. Fetch with populate in the parent and pass down. See Loading Strategies.

Route handlers#

// app/api/posts/route.ts
import { assert } from '@zmdb/validator';
import { postRepo } from '@/server/db';
import { type CreateDTO } from '@zmdb/orm';
import { posts } from '@/schema.js';

export async function POST(request: Request) {
  const dto = assert<CreateDTO<Post>>(await request.json());
  const created = await postRepo.create(dto);
  return Response.json(created, { status: 201 });
}

CreateDTO<Post> is derived, so adding a required column is a type error here rather than a runtime rejection.

Server actions#

'use server';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const dto = assert<CreateDTO<Post>>({
    title: String(formData.get('title')),
    body: String(formData.get('body')),
  });
  await postRepo.create(dto);
  revalidatePath('/posts');
}

A server action is a public endpoint. FormData is entirely attacker-controlled, so validate it and authorise it in the action — the fact that it is only called from your own form is not a control.

The transformer#

This warning applies to direct generic AOT calls such as assert<T> in repository-backed routes. The generated HTTP client does not depend on a Next transformer: its response validators were emitted ahead of time by zmdb client generate and are exercised through @zmdb/next.

For direct database access, Next.js compiles with SWC or Turbopack, and the zmdb TypeScript transformer does not run in either. An untransformed assert<T> call throws instead of validating.

Two workable answers:

it.

Zod.

Either way, put the canary somewhere it runs:

it('the transformer is running', () => {
  expect(is<{ id: number }>({ id: 'x' })).toBe(false);
});

If it fails, pick one of the two options above rather than shipping. This is the single most important thing on this page.

Connections#

next dev reloads modules, which means a new Pool per reload and a leak until you restart. Cache it on globalThis in development:

const g = globalThis as { __pool?: Pool };
const pool = g.__pool ?? new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
if (process.env.NODE_ENV !== 'production') g.__pool = pool;

In production on Vercel, prefer an HTTP driver — see Vercel for the connection arithmetic and the transaction caveat.

Caching#

Next caches aggressively. A server component reading the database is cached unless you opt out:

export const dynamic = 'force-dynamic'; // per route

Or revalidate for time-based freshness. This interacts with zmdb not at all — but a "stale data" bug in a Next app reading from a database is much more often Next's cache than the query.

Migrations#

From CI or a release step, never from a route handler or instrumentation.ts. See migrate.

---

See also: Next.js Client · Vercel · AOT Setup · Zod