The Key Trail: The Easy Way to Run Form-Heavy CMSs
Let the route's params pick the data, the state path and the form. In a form-heavy CMS or admin tool, a new screen becomes one route plus one field array, with no new endpoint and no new form code.
3 min read
At Rhombus, an ad-tech startup that Kargo acquired in 2020, much of the product work wasn't the product itself.
The approach that kept that work manageable is one I call the key trail. It isn't new. Frameworks have since made route-driven data loading the default, and schema-driven forms are well established. But it's still the simplest way I know to run a form-heavy CMS or admin system, so here it is as a small pattern you can copy.
The idea#
A route like /site/acme/placements/12 is an ordered list of keys: ['acme', 'placements', '12']. That list, the key trail, does three jobs:
- It tells one generic API endpoint what to load. Nothing loads until the trail is complete.
- It's the path where that data lives in client state.
- It selects the form, as an array of field definitions that comes back with the data.
So adding a content type or screen means adding one route and one field array. There's no new endpoint and no new form component.
The pattern#
Hydration: one hook reads the params, waits for a complete trail, and loads it.
function useTrail(names: string[]) {
const params = useParams();
const keys = names.map((n) => params[n]).filter(Boolean) as string[];
return { keys, complete: keys.length === names.length };
}
function useRecord(names: string[]) {
const { keys, complete } = useTrail(names);
const store = useStore();
useEffect(() => {
if (complete) api.get(`/content/${keys.join('/')}`).then((r) => store.set(keys, r));
}, [complete, keys.join('/')]);
return { keys, record: store.get(keys) }; // { data, fields, meta }
}
Server side: one handler per verb, addressed by the same trail.
// GET /content/:trail* -> { data, fields, meta }
// PUT /content/:trail* -> validate against fields, save, return the new record
Rendering: one form walks the field array and looks each type up in a registry of your design-system inputs.
const registry = { text: TextInput, select: SelectInput, toggle: Toggle, list: ListField, group: GroupField };
function TrailForm({ fields, value, onChange, readOnly }) {
return fields.map((f) => {
const Input = registry[f.type];
return (
<Input key={f.key} field={f} value={value?.[f.key]} readOnly={readOnly || f.readOnly}
onChange={(v) => onChange({ ...value, [f.key]: v })} />
);
});
}
A screen is then just a route:
// /site/[site]/[section]/[id]
export default function Screen() {
const { keys, record } = useRecord(['site', 'section', 'id']);
const [draft, setDraft] = useState(record?.data);
useEffect(() => setDraft(record?.data), [record]);
if (!record) return <Skeleton />;
return <TrailForm fields={record.fields} value={draft} onChange={setDraft} />;
}
A field array looks like this:
[
{ "key": "name", "type": "text", "label": "Name", "required": true },
{ "key": "device", "type": "select", "label": "Device", "options": ["mobile", "desktop"] },
{ "key": "live", "type": "toggle", "label": "Live" }
]
Drafts and revisions#
Put the revision in the trail: /site/acme/placements/12/r/4. Each revision is a full snapshot with a status (pending, active or inactive). Edits only go to a pending revision. Publishing flips its status, and copying an old revision creates a new pending one. Because the revision number is part of the URL, a link to "the version we shipped last Tuesday" is a real thing you can send someone.
Inside the screen, the form edits a local copy of the record. "Unsaved changes" is just a comparison between that copy and what was loaded. Save sends the whole snapshot back to the same trail.
Nested content#
Nested records are longer trails. /site/acme/placements/12/targeting loads the targeting object that belongs to placement 12, stored under that key path, with its own field array. The parent form doesn't need to know about it.
For small nested objects that belong in the parent form, use a group field whose fields are another field array. GroupField renders a nested TrailForm and writes back under its own key. The same renderer handles every level of nesting.
Lists and repeaters#
A repeater is a list field with an item field array:
{ "key": "slots", "type": "list", "label": "Ad slots",
"item": [
{ "key": "unitPath", "type": "text", "label": "Unit path" },
{ "key": "size", "type": "select", "label": "Size", "options": ["300x250", "728x90"] }
] }
ListField renders one TrailForm per item and adds add, duplicate, remove and reorder controls. When a list item gets big enough to deserve its own screen, give it a trail (/placements/12/slots/3) and link to it instead.
Permissions and locking#
The server already knows who's asking and which record they're on, so it returns that context with the data:
"meta": { "status": "active", "canEdit": false, "lockedBy": null }
The screen passes readOnly={!meta.canEdit || meta.status !== 'pending'} to the form, and every input respects it. Individual fields can also carry readOnly or be left out of the array entirely for roles that shouldn't see them. Enforcement stays on the server: PUT rejects writes that the caller can't make or that target a non-pending revision. If two people might edit the same draft, add lockedBy and a version number so the second save fails cleanly instead of overwriting the first.
What I'd change#
In the versions I built, the field arrays described what to draw, not what was allowed. Validation was thin and lived in too many places. Today I'd attach a schema to each field array and run the same checks in the browser and in PUT.
I'd also version the field arrays, so a record remembers which shape it was saved with. And I'd generate TypeScript types from them, so an unknown field type or a malformed trail fails at build time instead of in front of a user.
Why it matters for agents#
There's one more reason I still like this pattern. When an agent needs to produce an admin screen, the key trail gives it a narrow, reviewable output: a route and a field array. Your own typed renderer draws it, using components that already exist in your design system. That's easier to check and safer to run than generated UI code.
Related
- Engineering
One Table, Four Audiences
The clinical data pipeline I've been building shows records to four very different kinds of user. Instead of four tables, there's one pattern: server-side pagination, state in the URL and a single scoping rule applied on the server. Here's how it fits together.
React · State management · Healthcare · Front-end5 min read
- Design
Swapping a Component Library Without Stopping the Product
Moving a React app from MUI to shadcn-style primitives sounds like a rewrite. It doesn't have to be. Here's the approach I set up: codemods for the boring parts, a thin layer of your own components, and a migration that ships one screen at a time.
Design systems · React · Front-end5 min read
- Leadership
The Tech Job Exodus Isn't New: When Opening the File Stopped Being Enough
Around 2012 and 2013, knowing how to operate the tools stopped being enough to hold a job on the web. You had to be able to build. The AI shift feels like that again, only faster. What I saw then, what's different now, and what I'd do about it.
Careers · AI · Front-end · Leadership6 min read