Sprinter Docs

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-gatedmatches lets the registry disambiguate generic slugs. A pre-existing slug='home' row stays on the v1 stub until the tenant stamps source.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.tsx imports from features/custom/pages (allowed, since app/ is not platform code under the depcruise no-platform-to-custom rule). Platform code never imports the registry.
  • Error isolation — a route-level error.tsx boundary in app/(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

  1. 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
    }
  2. 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,
      },
    };
  3. Populate the row — any tenant or workspace that wants this UI creates a custom_pages row with slug='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

SymbolModulePurpose
customPageRegistry@/features/custom/pagesThe slug → component map. Mutable at module level only.
getCustomPageRenderer(slug)@/features/custom/pagesLookup helper; returns undefined for unregistered slugs.
CustomPageRenderer@/features/custom/pagesType alias: ComponentType<{ page: CustomPage }>.
CustomPageStub@/features/custom-pagesFallback <pre> renderer for unregistered slugs.
getCustomPageBySlug(slug)@/features/custom-pages/server/queriesRLS-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 matches gate? The registry holds platform-shared components; tenants opt in by populating a row. But generic slugs like home are foot-guns — any tenant could already have a row at slug='home' shaped for the v1 stub. The matches predicate (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 ComponentType registry gives the route a renderer but gives future tooling nothing. By pairing each component with its Zod schema, the eventual authoring tool / manageCustomPages agent 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, createElement documents the intent (we are dispatching to a known component, not constructing one) and quiets the false positive.
  • Why an error.tsx boundary? 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-nothing safeParse on 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[].href is free-form jsonb. Passing it raw to a clickable <a> would let a hostile row store javascript: 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-payload with one of the four ADR-0006 Specs.

On this page