Code blocks vs database blocks: a two-tier block registry
A block-based content platform resolves ~60 typed React blocks first, then falls back to database rows whose HTML template is rendered through
dangerouslySetInnerHTML. The split buys power; the price is where you draw the security line.
The decision and why it cannot be deferred
A page builder has to answer one question before any other: when a page references a block by name, what becomes that block? In a block-based content platform — pages stored as ordered arrays of typed blocks — every block instance is a record with a type string, and something has to turn that string into rendered output.
There are two honest answers, and they pull in opposite directions. You can compile blocks into the application as typed React components, which means a new kind of block is a code change, reviewed and deployed like any other. Or you can store blocks as rows in the database, which means a new block is a write to a table and shows up without a deploy. The first answer is safe and slow. The second is fast and dangerous.
This decision cannot be deferred because it dictates the rendering path, the permission model, and the security boundary all at once. The platform discussed here refuses to pick one. It runs both, in a fixed order, and accepts a specific trade-off to make that work. The resolver is small enough to read in full:
export function getCodeBlock(type: string): BlockDefinition | undefined {
return CODE_BLOCKS[type]
}
export function getCodeBlocks(): BlockDefinition[] {
return Object.values(CODE_BLOCKS)
}
// Resolve a block definition from either code registry or DB records
// Returns null if not found in either
export function resolveBlockDef(
type: string,
dbDefs: DynamicPageBlockRecord[]
): DynamicPageBlockRecord | BlockDefinition | null {
if (CODE_BLOCKS[type]) return CODE_BLOCKS[type]
return dbDefs.find((d) => d.type === type) ?? null
}
CODE_BLOCKS is a static map of roughly sixty block definitions, imported at build time. dbDefs is whatever the database returned. The order in resolveBlockDef is the entire argument of this post: code wins, every time, and the database is the fallback. The rest of the design follows from which tier you reach.
Option A: code blocks, the typed and trusted tier
The first tier is a static registry. Each entry is a fully typed React component packaged with metadata, default props, and an editor schema. The shape every code block satisfies is this interface:
export interface BlockDefinition {
type: string
label: string
description: string
category: string
/** Emoji or icon name shown in the block picker */
icon?: string
/** Keywords used for search matching in addition to label */
tags?: string[]
/** Approximate rendered height in px — used for skeleton fallback in DynamicPageRenderer */
skeletonHeight?: number
defaultProps: Record<string, unknown>
schema: Record<string, FieldSchema>
Component: ComponentType<Record<string, unknown>>
}
The load-bearing field is Component. A code block carries an actual React component, so the renderer can mount it directly and pass the block's props straight in. The schema field drives the editor's props panel, defaultProps seeds a fresh instance, and skeletonHeight lets the page reserve space while the component loads.
What you get from this tier is everything you expect from real code. The component is type-checked, it goes through review and CI, it can use hooks, fetch data, and compose other components. A hero block and a pricing-table block can be arbitrarily complex because they are nothing more or less than React.
What you pay is deployment latency. Adding a code block means editing the registry map, writing the component, opening a pull request, and shipping. A marketing person who wants a new kind of section on Friday cannot have one until an engineer ships it. For the blocks that form the visual vocabulary of the site, that cost is fine — you want those reviewed. For one-off campaign sections, it is friction the second tier exists to remove.
There is a second, quieter benefit to the code tier worth pulling out: the schema field is Record<string, FieldSchema>, the same field type the editor reads to build its props panel. Because the component and its editor schema ship together in one file, the person who changes the component is the person who changes the form, in the same review. That is the kind of coupling you want — when a code block grows a new prop, the field that edits it travels in the same commit. The editor never drifts away from the component, because nobody can ship one without the other passing the build. The cost of the trusted tier, then, is not just latency; it is that every block-shaped idea has to be expressible as a typed React component before it can exist at all. Most are. The ones that are not are precisely what the database tier is for.
Option B: database blocks, the fast and unsafe tier
The second tier is a Prisma model. A row in it defines a block that no engineer compiled:
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)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
}
The two fields that have no equivalent in the code tier are template and script. A database block has no Component; instead it stores template, a string of HTML with {{token}} placeholders, and an optional script, a string of JavaScript. The type column is unique, which matters for the resolver — there can only be one DB block per type name. isSystem flags rows the platform ships and seeds rather than rows an operator created.
The TypeScript view of that row drops the timestamps and keeps the runtime shape:
// DB-stored block definition (no Component — resolved at runtime)
export interface DynamicPageBlockRecord {
blockId: string
type: string
label: string
category: string
description: string
schema: Record<string, FieldSchema>
defaultProps: Record<string, unknown>
template: string
script?: string
isSystem: boolean
}
What you get from this tier is speed with no deploy. An admin opens the dashboard, writes some HTML, names the block, and it is live. No pull request, no build, no release. The DB block still carries a schema and defaultProps, so it slots into the same editor as a code block — an operator filling in a DB block's props panel cannot tell from the form which tier they are editing. The experience is uniform; the substrate underneath is not.
What you pay is that the block is now a string template instead of a typed component, and that string is about to be treated as executable. Nothing about the template or script columns is checked by the type system, the database, or the renderer beyond a non-null default. A String column can hold a valid template, a broken one, or a hostile one, and the platform finds out which at render time in the visitor's browser. The renderer for this tier is where that cost becomes concrete.
How the renderer chooses, and what it does with each tier
The runtime resolution lives in one client component. It calls the code registry first and only consults the database records if the registry has nothing:
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>
)
}
This is the same ordering as resolveBlockDef, now applied to rendering rather than lookup. A code block mounts its Component with the instance's props spread in. A database block is handed to TemplateBlockRenderer. A type that matches neither returns null, so the page keeps rendering with one block missing rather than crashing. Both branches sit inside BlockRenderErrorBoundary, so a block that throws at render costs you that block and nothing else — which matters when non-developers are assembling pages from sixty-plus block types.
The ordering has a property worth naming: because code is checked first, a database row can never shadow a code block. If someone creates a DB block whose type collides with a registered code block, the code block still wins and the DB row is dead weight. The reviewed path is authoritative by construction, not by convention.
The list wrapper above this does the sort, the hidden-filter, and one special case:
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>
)
}
Position is data, not array index — the order sort means storage order does not matter. The hidden !== true filter lets an editor park a block without deleting it. The popup-modal branch renders bare, because wrapping a fixed overlay in a className div creates a stacking context that traps the modal behind other content. That last one is the kind of detail you only find by shipping, and it shows this resolver is driving a real, lived-in editor rather than a demo.
The deciding factor: what TemplateBlockRenderer actually does
The database tier is the riskier one, so it is worth seeing exactly how a stored string becomes a rendered block:
const replaceTokens = (str: string, props: Record<string, unknown>) =>
str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const val = props[key]
return val !== undefined && val !== null ? String(val) : ''
})
export default function TemplateBlockRenderer({ template, props, script, blockType }: Props) {
useEffect(() => {
if (!script || !blockType) return
const id = `block-script-${blockType}`
if (document.getElementById(id)) return
const el = document.createElement('script')
el.id = id
el.textContent = replaceTokens(script, props)
document.body.appendChild(el)
}, [blockType, script]) // props intentionally omitted — script tokens fixed at first render
if (!template) {
return (
<div className="py-20 px-6 flex items-center justify-center min-h-40 bg-base-200 border-2 border-dashed border-base-content/20">
<p className="text-base-content/30 text-sm">Block has no template.</p>
</div>
)
}
const html = replaceTokens(template, props)
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
This is the honest center of the design. replaceTokens is a single regex over {{name}} placeholders. For each match it looks the key up in the block's props and substitutes String(val) — no escaping, no encoding, no allow-list. The substituted string is then handed to dangerouslySetInnerHTML, which mounts it as raw HTML. The optional script gets a parallel treatment: it is token-substituted the same way and appended to the document body as a live <script> element, guarded only by an id check so it is injected once per block type.
There is no sanitizer anywhere in this path. A database block's template is HTML that runs, and its script is JavaScript that runs. The author of a DynamicPageBlock row is not writing content — they are writing code that executes in every visitor's browser. The props intentionally omitted comment on the effect's dependency array tells you the script's tokens are frozen at first render, which is a behaviour choice, not a safety control.
That is the deciding factor. The platform does not try to make template blocks safe to render. It makes them safe to author by restricting who can author them. Creating and editing DynamicPageBlock rows is an admin-only operation. The security boundary sits at who can define a block, not at what a block may contain. Hold that line and the design works; loosen it and the database tier becomes a stored-XSS vector by design.
The trade-off, stated plainly
What the two-tier registry buys is real and worth naming without hedging. Admins mint new blocks with no deploy — a campaign section is a dashboard task, not an engineering ticket. Developers ship typed, reviewed, arbitrarily complex blocks through the repo. Both kinds live in the same page, resolved by the same renderer, sorted into the same array. Code wins ties, so the trusted path can never be silently overridden by the fast path.
What it costs is that the database tier is effectively a code-deploy surface wearing content's clothes. replaceTokens does String(val) with no escaping; the template flows through dangerouslySetInnerHTML; the script becomes a live tag. There is no sandbox, no tag allow-list, no script stripping. The only thing standing between a database block and arbitrary execution in a visitor's browser is the admin-only permission on the authoring endpoint. That is a boundary about authorship, not about containment — and the two are not the same guarantee.
The alternative path would be to sandbox every template, strip scripts, and allow-list tags. That would make custom blocks safe for any user to author, but it would also strip them of the power that justifies their existence — a sanitized block that cannot run a script or emit arbitrary markup is barely more than a code block with extra steps. The platform chose power plus a tight permission. That is a defensible call, but only for as long as the permission actually holds. There is no fake certainty available here: the design is one access-control regression away from a serious problem, and it is honest about that by keeping the surface admin-only.
Business impact
For a team that runs its own site, this is the difference between a page builder their marketers can drive and one that funnels every layout change back through engineering. The code tier keeps the brand's core sections reviewed and type-safe; the database tier lets the people closest to a campaign ship a one-off section the same afternoon. The cost lands on whoever owns access control: the admin role on block authoring is now a security-critical permission, not a convenience. That is a clear, manageable obligation as long as everyone treats granting it like granting deploy access — because functionally, that is what it is.
What to do next
If you are weighing a similar split, do the cheap experiment first: take the resolveBlockDef ordering — code registry checked before database records, null if neither matches — and decide whether your fallback tier renders trusted strings or untrusted ones. If it renders untrusted strings, you need a sandbox before you ship, not after. If you can guarantee admin-only authorship and treat that endpoint as a deploy surface, the {{token}} plus dangerouslySetInnerHTML approach is fast and maintainable. Worth a sanity check on where your security boundary sits — on who can author a block, or on what a block may contain — before the first non-developer gets write access.
If you want a second pair of eyes on a content model you are designing, that trade-off is the kind worth talking through while it is still a diagram. See /services for how that review works, or /case-studies for how similar builds played out.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox