Documentation source
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`)
```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`)
```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** — `matches` 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.
```tsx
// 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`:
```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
| 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 `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](/docs/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.
## Related Modules
- [`features/custom-pages/`](https://github.com/tylerdr/amble/tree/dev/features/custom-pages) — platform-side types, queries, stub renderer.
- [`features/custom/pages/`](https://github.com/tylerdr/amble/tree/dev/features/custom/pages) — venture-side registry + concrete components.
- [`app/(app)/p/[slug]/page.tsx`](https://github.com/tylerdr/amble/tree/dev/app/(app)/p/[slug]/page.tsx) — route handler.
- [Workspaces feature doc](/docs/features/workspaces) — for the shadow/scope mechanics.
- [ADR-0013 D7](/docs/adr/0013-workspaces-as-scoped-tenants) — the scope resolution rule for `custom_pages`.