Pages as JSON: the data model behind a block-based content platform
Each page is one database row. Its body is not a string of HTML — it is a JSON array of typed blocks, validated in application code and versioned so the shape can change without rewriting the table.
The interesting decisions in a content platform are almost never in the UI. They are in the data model, because that is the part you cannot cheaply walk back later. This post is a behind-the-scenes look at one such decision in a block-based content platform built on Next.js, Prisma, and PostgreSQL: a page is stored as a single row whose most important column is a JSON array of typed blocks, and everything else — validation, rendering, migration — is arranged around that one choice. Every snippet below is copied from the running code and trimmed to the lines that carry the argument.
The output the reader sees from the outside
From the outside, a page is a slug and some rendered HTML. From the inside, it is a row in one table, and the column that matters is not a string of markup — it is a JSON array.
model DynamicPage {
dynamicPageId String @id @default(cuid())
slug String @unique
title String
description String?
keywords String[]
sections Json @default("[]")
metadata Json? // { ogTitle, ogDescription, ogImage, twitterTitle, twitterDescription, twitterCard }
status DynamicPageStatus @default(DRAFT)
schemaVersion Int @default(2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
translations DynamicPageTranslation[]
githubPromotion GitHubPromotion?
@@index([slug])
@@index([schemaVersion])
}
The page body lives entirely in sections, a Json column defaulting to "[]". There is no separate block table with a foreign key back to the page, no join to assemble a page at read time. A page is one row, fetched by slug, and sections is the whole document. The two indexes tell you exactly what the system queries on: slug for the public render path, and schemaVersion for the migration sweep covered later. The status enum defaults to DRAFT:
enum DynamicPageStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Nothing a freshly created page contains is public until someone flips that enum. That is the entire lifecycle — three states, no workflow engine, no separate publish table. The simplicity is deliberate, and it is the same instinct that produced the JSON column: keep the table boring, and put the variety somewhere the database does not have to understand it.
The decision tree behind the output
Four decisions chain together to produce that one row, and each one constrains the next.
The first is the root choice: relational block rows versus a JSON array. Relational rows give you database-level integrity and queryable block contents, at the cost of a migration every time a block type gains a field and a multi-row join on every page load. A JSON array gives you a single-row read and zero migrations for new block types, at the cost of the database knowing nothing about what is inside.
That choice forces the second decision. If the database will not validate block contents, something must. The answer is a single Zod schema applied in application code at the write boundary.
The third decision falls out of the second. If the schema lives in code and the column is opaque JSON, the column can drift from the code over time. So the data carries a schemaVersion, and an index exists to find rows still on an old version.
The fourth is about who can break what. Some blocks are typed React components shipped in the repo; some are template rows authored from a dashboard. The renderer resolves between them in a fixed order, and the security boundary sits on who authors a block, not on what a block may contain. The rest of this post walks each node with the real artifact behind it.
Walk each node with a real artifact in the repo
Start with the contract every block agrees to. If sections is untyped JSON to the database, the discipline has to live in one place, and it does — a single Zod schema:
export const CURRENT_SCHEMA_VERSION = 2 as const
export const BlockDataSchema = z.object({
id: z.string(),
type: z.string(),
order: z.number(),
props: z.record(z.unknown()),
hidden: z.boolean().optional(),
label: z.string().optional(),
className: z.string().optional(),
})
export type BlockData = z.infer<typeof BlockDataSchema>
Seven fields carry the entire contract. id identifies the instance on the page, not the block type. type is the lookup key into the registry. order is the position. props is z.record(z.unknown()) — an opaque bag that deliberately refuses to type its contents, because a hero block and a pricing table share nothing structurally. hidden, label, and className are optional. The fact that they are optional is what let them be added without breaking any page already stored against an earlier shape. This is the load-bearing decision of the whole platform: keep the per-block envelope tiny and stable, and push all the variety into a props field that nothing validates strictly.
The page-level schema wraps an array of those blocks and pins the version:
export const DynamicPageSchema = z.object({
dynamicPageId: z.string(),
title: z.string(),
slug: z.string(),
description: z.string(),
keywords: z.array(z.string()),
sections: z.array(BlockDataSchema),
metadata: PageMetadataSchema,
status: DynamicPageStatusEnum,
schemaVersion: z.number().int().min(1).default(CURRENT_SCHEMA_VERSION),
// createdAt / updatedAt preprocessed to Date ...
})
Two details earn their place. sections: z.array(BlockDataSchema) is where the array-of-blocks shape becomes an enforced contract instead of a convention. And schemaVersion: z.number().int().min(1).default(CURRENT_SCHEMA_VERSION) does real work: a page that arrives without a version defaults to the current one, and the .min(1) floors it so no row can claim version zero. The metadata field maps to its own small schema (ogTitle, ogImage, twitterCard, canonical, robots) — also optional, also JSON, the same philosophy applied to SEO fields instead of layout.
The write path is where this schema gets teeth. The create route is short on purpose:
export async function POST(request: NextRequest) {
try {
await AuthMiddleware.authenticateUserByRequest({ request })
const body = await request.json()
const parsed = CreateDynamicPageSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors.map((e) => e.message).join(', ') },
{ status: 400 }
)
}
const page = await DynamicPageService.create(parsed.data)
return NextResponse.json({ page }, { status: 201 })
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error('Error in POST /api/dynamic-pages:', error)
return NextResponse.json({ message: msg }, { status: 500 })
}
}
The route authenticates, then safeParses the body, then delegates to the service. A malformed page body never reaches the service or the database — it gets a 400 with a flattened list of validation messages. This is what "validation lives in application code" means in practice: the Zod schema is the gate the database is no longer providing. The GET sibling on the same file handles list, search, sort, and status filtering and requires no auth, because reading the catalogue of pages is not a privileged act:
const { pages, total } = await DynamicPageService.getAll({
page, pageSize, search, sortKey, sortDir, status,
})
return NextResponse.json({ pages, total, page, pageSize })
Writing a page runs AuthMiddleware.authenticateUserByRequest first; reading the list does not. The asymmetry is intentional and it is the route's only piece of business logic — everything else is parse, delegate, respond.
The thing that almost went differently
The render path is where an untyped JSON array could have gone wrong, and the place the design earns the bet. The renderer is one client component that sorts, filters, and maps:
export default function ClientBlockList({ sections, dbDefs }: Props) {
const sorted = [...sections]
.sort((a, b) => a.order - b.order)
.filter((block) => block.hidden !== true)
return (
<div className="bg-base-100">
{sorted.map((block) =>
block.type === 'popup-modal' ? (
// Rendered as a fixed overlay — no wrapper div to avoid stacking context issues
<ClientBlock key={block.id} block={block} dbDefs={dbDefs} />
) : (
<div key={block.id} className={block.className} data-block-type={block.type}>
<Suspense fallback={<BlockSkeleton type={block.type} />}>
<ClientBlock block={block} dbDefs={dbDefs} />
</Suspense>
</div>
)
)}
</div>
)
}
Three things make this safe. The .sort((a, b) => a.order - b.order) means storage order in the array does not matter — position is data, not array index, which is why the editor can reorder freely. The .filter((block) => block.hidden !== true) is how an editor parks a block without deleting it; the hidden flag from the schema does exactly one job and this is it. And the popup-modal branch is the kind of detail you only find by shipping: a modal rendered inside a className wrapper inherits a stacking context and gets trapped behind other content, so it renders bare.
The part that almost went differently is failure handling. With sixty-plus block types and pages assembled by non-developers, one bad props value cannot be allowed to take down the whole page. So resolution and isolation happen together:
function ClientBlock({ block, dbDefs }: { block: BlockData; dbDefs: DynamicPageBlockRecord[] }) {
const codeDef = getCodeBlock(block.type)
if (codeDef) {
const { Component } = codeDef
return (
<BlockRenderErrorBoundary blockType={block.type}>
<Component {...block.props} __blockId={block.id} />
</BlockRenderErrorBoundary>
)
}
const dbDef = dbDefs.find((d) => d.type === block.type)
if (!dbDef) return null
return (
<BlockRenderErrorBoundary blockType={block.type}>
<TemplateBlockRenderer template={dbDef.template} props={block.props} script={dbDef.script} blockType={block.type} />
</BlockRenderErrorBoundary>
)
}
The resolver checks the code registry first via getCodeBlock, then falls back to a database-defined block, and returns null if neither matches — an unknown type string costs you one missing block, not a crash. Every block that does render is wrapped in BlockRenderErrorBoundary, so a throw inside one block is caught and the rest of the page survives. This is the safety net that makes "the database does not validate props" tolerable: the renderer assumes any block can be malformed and degrades one block at a time.
The two-tier resolution is also the platform's real architecture. Code blocks are typed React components shipped through the repo. Database blocks are rows in DynamicPageBlock that store a template string and an optional script:
model DynamicPageBlock {
blockId String @id @default(cuid())
type String @unique
label String
category String @default("General")
description String?
schema Json @default("{}")
defaultProps Json @default("{}")
template String @default("")
script String?
isSystem Boolean @default(false)
// ...
@@index([category])
}
Code blocks win ties because the resolver checks them first. A developer ships a powerful typed block through a deploy; an admin mints a weaker template block from the dashboard without one. The template is a string and script can hold injected JavaScript, which is why authoring a DynamicPageBlock is an admin-only operation. The security boundary sits on who can define a block, not on what a block may contain.
What you would change if starting over
Two things stand out, neither urgent. First, props being z.record(z.unknown()) means there is no compile-time link between a block component's actual props and the schema the editor renders for it. The DynamicPageBlock.schema JSON column exists to drive that editor form, but nothing checks it against the component. A generated type bridge — derive the editor schema from the component prop types — would kill a whole class of "the form has a field the component ignores" bugs.
Second, translations are a separate table, and that is worth a second look:
model DynamicPageTranslation {
id String @id @default(cuid())
dynamicPageId String
lang String
title String
description String?
sections Json @default("[]")
// ...
dynamicPage DynamicPage @relation(fields: [dynamicPageId], references: [dynamicPageId], onDelete: Cascade)
@@unique([dynamicPageId, lang])
@@index([dynamicPageId])
}
Each translation carries its own sections JSON, keyed uniquely by [dynamicPageId, lang], with onDelete: Cascade so a deleted page takes its translations with it. The base page owns the canonical structure; a translation carries localized text. The risk is structural drift — two sections arrays for the same page that can diverge in block order or count. The data model itself does not enforce parity. If I were starting over I would consider storing translations as per-block text overrides rather than a full parallel sections array, so the layout could not fork per language even in principle.
CTA
You can recreate the core of this in an afternoon. Define one envelope schema — id, type, order, props, plus a couple of optional display flags — and store an array of it in a single JSON column. Resolve type strings against a static registry. Render with a sorted, filtered map and wrap each block in an error boundary. That is the whole engine; the editor is a separate, later problem.
Trade-off
This model trades database-enforced integrity for shape flexibility, and it does so eyes open. The database cannot constrain what is inside props, cannot enforce that a translation matches its base page block-for-block, and cannot answer a query like "find every page using the pricing block" without scanning JSON. In exchange, a new block type needs no migration, a page is a single-row read, and the render path is one map. For a content surface that changes shape more often than it is queried analytically, that is the correct trade. For data you join and aggregate on heavily, it would be the wrong one — and the @@index([schemaVersion]) is the only hedge against the day the JSON shape needs to move.
Business impact
The consequence the audience cares about is that page changes stop being engineering tickets. A new campaign page is an editor action against an existing row shape; a new kind of section is one block file plus a registry entry, with no migration and no risk to pages already stored. The schemaVersion field and its index mean the stored shape can keep evolving — pages get upgraded as they are touched, and a sweep finds the stragglers — so the model does not calcify the way relational page builders tend to two years in. For a client that translates into a site their own non-technical team can run, and a codebase where "add a section type" stays a small, safe change long after launch.
What to do next
If you are designing a content model right now, the decision worth making first is not the editor — it is where validation and the security boundary live. Write your BlockDataSchema equivalent before anything else, give it a schemaVersion from day one even when the version is 1, and decide deliberately whether you are guarding who authors a block or what a block may contain. Which of those two boundaries fits your team — the deploy-gated registry, or sandboxed contents anyone can author? That answer shapes the rest of the system, and it is much cheaper to choose now than after the first migration.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox