cf.completefrontend
My progress
SYSTEM DESIGN · #113

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.

QuestionAnswer we assumeWhat 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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?

Hand-built screens per type

Each type gets its own ArticleList and ArticleForm components.

Shared pieces live in a component library.

Pros

  • Each screen can be tuned perfectly.
  • No abstraction to learn.

Cons

  • Linear cost per type, forever.
  • Shared behavior drifts between copies.
  • A schema change needs a frontend change and a deploy.

Fully generated from the schema

One generic ListView and one generic EntryForm read the schema at runtime.

Every field type maps to one fixed input.

Pros

  • A new type is a schema file and nothing else.
  • Unsaved-changes guard, validation and permissions behave the same everywhere.

Cons

  • Hits a wall on special needs: a map picker, a price with currency, a refund workflow.
  • Teams start bending the schema to fake custom UI.

Generated by default, with plugin overrides

Generic views as in option 2.

A registry maps each field type to components, and developers can register new field types.

A type can replace a whole view when the generic one isn't enough.

Pros

  • Zero work for the 90 percent case.
  • Custom UI where it pays off, without forking the app.
  • Shared behavior stays in one place, because overrides plug into the same form state.

Cons

  • A plugin API to design, version and document.
  • Plugins can break the shell if they aren't isolated.

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.

ts
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"] },
  ] },
});
One content type. The server and the admin read the same definition.

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.

ts
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.

Schema pipeline. The article schema, 8 fields with hints, flows into the Field registry, which holds a Cell, Input and Filter per field type. The registry produces three outputs: List columns (title, author, status), a Filter bar (enum, relation, date) and the Form (2 tabs and a validator). A note under the schema says rich text never becomes a column and relations become a search picker.article schema8 fields, hintsField registryCell, Input, FilterList columnstitle, author...Filter barenum, relationForm2 tabs, validatorrichText: no columnrelation: picker
The views never switch on field types. They ask the registry, so a new field type is one plugin, and a new content type is one schema file.

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.

Generated screens. List columns: title, author, category, updatedAt. Filters: author, category, tags. Content tab: title, hero, body. Meta tab: author, category, tags. Validation: valid.list columnstitleauthorcategoryupdatedAtfiltersauthorsearch pickercategorymulti-selecttagssearch pickerContent tabMeta tabtitleherobodyauthorcategorytagsSample entry is validSave is enabled
The sample entry has a 35-character title, no author, and a hero image without alt text. Change the schema and watch every screen follow.

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 hatchUse whenExample
Field hints in the schemaThe default input is right but needs a tweak.rows: 3 on a text area, placeholder, a help text.
Custom field typeA value needs its own input, cell or filter.geoPoint with a map, price with amount and currency.
Custom section in a formSeveral fields act as one widget.An SEO panel that shows a Google result preview from title and description.
Custom view for a typeThe screen is a workflow, not a record.The order page with refunds and shipment timeline.
Custom routeIt'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.

Wireframe of the article list at cms.example.com/content/article?status=draft. A sidebar lists the types Articles (current), Authors, Pages and Media. Across the top, saved views: All, My drafts (current) and Scheduled, with a New button. A filter bar holds status: draft, author: Ana and q: pricing. A bulk bar reads 3 selected: Publish, Archive, Delete. The table has columns Title, Author, Status and Updated, with rows Pricing update by Ana Ruiz, draft, 2h ago; Pricing FAQ, draft, 1d ago; New plans, draft, 3d ago. The footer shows 50 per page and Next page.cms.example.com/content/article?status=draftArticlesAuthorsPagesMediaAllMy draftsScheduled+ Newstatus: draftauthor: Anaq: pricing3 selected: Publish, Archive, DeleteTitleAuthorStatusUpdatedPricing updateAna Ruizdraft2h agoPricing FAQAna Ruizdraft1d agoNew plansAna Ruizdraft3d ago50 per pageNext page (cursor)
Everything above the table is the URL: the saved view, the filters, the sort and the cursor. The columns and the filter fields come from the schema; the bulk bar appears once rows are selected.

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.

text
/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
The whole list state in one URL. The API call is a direct translation of it.

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?

Offset pages

?page=7&limit=50 becomes OFFSET 300 LIMIT 50.

The footer shows "Page 7 of 8,012".

Pros

  • Editors can jump to any page.
  • Simple to build and to link.

Cons

  • Deep offsets are slow: the database still walks the skipped rows.
  • An exact total needs a COUNT that can take seconds on 30 million rows.
  • Rows shift when someone inserts an entry while you page.

Cursor pages

The server returns an opaque nextCursor that encodes the last row's sort value and id.

The next query asks for rows after that point.

Pros

  • Every page costs the same, page 1 or page 10,000.
  • No duplicates or gaps when rows are added.

Cons

  • No "jump to page 300".
  • The cursor is tied to the sort; changing the sort resets it.

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.

json
{
  "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" }
  ]
}
The finished job. The UI shows "2,297 published, 3 failed" and a Retry failed button that sends only the 3 ids.

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?

Explicit save, wait for the server

Save button and Cmd+S send a PATCH with changed fields.

The button shows Saving, then Saved with a time, or the errors.

Pros

  • The editor decides when a change is real.
  • Server validation errors arrive while the editor is still looking.
  • Easy to pair with version checks.

Cons

  • Work since the last save is at risk from a crash or closed tab.
  • Editors forget to save.

Optimistic save

Show Saved at once, update the list cache, send in the background, roll back on error.

Pros

  • Feels instant.

Cons

  • A 422 or a conflict arrives after the editor has moved on, and rolling back a 120-field form is confusing.
  • Saying Saved when it isn't breaks the core promise of no silent loss.

Autosave the draft

Every change is sent after 1 to 2 seconds of quiet.

Publishing is the explicit step.

Pros

  • Nothing to forget. Sanity Studio and the Contentful web app both save drafts automatically.

Cons

  • Many saves means many versions and more conflicts with a second editor.
  • Half-finished drafts are visible to colleagues and preview at once.

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.

Sequence of saving an entry. The editor presses Cmd+S. EntryForm sends PATCH with If-Match v42 through the API client to the CMS API, which validates and writes. The CMS API answers 422 slug taken; the API client maps it to errors by field path; EntryForm shows the error summary and moves focus there. The editor fixes the slug and presses Cmd+S again. EntryForm sends PATCH with If-Match v42, the CMS API answers 200 with ETag v43, and the form shows Saved 10:42.EditorEntryFormAPI clientCMS APICmd+SIf-Match v42validate, write422 slug takenerrors by pathsummary, focusfix slug, Cmd+SIf-Match v42200, ETag v43Saved 10:42
The failure path keeps every value and stays dirty: only the baseline decides what is saved. The success path replaces the baseline with the reply, so the next save sends If-Match: "v43".
ts
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]);
Cmd+S on Mac, Ctrl+S elsewhere, without the browser's Save Page dialog.

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?

One row with a status flag

The entry has status: draft | published. Saving overwrites the row.

Pros

  • Simplest possible model.

Cons

  • You can't edit a published article without changing what readers see.
  • No history, no restore.

Two copies: draft and published

Saving writes the draft copy. Publishing copies draft over published.

Pros

  • Live content stays stable while editing.
  • Sanity's drafts work roughly like this.

Cons

  • Still no history beyond two copies, unless added separately.

An append-only version log with pointers

Every save appends an immutable version.

The entry holds draftVersion and publishedVersion pointers.

Publish moves the published pointer; restore copies an old version into a new draft.

Pros

  • History, restore, compare and scheduling all fall out of one model.
  • A version number is ready-made for concurrency checks.

Cons

  • Storage grows with every save; old versions need pruning or compaction.
  • Queries for "the current draft" need the pointer.

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.

State machine of an entry. Draft (not live) goes to Scheduled (version pinned) on schedule, and back to Draft on cancel. Scheduled goes to Published (draft equals live) at the scheduled time. Draft can also go straight to Published with publish now. Published goes to Changed (draft newer) on save, and Changed returns to Published on publish or discard. Published goes to Archived (hidden) on unpublish and archive, and Archived goes back to Draft on restore. A note says the state comes from the two version pointers, plus the schedule and the archived flag.Draftnot liveScheduledversion pinnedPublisheddraft = liveChangeddraft newerArchivedhiddenpublish nowschedulecancelat timesavepublish,discardunpublish,archiverestorestate = f(draft pointer, published pointer, schedule, archived)
Draft, Published and Changed are computed from the draft and published pointers, so they can never disagree with the data; only Scheduled and Archived need extra fields. Changed is the state editors misread most, so the header names it.

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.DateTimeFormat and its timeZone option.
  • 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.

Sequence of previewing a draft. The editor presses Preview. The admin calls POST preview-token on the CMS API and gets back a URL with a one-hour token. The admin loads the site in an iframe with that URL. The site calls GET draft with the token on the CMS API and receives draft v43, then renders the page with Cache-Control no-store. Later the editor edits and saves, and the admin sends postMessage v44 to the site, which refreshes the preview.EditorAdminCMS APISitePreviewPOST tokenurl, 1 h tokeniframe src=urlGET draft + tokendraft v43page, no-storeedit, savepostMessage v44
The site never sees the admin's login. It gets a token scoped to one entry and one hour, the CMS API checks it, and the response is marked 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?

Last write wins

Every save is accepted. The latest one is the truth.

Pros

  • Nothing to build.

Cons

  • Silent data loss, exactly the case above.
  • Nobody even knows it happened.

Optimistic concurrency: version check on save

Each read returns the version as an ETag (entity tag: the server's label for one version of a resource).

Each save sends If-Match with that version.

If the entry moved on, the server refuses with 412 Precondition Failed and the UI helps merge.

Pros

  • No data loss, guaranteed by the server.
  • No cost when there is no conflict, which is almost always.
  • Plain HTTP, defined in RFC 9110.

Cons

  • The second editor finds out at save time, after the work is done.
  • Needs a merge UI.

Hard lock

Opening an entry for editing locks it. Others get read-only until it's released.

Pros

  • Conflicts can't happen.

Cons

  • Ana goes to lunch with the tab open and the homepage is frozen for an hour.
  • Locks need expiry and override, and then they aren't hard anymore.

Soft lock with presence

Open editors send a heartbeat. Others see "Ana is editing" and can still edit, or take over.

Pros

  • Warns before work is wasted, not after.
  • Cheap with polling or server-sent events.

Cons

  • Only a hint: heartbeats drop, people ignore banners.

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.

Sequence of a conflict. Both forms loaded version v42. Ben's form sends PATCH with If-Match v42 and gets 200, now v43. Ana presses Cmd+S; her form sends PATCH with If-Match v42 and gets 412 with the current v43. Her form merges three ways and tells Ana: merged, 0 conflicts. Ana saves again; the form sends PATCH with If-Match v43 and gets 200, v44. The CMS API tells Ben's form over the presence stream that Ana saved v44.AnaAna's formCMS APIBen's formIf-Match v42200, v43Cmd+SIf-Match v42412, current v43merged, 0 conflictsSave againIf-Match v43200, v44Ana saved v44
The server's version check turns a silent overwrite into a 412. The client's three-way merge turns the 412 into a one-click save, because Ben and Ana changed different fields.

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.

ts
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.

Step 1. Ana's form: headline Spring sale, hero beach.jpg, based on v42. Server: headline Spring sale, hero beach.jpg, v42. Ben's form: hero beach.jpg.Ana's formSpring salebeach.jpgbase v42editingServerSpring salebeach.jpgv42v42Ben's formSpring salebeach.jpgbase v42no warningSaves are accepted without any version check
Ana and Ben both open the homepage at v42. Neither knows about the other.

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.

Focal point crops. On the left, the original 6000 by 4000 photo with a focal point at x 0.62, y 0.30. On the right, three crops generated from it: a 1 to 1 card, a 16 to 9 hero and a 4 to 3 list image, each with the focal point kept inside the frame. A note says alt text is required unless the image is marked decorative.original, 6000 x 4000focal: x 0.62, y 0.301:1 card16:9 hero4:3 listalt text: required, or marked decorative
The focal point is stored as two fractions, so it works for any size and any crop the image CDN makes. Each crop is centered as close to the point as the frame allows.

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?

Always hide

Anything the user can't do is not rendered.

Pros

  • Clean screens for limited roles.

Cons

  • People can't tell "not allowed" from "doesn't exist", and file confused tickets.
  • Layouts jump between roles, so docs and screenshots don't match.

Always disable

Every control renders, greyed out if not allowed.

Pros

  • Same layout for everyone.

Cons

  • Shows the names of things the user may not even know about.
  • Pages full of dead controls.

Hide what you can't read, disable what you can't change

Fields and types without read permission are not rendered at all.

Readable but read-only fields render as read-only, with a reason: "Only editors can change this".

Actions the user can never do are hidden; actions blocked by state are disabled with the reason.

Pros

  • No leaks, and every grey control explains itself.
  • Freelancers see why Publish isn't there for them: they get Submit for review instead.

Cons

  • Needs a reason string per permission, and design for read-only states of every field type.

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.

Permission layers for an agency freelancer. Left, what the server sends: title write, body write, category read, internalNotes removed, and allowed actions update and submit. Right, the form: Title and Body editable, Category read-only with the reason Only editors change category, a Submit for review button, and Publish hidden. A line at the bottom says the server also checks every PATCH and answers 403.Server sendstitle: writebody: writecategory: readinternalNotes: gone_actions: update, submitForm showsTitle, editableBody, editableCategory, read-onlyOnly editors change categorySubmit for reviewPublish: hiddenand every PATCH is checked again: 403
The server removes what the user can't read before it leaves the building. The form only reads what arrived: missing fields are not drawn, read-only fields say why, and actions come from the entry's allowed list.

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.

Locale fallback chain for the title field. de-AT has no title and is marked missing; it falls back to de, which has Preise 2026 and is up to date; de falls back to en, the source, with Pricing 2026. A note says the de-AT form shows Preise 2026 greyed with Inherited from de, and Override copies nothing until the editor types.falls backfalls backde-ATtitle: emptydePreise 2026enPricing 2026missingup to datesourcede-AT form shows: Preise 2026, greyed, Inherited from deOverride copies nothing until the editor types
Fallbacks are resolved when content is read, so Austria inherits German text without a copy that drifts. The status under each locale is what the translator's saved view filters on.

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?

Bundled at build time

Schema files are imported into the admin code and shipped in its JavaScript.

Pros

  • Full type safety in custom views.
  • No extra request at startup.

Cons

  • Every new type or field needs an admin deploy.
  • The admin and the API can disagree for minutes during a rollout.

Fetched at runtime

GET /api/schema returns all types the user may see, already filtered by permissions, with an ETag.

Field plugins are code and still ship as lazy bundles; the schema refers to them by name.

Pros

  • New types appear with the API deploy alone.
  • The schema the UI sees is the schema the server validates with.
  • Permission filtering happens on the server.

Cons

  • One more request on boot (roughly 30 KB compressed for 40 types, revalidated with If-None-Match).
  • A schema naming an unknown field type needs a fallback input.

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

Architecture of the admin CMS. In the browser, top row: App shell (router, session), Schema registry (fetched schemas) and Field registry (plugins register here). Middle row: ListView (URL state, bulk jobs), EntryForm (form store, merge on 412) and Local backup (IndexedDB). Bottom row: Server cache (lists, entries), API client (ETag, error mapping) and Presence client (heartbeats). The shell feeds the schema registry; ListView reads from the server cache; EntryForm saves through the API client and backs up to IndexedDB. Below, on the server: CMS API and Image CDN. The API client talks REST with ETags to the CMS API, and the presence client listens to an SSE stream.BrowserApp shellrouter, sessionSchema registryfetched schemasField registryplugins registerListViewURL state, bulkEntryFormstore, 412 mergeLocal backupIndexedDBServer cachelists, entriesAPI clientETag, errorsPresenceheartbeatsServerCMS APIImage CDNREST, ETagSSE presence
Generic views sit between two registries and two stores. Only the API client knows HTTP rules, only the schema registry interprets schemas, and plugins enter through the field registry.
PartResponsibilityWhy the boundary is here
App shellRouter, sidebar of types, session and permissions, error boundary, command palette.Loads once; everything else is a lazy route.
Schema registryHolds the fetched schemas; answers "columns for article", "filters for product".The only place that interprets schema hints.
Field registryMaps field types to Cell, Input, Filter, validate; accepts plugins.New field types without touching views.
ListViewURL state, query building, table, bulk job tracker, saved views.Generic over types; one component for 40 types.
EntryForm and form storeBaseline and current values, dirty state, validation, save, merge on 412.Field-level subscriptions keep 120 fields fast.
Server cacheTanStack Query or similar: lists, entries, versions, keyed by URL query.Dedupes requests, prefetches, invalidates after writes.
API clientAuth header, ETag and If-Match, error mapping (422 to field paths, 412 to merge).One place for HTTP rules.
Presence clientHeartbeats and the server-sent events stream of who is where.Optional: the app works without it.
Local backupWrites dirty form values to IndexedDB, offers restore.Survives crashes, independent of the network.
Plugin hostLoads 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.

Data flow in two lanes. Read lane: ListView turns the URL into a query key for the Server cache; on a miss the cache asks the API client, which sends GET with filters and a cursor to the CMS API. The reply, items and nextCursor, is cached as a page and rendered as 50 rows. Write lane: Cmd+S in EntryForm hands changed fields to the Form store, which sends a PATCH through the API client with If-Match v42. The CMS API replies 200 with ETag v43, which becomes the new baseline, and the form shows Saved 10:42. A note says the cache entry is replaced, article lists are marked stale and the IndexedDB backup is deleted.Read: open a listquery keyon a missGET filter, cursorListViewreads URLCacheby queryAPI clientCMS API50 rowspage cacheditems, nextCursorWrite: save an entryCmd+SPATCH changedIf-Match v42EntryFormdirty fieldsForm storebaselineAPI clientCMS APISaved 10:42new baseline200, ETag v43then: cache entry replaced, article lists marked stale,IndexedDB backup deleted
Requests travel along the upper arrow of each lane and replies come back on the lower one. After a save, the reply's 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.

DataLives inNotes
Schemas and session permissionsServer cache, loaded at bootRefetched on a 403 or a new deploy; rarely changes.
List query: filters, sort, cursorThe URLShareable, survives refresh, drives the cache key.
List pages, entries, versionsServer cacheKeyed by type and query or id; invalidated after writes.
Form baseline and current valuesForm store (memory)Per open entry; dirty state is their difference.
Unsaved backupIndexedDBKeyed by entry id and baseline version; deleted after a successful save.
PresenceMemory, from the presence streamThrowaway; never persisted.
ts
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.

http
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 Input and 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 through aria-describedby, and aria-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-activedescendant on 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.

ts
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?

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.

MinutesWhat to doWhat to draw
0 to 5Ask about number of types, list sizes, concurrent editors, locales, roles.Nothing yet; write the numbers in a corner.
5 to 12Pitch schema-driven UI with a field registry and escape hatches.Schema box feeding List, Form and Filters.
12 to 20Architecture and the main data flow: URL to cache to API, save back.The design diagram and the data flow.
20 to 30Saves and conflicts: explicit save, local backup, ETag and 412, three-way merge, presence.The conflict sequence.
30 to 38Drafts, publishing, scheduling with zones, preview tokens, versions.The state machine.
38 to 45Permissions (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?"

Think first

"Legal wants every opinion article approved by two reviewers before publishing. Where does that go?"

Think first

"A developer renames a field from heroImage to hero. What happens to the open forms and saved views?"

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 ETag and If-Match; on 412, 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next