Home / Articles / Treat Content as Code: A Git-to-Postgres Seeding Pipeline

This article is published in English.

Treat Content as Code: A Git-to-Postgres Seeding Pipeline

Shows how to replace a CMS with Git-tracked JSON, Zod validation, and Prisma upserts to safely seed structured content into Postgres.

2258 words

Imagine building a quiz application with multiple-choice questions, embedded code snippets, explanations, and difficulty ratings. This kind of content changes fairly often, but not in a way that demands live editing at odd hours.

The obvious first choice might be a CMS like Sanity or Strapi. But before reaching for one, it helps to lay out the actual requirements:

  • A full change history for every edit made to the content
  • The ability to review changes before they reach production
  • Validation that breaks the build instead of breaking production
  • No extra infrastructure needed for an MVP
  • A workflow that mirrors how you already ship code

Given those needs, storing content directly in Git makes more sense than adding a CMS.

The approach uses JSON files, Zod schemas, a Prisma seed script, and PostgreSQL for runtime storage. The pipeline looks like this:

JSON → Zod → Seed (upsert) → Postgres → API

It's intentionally unexciting. A boring pipeline is one you can trust.

Why not “just use a CMS”?

CMS platforms prove their worth when non-technical people publish content daily, when you need draft states and permission roles, or when the shape of your data shifts unpredictably.

But for structured content that engineers themselves author — quiz banks, seed data, onboarding flows, pricing tiers — bringing in a CMS typically adds:

  • Yet another service you have to host and secure
  • Yet another schema you must keep aligned with your application
  • Yet another gap where invalid data can sneak through
  • Yet another context switch away from your editor

What was actually required wasn't a publishing platform — it was a content pipeline:

author → validate → review → deploy → seed → serve

Git already handles the first four steps. The only missing piece was a dependable way to import content into the database.

The architecture

content/
  questions/
    javascript/
      easy.json
      medium.json
      hard.json
    html/
      easy.json
packages/db/
  prisma/schema.prisma
  src/seed.ts              ← read, validate, upsert
packages/shared/
  schemas/question.ts      ← Zod contract
scripts/
  validate-content.ts      ← CI, no DB required
| Layer        | Responsibility                      |
|--------------|-------------------------------------|
| JSON         | Human-editable source of truth      |
| Zod          | Runtime validation + inferred types |
| Prisma seed  | Idempotent import into the database |

The non-negotiable rule here: your application never reads JSON files at runtime in production. JSON only exists as an input at deploy time. Postgres remains the layer that actually serves queries.

This gives you Git's workflow benefits without turning your database into something that just proxies files.

Step 1: Start with Zod, not JSON

Before writing any content, define the contract it must satisfy.

import { z } from 'zod';

export enum Topic {
  JavaScript = 'JAVASCRIPT',
  HTML = 'HTML',
  TypeScript = 'TYPESCRIPT',
}

export enum Difficulty {
  Easy = 'EASY',
  Medium = 'MEDIUM',
  Hard = 'HARD',
}

export const questionSchema = z
  .object({
    id: z.string().min(1), // stable slug: js-closures-loop-001
    topic: z.nativeEnum(Topic),
    subtopic: z.string().min(1),
    difficulty: z.nativeEnum(Difficulty),
    text: z.string().min(1),
    codeSnippet: z.string().nullable().optional(),
    options: z.array(z.string().min(1)).min(2),
    correctOptionIndex: z.number().int().min(0),
    explanation: z.string().min(1),
  })
  .refine((q) => q.correctOptionIndex < q.options.length, {
    message: 'correctOptionIndex must point to a valid option',
  });

export const questionsFileSchema = z.array(questionSchema);
export type QuestionContent = z.infer<typeof questionSchema>;

A few deliberate design choices stand out:

  • The id field lives inside the content file itself — this is what makes redeploys safe. Database-generated primary keys are just implementation details; a stable identifier like js-closures-loop-001 is what a user's saved progress actually depends on.
  • Enums are used instead of raw strings, which stops inconsistent casing like js, JS, or javascript from creeping into different files.
  • .refine() handles validation rules that span multiple fields — something a simple min() constraint can't express, such as keeping an answer index within bounds.
  • Each schema validates an entire JSON file as a single array, not record by record.

The result is that your content has an enforceable contract, not just a convention documented somewhere nobody reads.

Step 2: Author boring JSON

[
  {
    "id": "js-closures-loop-001",
    "topic": "JAVASCRIPT",
    "subtopic": "closures",
    "difficulty": "MEDIUM",
    "text": "What will this code log?",
    "codeSnippet": "for (var i = 0; i < 3; i++) {\n  setTimeout(() => console.log(i), 0);\n}",
    "options": ["0 1 2", "3 3 3", "undefined undefined undefined", "0 0 0"],
    "correctOptionIndex": 1,
    "explanation": "`var` is function-scoped, so by the time the timeouts run, `i` is 3."
  }
]

The format is deliberately plain: unambiguous types, clean diffs, and no debates over parsing edge cases. If content authors eventually want to write in Markdown or YAML, you can generate JSON from those formats in a prebuild step — the seed script itself should stay simple and predictable.

For rich text specifically, store the raw source string in the database — Markdown, plain text, or whatever format authors are comfortable with — and render it wherever the app displays it. Rendering to HTML at seed time ties you to one specific rendering library and creates migration headaches if you ever switch. Keep the source stored as-is, and render only where it's actually needed.

Step 3: Seed with upserts, not nukes

While you still have no real users, wiping the table with deleteMany and refilling it via createMany is fine. Once user records start referencing content rows, switch to upserts keyed on a stable identifier.

A simplified Prisma model:

model Question {
  id                 String   @id @default(cuid())
  externalId         String   @unique
  topic              Topic
  subtopic           String
  difficulty         Difficulty
  text               String
  codeSnippet        String?
  options            String[]
  correctOptionIndex Int
  explanation        String
}

The seed script:

import fs from 'node:fs/promises';
import path from 'node:path';
import { PrismaClient } from '@prisma/client';
import { questionsFileSchema, type QuestionContent } from '@myapp/shared';

const prisma = new PrismaClient();
const CONTENT_DIR = path.resolve(__dirname, '../../../content/questions');

async function loadQuestionsFromDisk(): Promise<QuestionContent[]> {
  const rows: QuestionContent[] = [];
  const topicDirs = await fs.readdir(CONTENT_DIR, { withFileTypes: true });

  for (const topicDir of topicDirs) {
    if (!topicDir.isDirectory()) continue;

    const dirPath = path.join(CONTENT_DIR, topicDir.name);
    const files = (await fs.readdir(dirPath)).filter((f) => f.endsWith('.json'));

    for (const file of files) {
      const raw = await fs.readFile(path.join(dirPath, file), 'utf8');
      const questions = questionsFileSchema.parse(JSON.parse(raw));
      rows.push(...questions);
    }
  }

  return rows;
}

async function main() {
  const questions = await loadQuestionsFromDisk();
  let created = 0;
  let updated = 0;

  for (const q of questions) {
    const data = {
      topic: q.topic,
      subtopic: q.subtopic,
      difficulty: q.difficulty,
      text: q.text,
      codeSnippet: q.codeSnippet ?? null,
      options: q.options,
      correctOptionIndex: q.correctOptionIndex,
      explanation: q.explanation,
    };

    const existing = await prisma.question.findUnique({
      where: { externalId: q.id },
    });

    if (existing) {
      await prisma.question.update({ where: { externalId: q.id }, data });
      updated++;
    } else {
      await prisma.question.create({ data: { externalId: q.id, ...data } });
      created++;
    }
  }

  console.log(`Seeded: ${created} created, ${updated} updated`);
}

main()
  .catch((err) => {
    console.error(err);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

Three details matter here:

  1. questionsFileSchema.parse(...) keeps malformed JSON out of the database entirely.
  2. findUnique({ where: { externalId: q.id } }) matches on the content's own ID, not the database's internal primary key.
  3. Updating instead of deleting preserves whatever answers or progress a user already has tied to that record.

You might ask why not just call createMany({ skipDuplicates: true }). That option prevents duplicate rows, but it leaves stale text untouched when content changes. For syncing content at deploy time, writing out explicit upserts is more transparent and works across database engines. Reach for a raw INSERT ... ON CONFLICT DO UPDATE only after profiling shows you genuinely need the speed.

Step 4: Fail fast in CI

You don't need a database to validate content. Run this on every pull request that touches content/:

// scripts/validate-content.ts
import fs from 'node:fs/promises';
import { glob } from 'glob';
import { questionsFileSchema } from '@myapp/shared';

const files = await glob('content/**/*.json');
let failed = 0;

for (const file of files) {
  try {
    const raw = await fs.readFile(file, 'utf8');
    questionsFileSchema.parse(JSON.parse(raw));
    console.log(`✓ ${file}`);
  } catch (err) {
    console.error(`✗ ${file}`, err);
    failed++;
  }
}

process.exit(failed > 0 ? 1 : 0);
# .github/workflows/validate-content.yml
name: Validate content
on:
  pull_request:
    paths: ['content/**']

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - run: pnpm install
      - run: pnpm tsx scripts/validate-content.ts

Broken content fails the pull request — it never ships silently.

Deploying looks like this:

npx prisma migrate deploy
npx prisma db seed

Content updates become an ordinary release: merge, migrate, seed.

Step 5: When your schema changes

Sooner or later your Zod schema will evolve. Maybe you add a hint field. Maybe you rename codeSnippet to code. Maybe options shifts from an array of strings to an array of objects.

Whatever the change, your existing JSON files stop matching the schema. The seed script throws. Don't panic — treat schema changes for content exactly like database migrations.

Start by versioning the schema itself:

// schemas/question.v1.ts — old shape
// schemas/question.v2.ts — new shape
// schemas/question.ts   — export latest as `questionSchema`

Then run a one-time codemod over the files on disk:

// scripts/codemod-questions-v2.ts
import fs from 'node:fs/promises';
import { glob } from 'glob';
import { z } from 'zod';
import { questionSchemaV1 } from '@myapp/shared/schemas/question.v1';

const v1File = z.array(questionSchemaV1);

for (const file of await glob('content/**/*.json')) {
  const old = v1File.parse(JSON.parse(await fs.readFile(file, 'utf8')));

  const next = old.map((q) => ({
    ...q,
    hint: null,
    code: q.codeSnippet,
    codeSnippet: undefined,
  }));

  await fs.writeFile(file, JSON.stringify(next, null, 2));
}

Run the codemod, commit the updated JSON, point the seed script at the v2 schema, and deploy. That's the whole process: codemod, commit, update, ship.

It's the same discipline you already apply with prisma migrate, and it pays off the same way.

What you gain

Content review becomes code review. "Is this explanation actually correct?" turns into a pull request with a visible diff instead of a Slack message.

You get validation for free, without building a custom linter. Zod catches typos in enums, missing required fields, and out-of-range indices — the schema itself acts as the linter.

You skip the overhead of a CMS entirely. There's no admin panel to build, no separate editor authentication, no second deployment target to maintain — especially when the people writing content are the same engineers shipping code.

Environments stay reproducible. Clone the repo, run migrations, run the seed, and you get an identical question bank every time, on every machine.

Bulk changes become scripts instead of manual clicks. Retagging forty questions from MEDIUM to HARD is a one-line sed command or a short script, not forty individual edits in an admin UI.

User progress survives deploys. Because you're using stable IDs combined with upserts, you can correct a typo in a question without severing the link to answers users already submitted.

What you give up

It's worth being upfront about the tradeoffs:

  • People who aren't engineers generally won't enjoy working in Git. If non-technical editors need to contribute, you'll need a CSV import path, an internal tool, or a headless CMS that exports to JSON.
  • There's no built-in draft-versus-published workflow. Whatever sits on main is what gets seeded. If you need drafts, you'll have to model that with branches.
  • Media assets don't belong inside JSON files. Images and video should live in object storage, referenced by URL.
  • Merge conflicts are a real risk once multiple people edit content. Mitigate this by splitting content into small files — organized by topic or difficulty — rather than one enormous questions.json.
  • Any content change in production requires a deploy. If your use case demands content updates without redeploying, this approach isn't the right match.

When to use it

This pattern fits well when your content is structured and repetitive, your engineering team is small, you're somewhere between MVP and early production, changes to content need to be traceable, and you're already using Prisma.

It fits poorly when you have non-technical editors publishing daily, complex approval chains, heavy reliance on media, or a requirement to edit live content directly in production.

The takeaway

What was actually needed wasn't a CMS — it was content held to the same standard as code: versioned, validated, reviewed, and deployed through infrastructure that was already trusted.

JSON in Git is the source of truth. Zod is the gatekeeper. Prisma's seed step is the loader. Stable IDs are what keep user data intact as content changes underneath it.

If there are no users yet, start simple with wipe-and-reload seeding. Switch to upserts the moment progress data starts to matter. Add CI validation before a bad enum typo ever reaches staging. Version your content schema before you hit your second breaking change.

The whole setup is intentionally unglamorous — and that's exactly the point. Save the excitement for the product itself, not for the pipeline responsible for storing your questions.