Custom Pages
Tenant- and workspace-scoped static pages, with a venture-side React component registry that replaces the v1 stub renderer.
Overview
A custom page is a custom_pages row keyed by (tenant_id, workspace_id, slug) that mounts at /p/<slug>. The router rewrites /t/<tenant>/p/<slug> and /t/<tenant>/w/<workspace>/p/<slug> to that single route, with the active workspace shadowing the tenant default per ADR-0013 D7.
The route renders through a venture-side registry: getCustomPageRenderer(slug) looks up a React component in features/custom/pages/registry.ts. Unregistered slugs fall through to <CustomPageStub>, which dumps source as plain text inside a <pre> (the v1 contract). Registering a new page is import + register — no platform changes, no DB metadata, no plugin runtime.
Status: v1 registry shipped (one pilot page registered: home).
Key Concepts
CustomPage (features/custom-pages/types.ts)
interface CustomPage {
id: string;
tenantId: string;
workspaceId: string | null; // NULL = tenant default; non-NULL = workspace-scoped
slug: string;
title: string;
source: Record<string, unknown>; // free-form jsonb; renderer-specific shape
runtime: "static";
createdBy: string | null;
createdAt: string;
updatedAt: string;
}Registry (features/custom/pages/registry.ts)
export type CustomPageRenderer = ComponentType<{ page: CustomPage }>;
export interface CustomPageRendererEntry {
component: CustomPageRenderer;
/** Zod schema for `source`. Used by future authoring tools / agents to generate valid jsonb. */
schema?: ZodTypeAny;
/** Optional gate — only handle rows whose `source` passes the predicate. */
matches?: (source: Record<string, unknown>) => boolean;
}
export const customPageRegistry: Record<string, CustomPageRendererEntry> = {
home: {
component: HomeDashboard,
schema: homeSourceSchema,
matches: (source) => source.renderer === HOME_DASHBOARD_RENDERER_KEY,
},
};
export function getCustomPageRenderer(
page: Pick<CustomPage, "slug" | "source">,
): CustomPageRenderer | undefined;- Slug-keyed + source-gated —
matcheslets the registry disambiguate generic slugs. A pre-existingslug='home'row stays on the v1 stub until the tenant stampssource.renderer = 'home-dashboard'. - Schema travels with the component — registered renderers expose the shape they expect via
schema, so agents and future authoring UIs don't have to reach into the component implementation. - Server components — registered components must be RSC-compatible. Client interactivity goes inside
'use client'children. - Platform → custom boundary — the route file
app/(app)/p/[slug]/page.tsximports fromfeatures/custom/pages(allowed, sinceapp/is not platform code under the depcruiseno-platform-to-customrule). Platform code never imports the registry. - Error isolation — a route-level
error.tsxboundary inapp/(app)/p/[slug]/catches renderer crashes so a bad custom component never kills the app shell.
How It Works
URL: /t/marbella/w/deals/p/home
↓ proxy.ts rewrite → /p/home + x-tenant-slug + x-workspace-slug
↓ PostgREST hook validates membership, pins app.tenant_id + app.workspace_id GUCs
↓ getCustomPageBySlug('home')
├─ workspace-scoped row exists for slug='home' → returned (D7 shadow)
└─ otherwise: tenant-default row (workspace_id IS NULL) → returned
↓ getCustomPageRenderer('home') → HomeDashboard
↓ <HomeDashboard page={page} />If getCustomPageRenderer(slug) returns undefined, the route nullish-coalesces to CustomPageStub and the <pre> text fallback is preserved.
Registering a custom renderer
-
Build the component in
features/custom/pages/components/. Server component preferred.// features/custom/pages/components/marbella-deal-flow.tsx import { z } from "zod"; import type { CustomPage } from "@/features/custom-pages/types"; export const MARBELLA_DEAL_FLOW_RENDERER_KEY = "marbella-deal-flow" as const; export const marbellaDealFlowSchema = z.object({ renderer: z.literal(MARBELLA_DEAL_FLOW_RENDERER_KEY).optional(), // ...your source shape, with `.optional().catch(undefined)` per field // so a single bad field doesn't blank adjacent sections. }); export function MarbellaDealFlow({ page }: { page: CustomPage }) { const parsed = marbellaDealFlowSchema.safeParse(page.source); // ...render } -
Register the component in
features/custom/pages/registry.ts:import { MarbellaDealFlow, marbellaDealFlowSchema, MARBELLA_DEAL_FLOW_RENDERER_KEY, } from "./components/marbella-deal-flow"; export const customPageRegistry: Record<string, CustomPageRendererEntry> = { home: { ... }, "marbella-deal-flow": { component: MarbellaDealFlow, schema: marbellaDealFlowSchema, matches: (s) => s.renderer === MARBELLA_DEAL_FLOW_RENDERER_KEY, }, }; -
Populate the row — any tenant or workspace that wants this UI creates a
custom_pagesrow withslug='marbella-deal-flow',source.renderer='marbella-deal-flow', and the rest of the component's expected shape.
That's it. No migration, no manifest, no install step. The matches gate keeps any pre-existing rows safe — they fall through to the v1 stub until a tenant explicitly opts in.
API Reference
| Symbol | Module | Purpose |
|---|---|---|
customPageRegistry | @/features/custom/pages | The slug → component map. Mutable at module level only. |
getCustomPageRenderer(slug) | @/features/custom/pages | Lookup helper; returns undefined for unregistered slugs. |
CustomPageRenderer | @/features/custom/pages | Type alias: ComponentType<{ page: CustomPage }>. |
CustomPageStub | @/features/custom-pages | Fallback <pre> renderer for unregistered slugs. |
getCustomPageBySlug(slug) | @/features/custom-pages/server/queries | RLS-aware row fetch (workspace shadows tenant default). |
For Agents
Agents do not yet author custom-page rows directly — this v1 ships the renderer registry only. The deferred follow-up of an authoring tool would expose manageCustomPages mirroring manageView. Until then, use the admin UI at /admin/custom-pages.
Design Decisions
- Why slug-keyed plus a
matchesgate? The registry holds platform-shared components; tenants opt in by populating a row. But generic slugs likehomeare foot-guns — any tenant could already have a row atslug='home'shaped for the v1 stub. Thematchespredicate (paired with the renderer's discriminator key, e.g.source.renderer='home-dashboard') means a pre-existing row stays on the safe<CustomPageStub>until the tenant explicitly stamps in. Per-tenant overrides above the row layer (a(tenant, slug)keyed sub-registry) are out of scope for v1. - Why ship the schema with the component? A bare
ComponentTyperegistry gives the route a renderer but gives future tooling nothing. By pairing each component with its Zod schema, the eventual authoring tool /manageCustomPagesagent can generate forms or inject schema context into prompts without reaching into the component implementation. - Why
createElement(renderer, { page })instead of<Renderer />? The React Compiler lints capitalized variables assigned from function calls as "components created during render," because that pattern often leaks state on remount. For a stable registry lookup,createElementdocuments the intent (we are dispatching to a known component, not constructing one) and quiets the false positive. - Why an
error.tsxboundary? Custom renderers come from venture-side code that the platform doesn't fully control. A bad component should never crash the(app)layout shell. The route-level boundary catches the error, surfaces a recoverable message, and lets the user retry without losing their session. - Why per-field
.catch(undefined)in the schema? All-or-nothingsafeParseon free-form jsonb means one bad metric value (numeric instead of string) silently blanks out subtitle, body, and actions too. Per-field.catch()keeps the rest of the page rendering and only drops the malformed section. - Why reject non-app-relative hrefs in
actions[]?source.actions[].hrefis free-form jsonb. Passing it raw to a clickable<a>would let a hostile row storejavascript:URIs or off-site URLs. The Zod refinement (startsWith("/") && !startsWith("//")) gates it to safe app-relative paths only. - Why not a plugin runtime? A bespoke iframe + esbuild + postMessage SDK was retired in ADR-0019. The registry lookup ships in a few lines, gives full RSC + shadcn integration, and is trivially extensible. For agent-authored UI, the canonical path is
manageRendererPlugin.bind-spec-payloadwith one of the four ADR-0006 Specs.
Related Modules
features/custom-pages/— platform-side types, queries, stub renderer.features/custom/pages/— venture-side registry + concrete components.app/(app)/p/[slug]/page.tsx— route handler.- Workspaces feature doc — for the shadow/scope mechanics.
- ADR-0013 D7 — the scope resolution rule for
custom_pages.