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.
1. Both dialect files
Section titled “1. Both dialect files”Add the column to products in db/schema.sqlite.ts and db/schema.pg.ts,
same name, same nullability, in the same commit.
subtitle: text("subtitle"),
// db/schema.pg.tssubtitle: text("subtitle"),They are two files because SQLite and Postgres do not share a drizzle-kit
dialect; nothing else forces them apart.
2. Generate both migrations
Section titled “2. Generate both migrations”npm run db:generateThat 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.
3. The shared schema
Section titled “3. The shared schema”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),4. The input schema
Section titled “4. The input schema”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),5. The repository
Section titled “5. The repository”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 buildProductsubtitle: row.subtitle ?? null,
// db/admin-repository.ts, in the insert and update value mapssubtitle: input.subtitle,6. The route
Section titled “6. The route”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.
7. The security test
Section titled “7. The security test”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.
8. The editor and the storefront
Section titled “8. The editor and the storefront”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.
9. Prove it on both engines
Section titled “9. Prove it on both engines”npm run typecheck && npm run lint && npm test -- --reporter=verboseLook 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.
Also worth updating
Section titled “Also worth updating”- The CSV export and import in
shared/catalogue-csv.ts, if merchants should be able to bulk-edit the field. - The
product.publishedwebhook payload inshared/webhooks.ts, if subscribers need it. Payloads are built field by field, so a new column is not exposed by accident.