Design an admin CMS
An internal admin or headless CMS where editors list, create and edit entries of many content types: screens generated from schemas, server-driven list views, large forms with validation, drafts and scheduled publishing, two editors on one entry, a media library, and permissions down to single fields.
The question
Design the web app our content team uses to manage everything on our sites: articles, authors, products, landing pages, FAQs, around 40 content types today and more every quarter. Editors list entries, filter them, edit them in forms, save drafts and publish.
Developers add new content types by writing a schema, and they don't want to build new screens each time. Walk me through how the screens get built, list views, forms, drafts and publishing, what happens when two people edit the same entry, media, permissions and history.
Real-life examples: Strapi admin panel, Sanity Studio, Contentful web app, Django admin, A company's internal back office for products, orders and users
Requirements: one app, forty content types
A CMS (content management system) is the app people use to create and change the content a website or app shows. An admin CMS is the internal side of it: a back office where editors, not readers, do their work. Strapi, Sanity Studio, the Contentful web app and Django admin are all versions of the same idea.
A headless CMS stores content and serves it over an API, and some other app (the public site, a mobile app) renders it. Our design is the editing UI, whether the content is shown by our own site or by many.
Every screen in such an app is one of two shapes. A list view shows many entries of one type in a table, with search, filters and bulk actions. A detail view shows one entry as a form you edit and save. With 40 types, each with its own fields and more every quarter, the core question of the whole design is: who builds those 80 screens?
Questions to ask
Each question below changes a real decision later in the guide. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many content types, and who adds them? | 40 today, about 5 new per quarter, added by developers as schema files. | Screens must come from the schema, not hand-written code per type. |
| How big are the lists? | Most types have hundreds of entries. Articles have 400,000. Order records in the back office have 30 million. | Pagination, sorting and filtering happen on the server. The client never holds a whole type. |
| How big are the forms? | Usually 10 to 30 fields. The landing page type has 120, with nested blocks and 8 relations. | Sections and tabs, lazy field components, and validation that scales. |
| Who uses it, and how many? | 150 editors in 4 time zones, plus 20 agency freelancers with limited access. | Roles per type and per field, scheduled publishing with time zones, audit history. |
| Do two people edit the same entry? | Rarely at once, but it happens on busy articles and on the homepage. | Conflict detection on save, and a hint that someone else is editing. |
| Is content translated? | Yes, into 9 locales (a language plus an optional region, such as de-AT), by translators who only see their own locale. | Fields marked as localized or shared, fallbacks, translation status. |
| What devices? | Desktop browsers, 1280 pixels wide and up. Phones only for a quick fix or approval. | Dense tables and side panels are fine. Touch is not a first-class target. |
Functional requirements
- Navigate between content types in a sidebar; open a list view per type.
- List views: server-side pagination, sorting and filters, all stored in the URL; saved views; select rows and run bulk actions such as publish, archive or delete.
- Detail views: a form generated from the type's schema, with field validation, relation pickers, rich text, media fields, and an unsaved-changes guard.
- Drafts and publishing: save a draft, publish now or at a scheduled time, preview a draft on the real site, see and restore older versions.
- Safe concurrent editing: never silently overwrite another editor's work.
- A media library: upload, crop around a focal point, require alt text, see where an asset is used before deleting it.
- Permissions per role, per content type and per field; a history of who changed what.
- Developers can add custom field components and dashboard widgets without forking the app.
Non-functional requirements
- A list view shows its first page in under 1 second on office Wi-Fi, and a page change, sort or filter takes under 500 ms at the 95th percentile.
- A detail form for a 120-field type is usable in under 1.5 seconds after a click from the list.
- The JavaScript for the shell stays under 250 KB compressed; field editors such as rich text or a map picker load only when a form needs them.
- No silent data loss: not from a closed tab, not from a second editor, not from a failed save.
- Fully keyboard operable and usable with a screen reader, including complex fields. Internal tools still fall under accessibility law in many countries, and some editors rely on assistive technology.
- The server enforces every permission; the UI only reflects them.
Out of scope
- The schema builder UI (a drag-and-drop editor for content types). Our developers write schemas as code, so we only consume them.
- Real-time co-editing of the same field, Google Docs style. Editors work on different entries almost all the time; we cover the rare overlap with conflict detection and presence instead.
- How the public site renders content and caches it, except where preview touches it.
- Workflow engines with many approval stages. We support draft, review and published, and mention where a richer workflow plugs in.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes an admin CMS hard
An admin CMS looks boring: tables and forms. The difficulty is not in any one screen. It's in five properties that every screen shares, and each one drives a later chapter.
- Many shapes, one app. 40 content types with different fields, growing every quarter. If each new type needs a frontend release with two new screens, the frontend team becomes the bottleneck for the whole company. The UI has to be data: generated from a schema at runtime.
- Long-lived, valuable edits. An editor may spend 40 minutes on one landing page. Losing that work to a closed tab, an expired session or another editor's save is the worst bug this app can have. Every save path needs a failure story.
- Two versions of the truth. Every entry has a draft that editors change and a published version readers see. Scheduling, preview and version history all come from keeping those apart cleanly.
- Huge lists. 400,000 articles and 30 million orders mean the list view is a window onto a server query. The URL, the API and the table must agree on what that query is.
- Uneven trust. A staff editor, an agency freelancer and a translator see the same app with different powers, down to single fields. The UI must reflect permissions honestly, while the server enforces them.
Notice what is not on the list: rendering speed of a single view, real-time feeds, offline mode. An admin CMS is a correctness problem first and a performance problem second. A senior answer spends its minutes on schemas, saves and conflicts, not on virtualizing a 50-row table.
Think first
If schemas generate every screen, why not go all the way and let editors build their own content types in the browser, like a no-code tool?
Because content types are a contract with the code that renders them. If an editor renames heroImage to banner, the public site's template breaks. Developers own the schema, review it in pull requests and deploy it with the site code.
Many products do offer a type builder in the UI, but they still treat a type change as a migration with consequences. Strapi's Content-type Builder only edits types in development, because a change rewrites schema files and restarts the server, and Strapi's docs say those files belong in source control. For our scope, schemas are code, and the admin only reads them.
Schema-driven UI: screens as data
Start with the naive plan: a developer builds a list page and a form page for each content type, by hand, in React. For the first 3 types this is the best UI you can get. By type 40 it's a trap: if each new type costs about 2 days of frontend work, 5 types a quarter is 10 days of a developer doing copy and paste.
Worse, the copies drift: the FAQ form warns about unsaved changes and the Author form doesn't, because someone forgot. Every fix to shared behavior has to be applied 40 times.
The way out is the idea from the config-driven-ui lesson: describe each type once as data, a schema, and let generic components turn that data into screens. A schema here is a description of a content type: its fields, each field's type (text, number, date, enum, relation, media, rich text), its validation rules, and a few hints for the UI.
Decision How do the screens for 40 content types get built?
What we pick: Generated by default with plugin overrides. It's the model Strapi (custom fields), Sanity Studio (custom input components) and Payload CMS (custom field and cell components) all follow, and Django admin generates its screens from model classes in the same spirit. The schema handles the common case for free, and the escape hatches are graded: a custom field first, a custom view only when needed.
What a schema looks like
The schema says what the data is and gives the UI a few hints. It doesn't say how a text input looks. That keeps it usable by the server too: the same file drives the database columns, the API validation and the admin screens.
export const article = defineType({
name: "article",
label: "Article",
titleField: "title",
fields: [
{ name: "title", type: "string", required: true, maxLength: 120, localized: true },
{ name: "slug", type: "slug", from: "title", unique: true },
{ name: "category", type: "enum", options: ["news", "guide", "opinion"] },
{ name: "author", type: "relation", to: "author", required: true },
{ name: "tags", type: "relation", to: "tag", many: true, max: 8 },
{ name: "hero", type: "media", accept: ["image/*"], altRequired: true },
{ name: "body", type: "richText", localized: true },
{ name: "displayDate", type: "datetime" },
],
// status (draft, published...) and updatedAt are system fields every type has
list: { columns: ["title", "author", "status", "updatedAt"], defaultSort: "-updatedAt", paging: "cursor" },
form: { tabs: [
{ label: "Content", fields: ["title", "slug", "hero", "body"] },
{ label: "Meta", fields: ["category", "author", "tags", "displayDate"] },
] },
});
The field registry: one entry per field type
The generic views never contain a switch over field types. They ask a field registry: a map from field type to the four things every screen needs from it. A list needs a Cell, a form needs an Input, the filter bar needs a Filter, and everyone needs validate. Adding a custom type, say geoPoint with a map picker, means registering one more entry.
type FieldPlugin<V> = {
Cell: React.ComponentType<{ value: V; field: FieldDef }>;
Input: React.LazyExoticComponent<React.ComponentType<InputProps<V>>>;
Filter?: React.ComponentType<FilterProps<V>>;
validate: (value: V, field: FieldDef) => string | null;
sortable: boolean;
};
registry.register("string", stringPlugin);
registry.register("relation", relationPlugin);
registry.register("richText", {
...richTextPlugin,
Input: lazy(() => import("./fields/RichTextInput")),
});
registry.register("geoPoint", geoPointPlugin); // added by the stores team
Input is lazy on purpose. A full rich text editor with its extensions can add 100 KB or more, and a map picker more; a form that has neither should never download them. The performance chapter comes back to this.
From the schema, the app derives three things. List columns: the list.columns hint, or else the title field plus the first 3 short fields, plus updatedAt. Rich text, media and many-relations never become columns, because they don't fit in a cell.
Filters: one per field whose plugin has a Filter: an enum becomes a multi-select, a date becomes a range, a relation becomes a search picker, a boolean becomes a toggle. The form: tabs and sections from form, one Input per field, and a validator built by running every field's validate plus the schema rules (required, maxLength, max). Django admin follows the same idea with a ModelAdmin class, whose list_display, list_filter and search_fields options are hints of exactly this kind.
When generic stops being enough
Generated UI is great until the screen is a workflow rather than a record. Three real cases from back offices: an order page where the main action is "refund 2 of 3 items", not editing fields; a landing page builder where editors drag blocks and want a live preview beside the form; a dashboard that shows numbers, not entries. Forcing these into a field list produces screens nobody can use.
| Escape hatch | Use when | Example |
|---|---|---|
| Field hints in the schema | The default input is right but needs a tweak. | rows: 3 on a text area, placeholder, a help text. |
| Custom field type | A value needs its own input, cell or filter. | geoPoint with a map, price with amount and currency. |
| Custom section in a form | Several fields act as one widget. | An SEO panel that shows a Google result preview from title and description. |
| Custom view for a type | The screen is a workflow, not a record. | The order page with refunds and shipment timeline. |
| Custom route | It's not about content at all. | A sales dashboard, a bulk import tool. |
Order matters: always try the smallest hatch first. A custom view loses the shared behavior (the unsaved-changes guard, the conflict check, history) unless it rebuilds it on purpose. So the plugin API gives custom views the same form state hooks the generic form uses.
Check your understanding · Exercise 2
A developer adds a new field price with type money (an amount and a currency). Which pieces should they have to write? Select all that apply.
List views: a window onto a server query
The list view is where editors start every task: "find the draft article about pricing that Ana wrote last week". The data-table guide covers the table itself (columns, sticky headers, row selection, virtualization). Here we focus on what's specific to a CMS: the list is a server query generated from a schema, it lives in the URL, it can be saved, and it's the launch pad for bulk actions.
The query lives on the server and in the URL
The naive list fetches all entries of a type and filters in the browser. For FAQs (80 entries) that works. For articles it would download 400,000 rows, around 200 MB of JSON. So paging, sorting and filtering all happen in the database, and the client sends a description of what it wants.
That description is the list's real state, and as the url-as-state lesson argues, it belongs in the URL: a teammate can open the link, the back button undoes a filter, and a refresh lands on the same page.
/content/article?q=pricing&status=draft&author=usr_ana
&updated=2026-10-12..2026-10-19&sort=-updatedAt&cursor=eyJ1IjoiMjAy
GET /api/entries/article?q=pricing&filter[status]=draft
&filter[author]=usr_ana&filter[updatedAt][gte]=2026-10-12
&sort=-updatedAt&limit=50&cursor=eyJ1IjoiMjAy
Filter names come from the schema, so the list can validate the URL before sending it: an unknown field or an operator the field type doesn't support (a range on an enum) is dropped and the URL rewritten with history.replaceState. That stops a stale bookmark from turning into a 400 error page.
Decision Page numbers or cursors?
What we pick: Offset pages with an exact total for small types, cursors for big ones, chosen per type by a schema hint (list.paging). Editors rarely jump to page 300: they filter instead. For the orders type, the footer says "about 30,100,000 results" from the database's row estimate, never an exact count.
Saved views
Editors repeat the same filters every day: "my drafts", "scheduled this week", "missing alt text". A saved view is a name plus the list's URL query plus the chosen columns. Because the state already lives in the URL, saving a view is just storing that query string on the server under the user (private) or the team (shared).
Opening a view navigates to the stored URL. Keep views on the server, not in localStorage, so they follow the editor to another laptop and so a lead can share a view with the whole team.
Bulk actions and partial failure
Select 50 rows, press Publish. The naive version fires 50 requests from the browser with Promise.all, and three things go wrong. If the tab closes after 20, the other 30 never happen and nobody knows. Promise.all rejects on the first failure, so the UI can't say which ones failed, and "select all 2,300 matching this filter" would mean 2,300 requests.
The better design sends one request that describes the selection: either a list of ids (up to a page) or the filter itself ("all matching"). The server creates a bulk job, returns 202 Accepted with a job id, and processes entries one by one, checking permissions and validation for each. The client polls the job (or listens on a server-sent events stream) and shows progress. When it ends, the result lists every failure with a reason.
{
"id": "job_8f2",
"action": "publish",
"status": "done",
"total": 2300,
"succeeded": 2297,
"failed": [
{ "id": "art_19", "code": "validation", "message": "Hero image is missing alt text" },
{ "id": "art_77", "code": "forbidden", "message": "You can't publish opinion articles" },
{ "id": "art_903", "code": "conflict", "message": "Locked by Ana Ruiz" }
]
}
Two details matter. When the filter is the selection, freeze it: the server resolves the ids when the job starts, so an entry that starts matching mid-job isn't silently published. And destructive actions (delete) take a confirmation that states the count in words, "Delete 2,300 articles", with the count typed back for anything above 100.
Check your understanding · Exercise 3
An editor filters to 2,300 drafts and presses "Select all matching, Publish". Which designs are sound? Select all that apply.
Forms: big, validated, and hard to lose
The detail view is a form generated from the schema. For most types it has 15 fields and is easy. The landing page type has 120, nested blocks, 8 relations and 3 rich text areas. Design for that one, and the small ones come for free.
Form state that scales to 120 fields
The naive form keeps every value in one useState object at the top. Each keystroke creates a new object and re-renders all 120 fields, including 3 rich text editors. On a mid-range laptop that can take tens of milliseconds per key, which you can feel as lag.
The form-state lesson covers the fix: a form store outside React (React Hook Form, TanStack Form, or our own small store) where each field subscribes only to its own value. A keystroke in the title re-renders the title input and the few things that watch it, like the slug preview.
The store keeps two copies of the entry: the baseline (what the server last returned) and the current values. Dirty state is the difference between them, per field. That one comparison feeds four features: a dot on each tab that has changes, the Save button's enabled state, the unsaved-changes guard, and the field list in the save request (send only changed fields, a PATCH).
Sections, tabs, and where errors hide
Tabs come from the schema's form.tabs. Tabs keep a 120-field form scannable, but they hide errors: the editor presses Save on the Content tab and the error is on the Meta tab. So each tab label shows a count of its errors ("Meta, 2 errors"), and on a failed save the form moves focus to an error summary at the top that links to each field, switching tabs when a link is followed. The multi-step-form guide builds that error summary in detail; reuse it as is.
Validation from the schema, twice
The schema rules (required, maxLength: 120, max: 8 tags, unique) compile into a validator that runs in the browser on blur and on save. The same schema runs on the server, which is the real judge, and some rules can only run there: unique slug, "the related author must be published". As the input-validation lesson says, client checks are for speed, never for safety. The server returns 422 with errors keyed by field path, like blocks.3.cta.url, and the form maps each path back to its input.
Relation pickers
A relation field links to entries of another type: an article's author, its 8 tags. There may be 3,000 authors, so the picker is a combobox that searches the server: type 2 characters, wait 200 ms, call GET /api/entries/author?q=an&limit=20&fields=id,title,avatar, cancel the previous request with AbortController. Each result shows the target type's titleField and status, so editors don't link a draft author by mistake.
Two extras save real time. "Create new author" opens a nested form in a side panel and links the result on save, and each chosen relation has an "Open" link to its own entry. The keyboard and screen reader rules come from the WAI-ARIA combobox pattern; the accessibility chapter lists them.
Rich text fields embed the editor from the rich-text-editor guide. The CMS adds one thing on top: the editor stores structured JSON (blocks and marks), not HTML, so the public site can render it anywhere and the history chapter can diff it.
Saving: explicit, optimistic, or automatic
Decision How does an edit reach the server?
What we pick: Explicit save to the server plus a local backup. The form writes its dirty values to IndexedDB every few seconds, so a crash or closed tab can be recovered ("You have unsaved changes from 10:42. Restore?"). Optimistic updates, as the optimistic-updates lesson describes them, are kept for small one-click actions in the list, like toggling a featured flag, where a rollback is easy to show. Autosave is a valid choice when drafts are personal; we note it as a variant.
If-Match: "v43".useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (form.isDirty && !form.isSaving) void form.save();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [form]);
The unsaved-changes guard
Two exits need guarding. Closing or reloading the tab: listen for beforeunload and call event.preventDefault() while the form is dirty; browsers then show their own generic prompt (no custom text is allowed). Moving inside the app: the router must block navigation and show our own dialog with Save, Discard and Cancel. Register the beforeunload listener only while dirty and remove it after a save: web.dev's back/forward cache guide recommends exactly that, because the listener has made pages ineligible for that cache in the past and is still unreliable.
Check your understanding · Exercise 4
An editor changes the title, switches tabs, and presses Cmd+S. The server says the slug is already taken. Which behaviors are right? Select all that apply.
Drafts, publishing, preview and versions
Readers must never see half-done work, and editors must be able to change a live article without taking it offline. So every entry has two faces: the draft editors change and the published version the site serves. Scheduling, preview, history and restore all come from how those two are stored.
Decision How are drafts and published content stored?
What we pick: The version log. The cost is storage, and text is cheap: 50 saves of a 20 KB article is 1 MB. The next chapter reuses the version number as the concurrency token, so one design serves two features.
The UI shows the state from the two pointers, not from a stored flag. No published version: Draft. Draft equals published: Published. Draft newer than published: Changed (live, with newer edits waiting), the state editors most often misread.
The Contentful web app shows entries with these same three names, plus Archived: a flag for unpublished entries that hides them from lists and pickers without deleting them. Our header shows the state as a chip. When it reads Changed, two buttons sit next to it: "Publish changes" and "Discard changes" (which resets the draft to the published version).
Scheduled publishing across time zones
An editor in Berlin schedules a US sale page for "9:00 on Monday 2 November". Whose 9:00? The naive date picker uses an input type=datetime-local, which returns a string with no zone, and the code calls new Date(value), which reads it in the editor's own zone. The page goes live at 9:00 Berlin, 3:00 in the morning in New York.
Converting by hand is no safer. Europe leaves summer time on 25 October 2026 and the US on 1 November, so for that one week New York is 5 hours behind Berlin instead of the usual 6. Only a time zone database gets this right, which is why the zone travels with the time:
- The picker asks for a time and a zone, defaulting to the site's audience zone from the schema, not the browser's. Zones are IANA time zone names such as
America/New_York, the identifiers browsers and servers share, which carry each region's daylight saving rules. - The client converts the wall time in that zone to one UTC instant and sends it as ISO 8601:
2026-11-02T14:00:00Z. - The confirmation shows both: "Mon 2 Nov, 09:00 New York (15:00 your time, Berlin)", formatted with
Intl.DateTimeFormatand itstimeZoneoption. - The server runs the job. The browser never publishes on a timer: the editor's laptop may be asleep.
- The schedule pins a version. If the draft changes after scheduling, the header warns "Scheduled version is older than your draft" and offers to reschedule with the new one.
Preview drafts on the real site
A form can't show how a landing page will really look: the site's fonts, layout and components do that. So the editor needs the real site, rendering the draft. The site normally only serves published content, and it must not trust the admin's login cookie (different origin, different app).
The answer is a preview token: a short-lived signed token, created by the CMS API for one entry and one version, that lets the site fetch that draft and nothing else. Hosted CMSs give the site the same power in their own ways: Contentful through a separate Content Preview API with its own access token, Sanity through a drafts perspective on queries. On a Next.js site, the receiving end is Draft Mode: a route checks the secret, sets a cookie, and the page then skips its caches and fetches drafts.
no-store so no CDN keeps a draft.Keep the token narrow: valid for one hour, read-only, scoped to one entry, and checked by the content API, not just by the site. A hosted preview key that can read every draft is far more powerful, so it lives only on the site's server, never in the browser. The preview response must send Cache-Control: private, no-store so a CDN never caches a draft for the public; Next.js Draft Mode sends a header like this for you.
In a side-by-side layout, the admin shows the preview in an iframe and after each save sends postMessage with the new version, so the preview refreshes without a full reload. Check event.origin on both sides.
Versions, compare, restore
The History panel lists versions newest first: who, when, and a summary such as "changed title, body, 2 tags". Picking two versions opens a compare view with a field-by-field diff (the audit chapter shows how). Restore never rewrites history: it copies the old content into a new draft version, which the editor reviews and publishes like any other change. That way restoring is itself undoable, and the log stays append-only.
Check your understanding · Exercise 5
Which scheduling designs avoid publishing at the wrong time? Select all that apply.
Two editors, one entry
Ana opens the homepage entry at 10:00. Ben opens it at 10:02, swaps the hero image for the sale banner and saves at 10:05. Ana fixes a typo in the headline and saves at 10:07.
If her save sends the whole entry as she loaded it, the old hero image comes back and Ben's sale banner is gone. Nobody gets an error. This is the lost update problem, and in a CMS it's the bug that makes editors stop trusting the tool.
Sending only changed fields (a PATCH) shrinks the risk: Ana's save now only touches the headline. But it doesn't remove it. If both edit the headline, or Ben edits the body while Ana edits the summary that describes it, one of them still wins silently.
Decision How do we stop editors overwriting each other?
What we pick: Both the version check and the soft lock. The ETag check is the guarantee: the server will never accept a save based on an old version. Presence is the courtesy: it tells Ben that Ana is in the entry before he starts. Neither alone is enough. Real-time co-editing (the google-docs approach with CRDTs) is out of scope, because overlapping edits are rare here and a merge on save handles them.
How the version check works
GET /api/entries/page/home returns the entry and the header ETag: "v42". The form keeps v42 as its baseline version, and saving sends PATCH with If-Match: "v42", which asks the server to apply the change only if the entry is still at that version.
The server compares and writes in one atomic step (UPDATE ... WHERE id = ? AND version = 42). If no row matched, it answers 412 Precondition Failed with the current version in the body. A save with no If-Match at all gets 428 Precondition Required (defined in RFC 6585 for exactly this lost-update case), so an old client or a script can't skip the check by accident.
Hosted CMSs build the same guard. Contentful's Content Management API, for example, takes the version in its own X-Contentful-Version header and answers 409 Conflict on a mismatch. Standard If-Match and 412 have the edge that generic HTTP clients and libraries already know them.
On a 412, the worst UI is "Someone else changed this entry. Reload?" because reloading throws Ana's typo fix away. The good UI is a three-way merge: the client has the base (v42, what Ana started from), mine (Ana's values) and theirs (v43, from the 412 body). Per field: if only one side changed it, take that side; if both changed it to different values, it's a real conflict, and only those fields need a decision.
type Pick = { field: string; value: unknown } | { field: string; conflict: [unknown, unknown] };
function merge3(base: Entry, mine: Entry, theirs: Entry, fields: string[]): Pick[] {
return fields.map((f) => {
const b = base.values[f], m = mine.values[f], t = theirs.values[f];
if (equal(m, t)) return { field: f, value: m };
if (equal(m, b)) return { field: f, value: t }; // only they changed it
if (equal(t, b)) return { field: f, value: m }; // only I changed it
return { field: f, conflict: [m, t] }; // both changed it
});
}
In the homepage story: hero changed only by Ben, so take his; headline changed only by Ana, so take hers. Zero conflicts: the UI shows "Merged with Ben's changes from 10:05" and lets Ana save again with If-Match: "v43". Only when both touched the same field does she see a side-by-side choice for that one field. Rich text is one field here; merging inside a document is the job of the rich-text and google-docs designs, not ours.
Presence and soft locks
When a form opens, the client posts a heartbeat, PUT /api/presence/page/home, and repeats it every 15 seconds while the tab is visible (the Page Visibility API's visibilitychange event pauses it in hidden tabs). The server drops anyone silent for 45 seconds. Other open editors learn about it from a server-sent events stream or a poll every 15 seconds; the presence-and-cursors lesson compares the transports.
The header shows avatars, as Sanity Studio and the Contentful web app do for people in the same entry. If someone has unsaved changes, a banner adds: "Ana Ruiz is editing this entry (unsaved changes). Your changes may conflict." Ben can still edit. The lock is soft by design; the version check is what keeps the data safe.
Check your understanding · Exercise 6
Ana saves with If-Match: "v42" and gets 412; the current version is v43. Which UI responses are good? Select all that apply.
The media library
Images and files are content too, but they behave differently: they're big, shared by many entries, and served through an image CDN. The media library is its own content type with a grid view, and every media field opens it as a picker. The upload itself (chunks, progress, retries, pausing) is the file-uploader guide; here we cover what a CMS adds.
Store one original, serve many sizes
Editors upload a 6000 by 4000 pixel, 9 MB photo. The site needs it at 400, 800 and 1600 pixels wide, as AVIF or WebP, cropped square for a card and 16 by 9 for a hero. The naive approach asks the editor to upload each version.
The better one stores only the original and lets an image service create variants on request from the URL: /img/abc123.jpg?w=800&fm=webp&fit=crop&ar=16:9. Imgix, Cloudinary, Sanity's image pipeline and Contentful's Images API all work this way, and the CDN caches each variant after its first request.
Automatic crops cut off heads. So the editor sets a focal point: a click on the most important spot, stored as two fractions, { x: 0.62, y: 0.30 }. Every crop is then centered as close to that point as the frame allows. The editor sees live previews of the main aspect ratios (1:1, 4:3, 16:9) next to the image while moving the point, which takes 5 seconds and saves an angry message from the design team.
Real products differ in how much they offer here. Sanity's image field stores a hotspot and a crop as fractions of the image, and its URL builder applies them. Contentful's Images API takes a named focus area instead (f=face, f=top, f=center), and an exact editor-chosen point comes from an add-on, such as the image focal point app Contentful publishes for its marketplace.
Alt text is required, and it lives in two places
WCAG success criterion 1.1.1 (Non-text Content, level A) asks for a text alternative for every meaningful image, and an empty alt="" for purely decorative ones so screen readers skip them. A CMS is the right place to enforce it, because the developer who writes the template never sees the image. The asset stores a default alt text, required on upload unless the editor ticks "decorative" (which outputs alt="").
A media field can override it per use, because the same photo of a store may mean "Our Berlin shop" in one article and "Queue on launch day" in another. The schema's altRequired: true makes publishing fail validation if neither is set; saving a draft still works. A saved view "Images missing alt text" turns the backlog into a task list.
Know where an asset is used before deleting it
Deleting an image that 14 published pages show leaves 14 broken images on the live site. The server keeps a reference index: when an entry is saved, it records which assets (and which other entries) it points to, including assets inside rich text. The asset's detail panel shows "Used in 14 entries" with links.
Delete is blocked while published entries use the asset. Instead the panel offers "Replace everywhere", which swaps the asset id in each entry as a new draft version, or Archive, which hides the asset from the picker without breaking anything.
Check your understanding · Exercise 7
Which choices make the media library safe and useful? Select all that apply.
Permissions in the UI, and who changed what
Our users have uneven powers. Staff editors publish anything, and admins also manage users. Agency freelancers create and edit articles but can't publish, and can't see the internalNotes field; translators edit only the localized fields of their own locale.
Permissions have three levels: per type (read, create, update, publish, delete on articles), per field (read or write internalNotes), and per entry (authors may update only entries they created). Strapi's admin roles have the same three: actions per content type, fields that can be unticked per action, and conditions such as "the administrator must be the creator".
The rule from the linkedin guide's privacy section applies unchanged: the server decides, the client only displays. If a freelancer can't read internalNotes, the API must leave the field out of every response, list, search result, version history and export. Hiding it in the form while it sits in the JSON is a leak anyone can read in the network tab. And every write is checked again on the server, field by field, so a crafted PATCH that includes internalNotes gets 403.
Decision When the user lacks a permission, do we hide the control or disable it?
What we pick: Hide what you can't read, disable what you can't change, and always say why. The field registry gets one more duty: every Input must have a read-only mode, because a translator sees the shared price field but can't change it.
Where do the permissions come from? At sign-in the client loads a session payload with the user's roles compiled into simple lookups: can("article", "publish") and fieldAccess("article", "internalNotes"). Rules that depend on the entry ("own entries only") can't be compiled ahead, so each entry response carries its allowed actions: "_actions": ["update", "submit"].
The UI never recomputes those rules: it reads them. When an admin changes a role, the next request gets a 403; the client then reloads the session payload and re-renders.
Audit history with field-level diffs
"Who changed the price on this product?" is a daily question in a back office, and sometimes a legal one. The version log from the drafts chapter already stores every state. The audit log adds the events around it: who, when, what action (save, publish, unpublish, schedule, restore, delete, permission change), from which IP and client, and which fields changed. It is append-only and written by the server, never by the client.
The history panel turns two versions into a field-level diff. Short text fields show old and new side by side, while long text and rich text use a word-level diff inside the field, computed on the structured JSON so formatting changes show as "made bold" instead of a wall of markup. Relations show added and removed entries by their titles ("+ Pricing, - Promo"), looked up at display time. Fields the viewer can't read are left out of the diff too, or it would leak the value through history.
Check your understanding · Exercise 8
A freelancer must not see internalNotes on articles. Which measures are needed? Select all that apply.
Localized content
The translation-architecture lesson is about translating the app's own UI strings ("Save", "Publish"). Content localization is a different problem: the articles themselves exist in 9 locales, and editors and translators work on them. A locale is a language plus an optional region, written as a BCP 47 tag such as de-AT (German as used in Austria).
The schema decides localization per field. title and body are localized: true, so they hold one value per locale. price, sku and author are shared: one value for all locales, because a product doesn't change its SKU in France.
The form gets a locale switcher in the header. Shared fields show in every locale, read-only for translators, with a small "shared" label so nobody is surprised that changing it in German also changes it in English. For translators, a side-by-side mode shows the source locale on the left, read-only, and their locale on the right.
Fallbacks
Austria uses German, so most de-AT content is the same as de, and requiring a copy for each locale creates thousands of duplicate values that drift apart. Instead each locale has a fallback chain, for example de-AT then de then en, the same idea the translation-architecture lesson uses for UI strings. An empty field takes the first value found along the chain; Contentful works this way, with each locale naming its fallback locale.
In our admin, the form shows the inherited value greyed out with "Inherited from de" and an "Override" button, so the editor sees what the site will render without the value being copied. Override only stores a de-AT value once the editor types something.
Translation status
Each localized field records which source version it was translated from. That gives three statuses per locale: missing (empty, maybe falling back), outdated (the source changed after the translation was made), and up to date. The list view shows a small badge row per entry ("fr: outdated, ja: missing") and a saved view "Outdated in my locale" becomes each translator's inbox. When the English body changes, the French one doesn't silently become wrong: it becomes visibly outdated.
Check your understanding · Exercise 9
The English title of an article changes after the French translation was done. What should the CMS do? Select all that apply.
Architecture, data model and API
With the hard parts decided, the architecture is mostly about boundaries: which piece knows about schemas, which knows about the network, and where plugins plug in. An admin app sits behind a login and has no SEO needs, so it's a client-rendered single-page app; the spa-vs-mpa lesson explains why that's the easy call here. One real decision remains: where schemas come from.
Decision Does the admin bundle the schemas at build time, or fetch them at runtime?
What we pick: Fetch at runtime. It keeps the admin and the API in step by design, and the boot cost is one cached request. A schema that names an unknown field type renders a read-only JSON view of the value with a warning, instead of crashing the form.
The parts and their boundaries
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| App shell | Router, sidebar of types, session and permissions, error boundary, command palette. | Loads once; everything else is a lazy route. |
| Schema registry | Holds the fetched schemas; answers "columns for article", "filters for product". | The only place that interprets schema hints. |
| Field registry | Maps field types to Cell, Input, Filter, validate; accepts plugins. | New field types without touching views. |
| ListView | URL state, query building, table, bulk job tracker, saved views. | Generic over types; one component for 40 types. |
| EntryForm and form store | Baseline and current values, dirty state, validation, save, merge on 412. | Field-level subscriptions keep 120 fields fast. |
| Server cache | TanStack Query or similar: lists, entries, versions, keyed by URL query. | Dedupes requests, prefetches, invalidates after writes. |
| API client | Auth header, ETag and If-Match, error mapping (422 to field paths, 412 to merge). | One place for HTTP rules. |
| Presence client | Heartbeats and the server-sent events stream of who is where. | Optional: the app works without it. |
| Local backup | Writes dirty form values to IndexedDB, offers restore. | Survives crashes, independent of the network. |
| Plugin host | Loads plugin bundles, gives them a stable API, catches their errors. | A broken plugin breaks one field, not the app. |
Follow the data for the main loop. An editor opens a list: the URL is parsed against the schema, turned into a query key, and the cache either answers or calls the API. Hovering a row prefetches that entry, so clicking opens the form from cache with no spinner, while a background refetch confirms the ETag.
Saving sends a PATCH with only the changed fields; the reply's new version becomes the form's baseline and replaces the entry in the cache, and the list queries for that type are marked stale so they refetch on next view.
ETag becomes the next If-Match, so the version check is free on every later save.Data model: where each kind of data lives
The client holds six kinds of data, and each has one home. Mixing them up is how admin apps end up with a stale list after a save, or a filter that a refresh forgets.
| Data | Lives in | Notes |
|---|---|---|
| Schemas and session permissions | Server cache, loaded at boot | Refetched on a 403 or a new deploy; rarely changes. |
| List query: filters, sort, cursor | The URL | Shareable, survives refresh, drives the cache key. |
| List pages, entries, versions | Server cache | Keyed by type and query or id; invalidated after writes. |
| Form baseline and current values | Form store (memory) | Per open entry; dirty state is their difference. |
| Unsaved backup | IndexedDB | Keyed by entry id and baseline version; deleted after a successful save. |
| Presence | Memory, from the presence stream | Throwaway; never persisted. |
type FieldDef = {
name: string;
type: string; // "string" | "relation" | "media" | ... | plugin types
required?: boolean;
localized?: boolean;
access?: "read" | "write"; // already resolved for this user by the server
[rule: string]: unknown; // maxLength, to, many, options...
};
type ContentType = { name: string; label: string; titleField: string; fields: FieldDef[];
list: { columns: string[]; defaultSort: string; paging: "offset" | "cursor" };
form: { tabs: { label: string; fields: string[] }[] } };
type Entry = {
id: string; type: string;
version: number; // also sent as the ETag
publishedVersion: number | null;
scheduledAt: string | null; // UTC instant
values: Record<string, unknown>; // localized fields: { en: "...", de: "..." }
updatedBy: { id: string; name: string }; updatedAt: string;
_actions: ("update" | "publish" | "delete" | "submit")[];
};
The API
It's REST, because the shapes are generic (one set of endpoints for every type) and HTTP already has what we need: ETag, If-Match, 412, 202 for jobs. GraphQL works too (Contentful offers a GraphQL Content API next to REST, and Hygraph is built around GraphQL), but a generic admin gains little from per-query field selection when the schema already says which fields a screen needs.
GET /api/schema -> ContentType[] (ETag)
GET /api/entries/:type?filter..&sort&limit&cursor -> { items, nextCursor, estimate }
GET /api/entries/:type/:id?locale=de -> Entry (ETag: "v42")
POST /api/entries/:type -> 201 Entry
PATCH /api/entries/:type/:id If-Match: "v42" { values: {...changed} }
-> 200 Entry (ETag "v43") | 412 { current: Entry } | 422 { errors } | 428
POST /api/entries/:type/:id/publish If-Match: "v43" { at?: ISO instant }
GET /api/entries/:type/:id/versions -> Version[]
POST /api/entries/:type/:id/preview-token -> { url, expiresAt }
POST /api/bulk { action, type, ids? | filter? } -> 202 { jobId }
GET /api/bulk/:jobId -> { status, succeeded, failed[] }
PUT /api/presence/:type/:id -> 204 (heartbeat)
GET /api/presence/stream -> text/event-stream
Errors share one shape, so the API client can map them without knowing the endpoint: { "code": "validation", "errors": [{ "path": "blocks.3.cta.url", "message": "Must be a full URL" }] }. Paths use the same dotted form as the form store, so mapping a server error to an input is a lookup, not a translation.
Check your understanding · Exercise 10
After a successful save of an article, what should the client update? Select all that apply.
Performance, accessibility, and the plugin API
Performance: load what this type needs, before the click
Editors live in the app all day, so the metrics that matter are the speed of the next click and of typing, not the first load. Three techniques cover most of it.
- Split by field type and by custom view. As the code-splitting lesson shows, a route-level split isn't enough here: one generic form route can need a rich text editor, a map and a code editor, or none of them. Each field
Inputand each custom view is its own chunk. The shell stays under the 250 KB budget, and a 15-field FAQ form loads only small inputs. - Prefetch on intent. When the pointer rests on a list row for about 100 ms (long enough to skip rows the mouse just crosses), the list prefetches that entry into the server cache and calls
import()for the field chunks its type needs. The schema knows which those are. By the click, the form renders from memory, so an open that had a 1.5 second budget can take around 100 ms. - Keep typing fast. Field-level subscriptions from the forms chapter keep Interaction to Next Paint (INP) low on a 120-field form; Google's good threshold is 200 ms, and a keystroke should stay far below it. Validate on blur, not on every key, and run the expensive cross-field rules only on save.
Lists show 50 rows by default, which needs no virtualization. If an editor picks 500 rows per page, the table switches to the virtualized mode from the data-table guide. Rendering cost is rarely the problem in a CMS; waiting on the network is, and prefetching is the fix.
Accessibility of complex forms
- Every generated input gets a real
label, its help text and error joined througharia-describedby, andaria-invalid="true"when it fails. The field registry enforces this: the generic wrapper renders label, help and error, so plugins can't forget. - Form tabs follow the WAI-ARIA tabs pattern (arrow keys between tabs, Tab into the panel). The error count from the forms chapter is part of each tab's accessible name, "Meta, 2 errors", and the error summary's links move focus to the field, switching tabs when needed.
- The relation picker is a WAI-ARIA combobox:
role="combobox",aria-expanded,aria-controls,aria-activedescendanton the active option, arrow keys to move, Enter to pick, Escape to close. Picked items are a list, each with a button named "Remove tag Pricing". Reordering many relations offers Move up and Move down buttons, not only drag. - Save results, bulk job progress and presence changes are announced in one
aria-live="polite"region: "Saved at 10:42", "Ana Ruiz started editing".
Extensibility: dashboards and a plugin API
Teams will want more than custom fields: a dashboard widget showing "Articles scheduled this week", a custom view for orders, a toolbar action "Send to translation agency". A plugin is a bundle that registers any of these through one entry point, and gets a small, versioned API instead of the app's internals.
export default definePlugin({
name: "stores",
apiVersion: 2,
fields: { geoPoint: { Cell: GeoCell, Input: lazy(() => import("./GeoInput")), validate } },
views: { store: { detail: lazy(() => import("./StoreView")) } },
widgets: [{ id: "low-stock", title: "Low stock", Component: lazy(() => import("./LowStock")) }],
actions: [{ id: "geocode", label: "Geocode address", types: ["store"], run: geocode }],
});
// Inside plugins: useField(name), useEntry(), useApi(), usePermissions(). Nothing else.
Each plugin component renders inside its own error boundary and Suspense, so a crash shows "This field failed to load" with the raw value read-only, and the rest of the form still saves. Plugins written by our own teams run in the same page. Plugins from outside vendors are less trusted and can run in a sandboxed iframe that talks over postMessage, which is how Contentful's App Framework isolates apps.
The dashboard itself is just a grid of widgets, and the built-in ones are saved views rendered small: "My drafts", "Scheduled this week", "Images missing alt text".
Think first
Why does the plugin declare apiVersion: 2, and what should the host do with it?
Plugins are built and deployed separately from the admin. When the host changes the plugin API, old plugins can't all update on the same day.
The host supports the current and the previous version side by side, adapting old calls, and shows a warning to admins for plugins on a deprecated version. A plugin asking for a version the host doesn't know is not loaded, and its fields fall back to the read-only view.
Check your understanding · Exercise 11
Opening a landing page from the list takes 1.8 seconds. Which changes help most? Select all that apply.
Running it in the interview
An admin CMS question is wide, and the trap is spending 20 minutes on the table component. The interviewer wants to see that you find the few decisions that carry the design (schemas, saves, conflicts, drafts, permissions) and treat the rest as known patterns you can name and link.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Ask about number of types, list sizes, concurrent editors, locales, roles. | Nothing yet; write the numbers in a corner. |
| 5 to 12 | Pitch schema-driven UI with a field registry and escape hatches. | Schema box feeding List, Form and Filters. |
| 12 to 20 | Architecture and the main data flow: URL to cache to API, save back. | The design diagram and the data flow. |
| 20 to 30 | Saves and conflicts: explicit save, local backup, ETag and 412, three-way merge, presence. | The conflict sequence. |
| 30 to 38 | Drafts, publishing, scheduling with zones, preview tokens, versions. | The state machine. |
| 38 to 45 | Permissions (server enforces, hide vs disable), media, locales, performance, accessibility, as time allows. | Add boxes to the architecture. |
Say the trade-offs out loud as you go: "I'm choosing explicit save over autosave because a half-done draft is visible in preview, and I'll protect against crashes with a local backup instead." A clear reason for a choice beats a long list of features.
Follow-up questions to expect
Think first
"Editors complain about merge dialogs on the homepage. Would you add real-time collaboration?"
First measure: how many 412s per day, on which entries. If it's a handful of hot entries like the homepage, presence banners plus finer-grained entries (split the homepage into modules, each its own entry) cut conflicts more cheaply than a CRDT.
If many people really do co-edit the same entries all day, then yes: move those types to a real-time model with per-field or per-block sync, as in the google-docs design. Sanity Studio takes that route for every document, syncing edits between editors live. The version check stays for everything else.
Think first
"Legal wants every opinion article approved by two reviewers before publishing. Where does that go?"
It's a workflow, so it lives on the server: a state per entry (draft, in review, approved) and rules for who moves it. The client shows the current state, the allowed transitions from _actions, and the reviewers.
In the UI it's a plugin: a sidebar panel and replacing Publish with Submit for review for that type. The publish endpoint refuses until the server sees two approvals, so the rule holds even if the UI is bypassed.
Think first
"A developer renames a field from heroImage to hero. What happens to the open forms and saved views?"
The server runs a migration for the data, and the schema's ETag changes. API responses carry the current schema version in a header, so the client notices on its next request and refetches the schema.
Saved views referring to heroImage are rewritten by the same migration, or else the list drops the unknown filter and shows a notice. An open form with unsaved changes to the old field keeps its local backup and offers to map it, rather than losing it.
Summary
- Generate list views, forms and filters from schemas fetched at runtime, through a field registry; offer graded escape hatches (custom field, custom section, custom view, custom route) for the screens that are workflows.
- A list is a server query that lives in the URL. Saved views are stored URLs; bulk actions are one server job that reports every failure.
- Big forms need a form store with field-level subscriptions, a baseline to compute dirty state, validation from the same schema on both sides, and errors keyed by field path.
- Save explicitly, back up unsaved work to IndexedDB, and guard both tab close and in-app navigation. Never show Saved before the server says so.
- An append-only version log with draft and published pointers gives publishing, scheduling, restore and compare from one model.
- Schedule as a UTC instant from a wall time plus an IANA zone, run by the server, and preview drafts on the real site with a narrow, short-lived token.
- Prevent lost updates with
ETagandIf-Match; on412, merge three ways and ask only about fields both editors changed. Presence is a courtesy, not a guarantee. - The server filters unreadable fields out of every response and checks every write; the UI hides what you can't read and disables, with a reason, what you can't change.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- RFC 9110: HTTP Semantics Section 13 defines conditional requests:
ETag,If-Match, and 412 Precondition Failed. - MDN: 412 Precondition Failed The status code behind the conflict check, with an example of avoiding lost updates.
- MDN: Window beforeunload event How the unsaved-changes prompt works and why custom text is ignored.
- web.dev: Back/forward cache Why to add
beforeunloadonly while there are unsaved changes. - WAI-ARIA APG: Combobox pattern Keyboard and ARIA rules for the relation picker.
- WAI-ARIA APG: Tabs pattern Keyboard behavior for form tabs.
- Next.js: Draft Mode One concrete implementation of previewing CMS drafts on the real site.
- MDN: Intl.DateTimeFormat constructor The
timeZoneoption used to show a scheduled time in two zones. - RFC 6585: Additional HTTP Status Codes Section 3 defines 428 Precondition Required, for servers that insist on conditional writes.
- Sanity: Image transformations A real image pipeline that crops and resizes from URL parameters, with hotspot support.
- Sanity: Perspectives How one query returns published content or drafts, the basis of Sanity previews.
- WCAG 2.2: Understanding Non-text Content (1.1.1) Why every meaningful image needs a text alternative and decorative ones need none.
- Strapi: Role-Based Access Control Admin roles with per-type actions, per-field permissions and creator conditions.
- Django: The admin site A classic generated admin:
ModelAdminoptions such aslist_displayandlist_filterdrive the screens.
Concepts behind it
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Translating an appHow an app speaks many languages: text in per-locale message catalogs, ICU messages so plurals and gender work everywhere, catalogs loaded per locale and route, clear fallbacks, and a safe workflow with translators.