Recipes
Three tasks people reach for DRZL to do, and where each one is answered. Two of them already have a full page of their own and are summarised here so you can tell which one you want. The third, publishing an OpenAPI document, is written out below.
| Task | Where |
|---|---|
| Bulk insert, with the duplicate finder | Seeding and Bulk Inserts |
| Form validation, client and server | React Hook Form, TanStack Form, Next.js Server Actions |
| Publishing an OpenAPI document | Below |
Bulk insert with the duplicate finder
A seed script is yours to write, and the part that goes wrong is never the INSERT. It is the five hundredth row failing a CHECK the script never looked at, a batch that collides with itself before it reaches the database, and children inserted before their parents.
Seeding and Bulk Inserts composes three things DRZL already emits into one checked pipeline: insert schemas with the CHECK constraints folded in, a per-table duplicate finder that catches a batch colliding with itself on a unique key, and the foreign-key graph as plain data in constraints.ts, which is what orders the inserts. Every step on that page is measured against a real PostgreSQL server.
Reach for it when you are writing a seed, a migration backfill, or an import that takes rows from somewhere you do not control.
Form validation
Every schema DRZL emits in zod, valibot or arktype spelling carries Standard Schema v1, so there is no @drzl/* package to install for a form. Wiring one is a single property:
useForm({ resolver: zodResolver(InsertusersSchema) }); // React Hook Form
useForm({ validators: { onChange: InsertusersSchema } }); // TanStack FormThe pages are about what happens either side of that line, which is where the real traps are:
- React Hook Form.
register()appliesvalueAsNumber,valueAsDateandsetValueAsto the input's string before any resolver runs, so what your schema sees is not what the user typed. Also covers where errors land informState, and why a nullable column and an omittable one are different things in a form. - TanStack Form. Validation gates, values do not flow: the parsed output of a schema is discarded by design, so the conversion is one
parsecall in your submit handler. - Next.js Server Actions. The same schemas on the server, where a form post arrives as strings and nothing in the browser can be trusted.
Publishing an OpenAPI document
This is the end to end: a Drizzle schema, a config, a generated document, a validator that says it is a real OpenAPI document, and a server that hands it out. No part of the document itself is hand-written, and the recipe is built so it stays that way.
Measured 2026-08-09 with @drzl/cli 4.22.0, @drzl/generator-json-schema 0.8.0, drizzle-orm 0.45.2, TypeScript 5.9.3 and @seriousme/openapi-schema-validator 2.9.1, on Node 22.22.0. Every command below was run, and the output quoted is what it printed.
What you are publishing, and what it does not know
The document is derived from the schema alone. Its own info.description says so:
Generated by DRZL from a Drizzle schema. Paths, request bodies and response bodies are derived from the schema alone; nothing here has been checked against a running server.
That is the honest reading of what it is. It describes the resources your tables imply, the request body each write accepts, and the shape of a row that comes back. It does not know your handlers exist. See OpenAPI Document for the path set, the status codes and the 3.0 translation table; this page is about getting one served and keeping it true.
1. The config
@drzl/cli depends on @drzl/generator-json-schema, so an ordinary install already has it. drzl init will not offer it, by design: init scaffolds the five kinds whose config entry it knows how to write, so this one is an entry you add by hand.
import { defineConfig } from '@drzl/cli/config';
export default defineConfig({
schema: 'src/db/schema.ts',
generators: [
{
kind: 'json-schema',
path: 'src/openapi',
target: 'openapi-3.1',
includeRelations: true,
document: {
format: 'both',
info: { title: 'Blog API', version: '1.0.0' },
},
},
],
});Two choices in there are worth stating.
format: 'both' writes the document twice, as openapi.ts and as openapi.json, because the two have different readers. A server imports the .ts and gets it typechecked with the rest of your output; a linter, a client generator or a docs viewer reads the .json off disk. Pick one if you only have one reader.
No servers key. The specification reads an absent servers as a single server at /, meaning the document describes whatever is serving it, and when you serve the document from the API itself that is the only true statement available. Add servers: [{ url: 'https://api.example.com/v1' }] inside document when the document is published somewhere the API is not, such as a docs site.
2. Generate
drzl generateAgainst a schema of users and posts, with posts.authorId referencing users.id:
✔ Analysis complete in 34ms
✔ Generated (json-schema): 5 files (5 created)
+ src/openapi/posts.schema.ts
+ src/openapi/users.schema.ts
+ src/openapi/openapi.ts
+ src/openapi/openapi.json
+ src/openapi/index.tsopenapi.ts exports one as const object called openapi, with everything inlined, and the barrel re-exports it beside the per-table schemas. openapi.json is the same value as JSON, and is the only emitted file the barrel does not re-export, since nothing imports it.
Adding components: true writes a sixth file, components.ts, holding components.schemas on its own. You do not need it for the document, which inlines them; it is for a project that wants the component schemas without the paths.
includeRelations: true is what produced the fifth path here:
/posts
/posts/{id}
/users
/users/{id}
/users/{id}/posts3. Check that it is really an OpenAPI document
A document that validates is not automatically useful, but one that does not validate is a liability: the reader is usually a code generator in another language with nothing to check against. Validate it in the same place your tests run.
npm i -D @seriousme/openapi-schema-validator// scripts/validate-openapi.mjs
import { readFileSync } from 'node:fs';
import { Validator } from '@seriousme/openapi-schema-validator';
const file = process.argv[2] ?? 'src/openapi/openapi.json';
const doc = JSON.parse(readFileSync(file, 'utf8'));
const result = await new Validator().validate(doc);
if (!result.valid) {
console.error(`${file} is not a valid OpenAPI document:`);
console.error(JSON.stringify(result.errors, null, 2));
process.exit(1);
}
console.log(`${file}: OpenAPI ${doc.openapi} valid, ${Object.keys(doc.paths).length} paths`);src/openapi/openapi.json: OpenAPI 3.1.1 valid, 5 pathsThat validator is the one DRZL's own gate uses, and it was picked for a reason worth repeating: it carries a real 3.1 meta-schema rather than checking 3.1 documents against the 3.0 one. Two defects in the 3.0 output were found by it and by nothing else. See Verification.
If you serve the .ts form and not the .json, validate the .ts: the two are generated from one value, so either proves the other, and validating the one you do not ship proves the least.
4. Serve it
The document is a plain JSON value, so any server can hand it out. This one needs no dependencies at all:
// scripts/serve.mjs
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const doc = JSON.parse(readFileSync('src/openapi/openapi.json', 'utf8'));
const reference = `<!doctype html>
<html>
<head><title>${doc.info.title}</title></head>
<body>
<script id="api-reference" data-url="/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`;
createServer((req, res) => {
if (req.url === '/openapi.json') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(doc));
return;
}
if (req.url === '/docs') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(reference);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ message: 'Not found' }));
}).listen(8787);$ curl -s http://localhost:8787/openapi.json | head -c 60
{"openapi":"3.1.1","info":{"title":"Blog API","version":"1.0In a real application, import the module rather than reading the file, so the document is bundled with the code that serves it and cannot go missing at runtime:
import { openapi } from './openapi/index.js';
app.get('/openapi.json', (c) => c.json(openapi)); // Hono
app.get('/openapi.json', (_req, res) => res.json(openapi)); // Express/docs above renders the document with a viewer loaded from a CDN. Any of Scalar, Swagger UI, Redoc or RapiDoc reads the same URL; the choice is yours and none of them is a DRZL dependency.
5. Keep it true
The document is generated, so the failure mode is not that it is wrong on the day you write it. It is that the schema moves and the checked-in document does not. generate --check is the guard: it regenerates in memory, compares, writes nothing, and exits 2 when the tree is stale.
- run: npx @drzl/cli generate --check # 2 means commit the regenerated document
- run: node scripts/validate-openapi.mjsA stale document reports itself with a diff:
Generated output is out of date (1 file(s)):
~ changed src/openapi/openapi.json
--- a/src/openapi/openapi.json
+++ b/src/openapi/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.1",
"info": {
"title": "Blog API",Run the two in that order. --check compares the document with the schema, and the validator compares it with the specification; a run where the first fails makes the second's answer a statement about last week's schema.
What neither of them checks is whether your handlers implement what the document promises. Nothing in a Drizzle schema knows that, and DRZL does not claim it. If you want that checked, a contract test against the running server is the tool, and the document is a fine input to one.
What the document does not say
Three absences that are deliberate, so you know to fill them in yourself rather than wait for them:
- No pagination. Whether
GET /userstakes a limit, an offset or a cursor is not something a schema states, and a declared parameter no server honours is worse than an undeclared one. - No authentication. There is no
securitySchemesblock, for the same reason. - No examples. A schema says what a value must look like, never what one is.
Adding any of them is editing the generated file, which --check will then report on every run. The supported way to carry them is to merge your own object over the generated one in the module that serves it, so the generated file stays generated:
import { openapi } from './openapi/index.js';
export const document = {
...openapi,
security: [{ bearerAuth: [] }],
components: {
...openapi.components,
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
},
};
