Skip to content

Add a field to products

This is the one walkthrough worth doing in full, because it crosses every convention at once: both dialect schemas, a migration for each, the shared zod schema, the input schema, the repository, the route, the security test, and the editor. We will add a nullable subtitle to products.

Skipping a step is how a field works in the admin and silently 404s in the storefront, or types clean and fails at runtime on Postgres only.

Add the column to products in db/schema.sqlite.ts and db/schema.pg.ts, same name, same nullability, in the same commit.

db/schema.sqlite.ts
subtitle: text("subtitle"),
// db/schema.pg.ts
subtitle: text("subtitle"),

They are two files because SQLite and Postgres do not share a drizzle-kit dialect; nothing else forces them apart.

Terminal window
npm run db:generate

That emits one migration into db/migrations/sqlite/ and one into db/migrations/pg/. Commit both. A migration for only one dialect is invariant 6 broken silently, since npm test seeds SQLite and will not notice Postgres never got the column.

shared/schema.ts is the type the storefront, the admin and the server all import, so this is the step that turns a forgotten later step into a type error.

export const productSchema = z.object({
// ...
subtitle: z.string().max(120).nullable().default(null),

shared/api.ts holds the schema an admin write is validated against before it reaches the database. Add the field there, never inline in the route.

export const productInputSchema = z.object({
// ...
subtitle: z.string().trim().max(120).nullable().default(null),

buildProduct in db/repository.ts is where a row becomes a Product; add the column on the read side. createProduct and updateProduct in db/admin-repository.ts are the write side.

// db/repository.ts, in buildProduct
subtitle: row.subtitle ?? null,
// db/admin-repository.ts, in the insert and update value maps
subtitle: input.subtitle,

The admin product route in server/routes/admin.ts already parses the body with the schema from step 4 and calls the repository function from step 5. Usually nothing changes here, which is the payoff of routes staying thin.

For a field on an existing route there is nothing to add. This step is here because it is the one that is easy to forget when it does apply: a genuinely new route goes into MUTATIONS or READS in server/security.test.ts, and that file is what stops an unprotected endpoint shipping.

Add an input to src/admin/ProductEditorPage.tsx; the form autosaves through the same input schema. Then render it wherever it belongs, for instance under the name in src/components/product/ProductDetails.tsx.

Terminal window
npm run typecheck && npm run lint && npm test -- --reporter=verbose

Look for repository on postgres in the output. Postgres skips silently if embedded-postgres cannot start, so a green run without that line is not proof both dialects passed.

  • The CSV export and import in shared/catalogue-csv.ts, if merchants should be able to bulk-edit the field.
  • The product.published webhook payload in shared/webhooks.ts, if subscribers need it. Payloads are built field by field, so a new column is not exposed by accident.