All articles
What Four Months of Real Projects Changed in The Content Architecture
Four months, two editions, one security audit. What changed in The Content Architecture since launch, and why most of it came from real projects breaking in ways no demo would.
- [Author]
- Edoardo Lunardi
- [Published]
- [Reading time]
A starter template is a snapshot. The day you clone it, it starts aging: the framework moves, the CMS ships a new major, and every decision baked into it gets tested by content nobody wrote for the demo. Most starters stay frozen on launch day, and the bill lands on whoever cloned them, somewhere around month six.
The Content Architecture launched on June 15. Four months and a little over four hundred commits later, it ships in two editions, caches differently, animates without a library, removes its own features on request, and has been through a full security audit. This is the first full account of what changed since launch.
The more interesting part is where those changes came from. Very little of it was on the plan in June.
Where the changes came from
Most of what shipped since launch was found, not planned. The starter runs under real client sites, and every one of them is a stress test the demo content never was. A fresh setup on a new project failed on import because the seed referenced an author it never shipped. A feature trim on another left two stray dividers in the Studio sidebar. A full-page navigation in Astro left autoplaying videos paused on the next page, because the player had bound to a video element the router had already replaced.
None of those show up in a demo. The video one is the best example of why. Astro's router connects the incoming page first and only then replaces every parsed
<video> with a fresh copy, so the player's setup ran perfectly against an element that was about to be thrown away. Nothing errored. The video just sat there. The fix is a few lines: the player watches its own children and rebinds when its media element changes underneath it.// The router connects this element, then swaps the <video> inside it.
#onChildren = () => {
if (this.#media && this.contains(this.#media)) return;
this.#teardown();
this.#setup();
};
connectedCallback() {
this.#setup();
this.#children = new MutationObserver(this.#onChildren);
this.#children.observe(this, { childList: true });
}Each of those bugs showed up once, on a real project, and was fixed in the starter so the next project never meets it. The seed now has a test that fails on any dangling reference. The trim cleans up the dividers it strands. The player rebinds.
That is the rule behind this whole changelog. A fix that lives in one client repo protects one site. A fix that lands in the starter protects every site cloned after it.
A second edition on Astro
The biggest addition is a second edition. In July the starter shipped on Astro next to the Next.js edition: Tailwind for styling, small vanilla TypeScript custom elements for interaction, and React only inside the Sanity Studio, where it belongs.
Both editions share the same Sanity surface. The schema, the Studio structure, the setup scripts and the seed dataset are byte-identical across the two, and only the generated types differ. A team can model content once and pick the frontend per project. An editor moving between two sites built on different frameworks meets the same Studio.
What differs is what each framework is good at. The Astro edition caches whole responses in a store the app owns, which means a page behind the CMS-managed password gate is cached too, in its own keyspace that only a request with matching credentials can read. Shared caches refuse anything carrying an
Authorization header, so before this a password-protected site re-rendered and re-queried Sanity on every single request. Entries are keyed by the build that rendered them, so a deploy can never serve the previous release's page, and published reads go through the Sanity CDN under a version the publish webhook bumps, so a render can never store a stale copy. Most of what follows landed on both editions within days of each other.Caching that only moves when you publish
The Next.js edition moved to Cache Components in August. Every page now prerenders into a static shell with a one-year lifetime, and publishing in the Studio is the only thing that expires it. There are no timers anywhere. A page stays exactly as it was until an editor presses publish, and it changes on the next request after that.
Getting there exposed a bug only a production build could show. The contact form's spam check measures how long a visitor takes to fill the form, and it stamped the start time like this:
// Before: Date.now() runs during render, and render now also happens in the prerender
const startTime = React.useRef<number>(Date.now());Under Cache Components, a value that differs between renders makes React quietly abandon the surrounding boundary in the static shell. The form fell out of the prerender, rendered after hydration, and pushed every section below it into a hydration error. Development was green. The build was green. Only the production server showed it, and the fix is one hook moving the stamp to the browser:
// After: stamped on mount, never during render
const startTime = React.useRef<number | null>(null);
React.useEffect(() => {
startTime.current ??= Date.now();
}, []);The bug is a single line. Finding it took a production build, and that is the argument for testing a starter on real deploys rather than on its own dev server.
The two caches that sit between a publish and the page, and how each edition handles them, are covered in the caching deep dive. The short version: content is fresh the moment it is published, and nothing in between is guessing.
The queries under it changed shape too. Rich text, media and links are Sanity custom GROQ functions rather than fragments pasted into every query. A fragment is text, so nesting multiplies it, and the API rejects a query over 300 KB; a function is declared once and called. The Astro edition also halved the Sanity round trips per page render on the way.
What editors got
Most of the visible work went into the Studio, with one aim throughout: give editors more to work with without giving them more ways to break the site.
The new content is the obvious part: a blog with categories, author profile pages and an RSS feed, legal pages, a CMS-owned announcement bar, and a headline section that lights up word by word as you scroll. Media grew self-hosted video files and plain video URLs next to Mux, and image alt text is now generated with Sanity AI, with a backfill for everything uploaded before it existed.
The less obvious part is what changed about editing itself:
- •Site-wide settings moved into their own singleton, so global SEO defaults and navigation stop competing for room with page content.
- •Every section can be disabled or made full bleed, so a section can be parked without being deleted.
- •The insert menu shows a snapshot of each section, so editors pick by what a section looks like instead of by its name.
The colour picker is the clearest example of the aim. Rich text used to accept any colour from a native picker, so content could introduce colours the design system did not have. It is now a swatch grid over the brand palette, and the Studio stores the token's name, not its value:
// Stored in Sanity: { color: "brand-accent" }
tokenColor("brand-accent"); // "var(--color-brand-accent)"
tokenColor("tomato"); // undefined, never reaches the pageThe value keeps living in the stylesheet. Change a brand colour there and every piece of published content that uses it follows, without an editor touching a document. A test parses the real stylesheet, so the palette the Studio offers cannot drift from the palette the site ships.
Getting a project started
The first hour matters more than the first month, so the setup path got its own work.
npm run sanity:project-setup provisions the Sanity project, mints the tokens, registers the publish webhook and writes the env file. It now requests a private dataset by default, generates the webhook secret when the value is empty, and revokes every token it created but did not keep if it fails halfway.The starter ships a seed dataset, and a guided import restores it or a backup of your own into whichever dataset you point it at. A fresh clone opens on a working site with pages, articles and an author in place rather than an empty Studio. A
GETTING-STARTED guide walks from clone to the first rendered section, and a test now fails on any seed archive that references a document it does not contain, which is how the author bug from the opening stays fixed.One form system instead of one form
The contact form became a form system. Every public form is now defined by one schema, and that schema drives the markup, the browser validation and the server action, so the three cannot drift apart. Every field it rejects is flagged at once, on its own control, and each error clears as soon as it is fixed.
The validation code is its own chunk, fetched by the first submit and never with the page. A visitor who reads the page and leaves never downloads it. The contact section ships as the worked example, with one field of every control type, so a new form starts from something that already handles the edge cases.
What ships next, first
Breakdowns of how the starter is built land on the list before anywhere else: the fetch layer, the agentic layer, the parts that never make it into an estimate. Low volume, no filler.
The agentic layer
Search engines read HTML. AI agents increasingly ask for something else, and the starter now answers them. Ask this site for any article with the right header and you get the article, not the page:
curl -H "Accept: text/markdown" \
https://www.contentarchitecture.dev/blog/stale-sanity-content-nextjs-caching-revalidation
# HTTP/2 200
# content-type: text/markdown; charset=utf-8
# vary: Accept
#
# # Sanity Content Not Updating in Next.js or Astro: The Fix
# _By Edoardo Lunardi · 2026-06-27_Around that sit an
llms.txt generated from the site's content with one click in the Studio, the AI crawler policy declared in code so it is reviewed like any other change, an OpenAPI description of the site's API with structured JSON errors, and a Markdown map of the site when an agent asks for a page that does not exist.The Markdown is a stored field, not a live conversion. Editors generate it from the page, read it, edit it and publish it, so what an agent receives has been checked by a person. It is now required wherever serving is on, a backfill fills every page that was missing it, and the Studio reminds editors to regenerate it when a page changes. Why this matters, and what agents do with it, is in the piece on serving content to agents.
Search engines got the same pass
Both editions share one JSON-LD core, so the organisation, article and breadcrumb data comes out the same whichever framework renders the page, and a site's social profiles feed
sameAs from the CMS. The favicon is a Studio upload served from a stable /favicon.ico. Open Graph and Twitter cards are complete on every route.Two smaller changes did more than they look. Every canonical URL, sitemap entry, feed link and JSON-LD
url goes through one builder, with a test that fails on a hand-built one, so a site can never advertise two spellings of itself. The proxy also answers unknown paths with a real 404 now, instead of streaming a page shell with a not-found message inside it: crawlers see the status, and agents get the Markdown map of the site.No animation library in either edition
Both editions now animate without one. In October GSAP and Motion came out entirely, and every shared component moved onto the Web Animations API and native scroll: the inner parallax, the reading text, the split-text reveals and the blog list's filter animation. Both editions use the same timings and the same easing curves, so a page moves identically whichever framework renders it.
Removing a library is easy. Removing it without anyone noticing is the work. The blog list's filter animation was rebuilt and compared frame by frame against the previous version until the two timelines matched.
Navigation got the same attention. The header and the announcement bar are identical on every page, so a route change no longer fades them out and back in: both hold still while the page under them changes, and the bar's marquee runs straight through the swap. A dialog no longer shifts the page sideways when it locks scroll, because the scrollbar's space is reserved while the lock holds. A link to a section anchor lands on it even when that section streams in after the page.
The same pressure ran through the rest of the bundle. Rive and Lottie runtimes load only when their element nears the viewport. Text splitting moved to kugiri, a splitter I wrote that breaks text exactly where the browser already broke it. Environment validation moved to
zod/mini. Analytics load once the page is idle. Each of those is small. Together they decide whether the starter stays fast after a team has spent two years adding features to it.Removing what a project does not need
The starter is a superset on purpose. Every project inherits all of it, and most projects do not need all of it. Removing the blog by hand means thirty-odd files across routes, schemas, registries, queries, sitemap tags and docs, and it never touches the content already sitting in the dataset.
npm run trim does the whole job. Run with no flags it is a guided session. The flags exist for scripts and agents:npm run trim # guided: overview, choose, remove
npm run trim -- --status # what this project has of the starter
npm run trim -- --feature blog --dry-run # the full plan, nothing writtenThe blog, authors, legal pages, the form layer, the contact form, the announcement bar, analytics, the OpenAPI document and each media type can all go. It removes a chosen feature across code, dependencies and docs, and then deals with the content: it compares the schema against what each dataset stores, backs the dataset up first, and deletes only what nothing declares anymore. It works on day one and months into a project that has grown well past the starter, because it can tell a starter feature from a project's own section that happens to share its name.
The first real trim, on a live client project, exposed gaps no test had. They were closed a week later, and that second pass is what turned the tool from complete into trustworthy.
What shipped on it
Two of the sites that drove these changes are public. Prism Science launched on the Astro edition in September: dynamic structural data for biology, built in about a week once the page builder, draft mode, caching, SEO and the Studio already existed. Aspen Search shipped on the Next edition in ten days and took Site of the Day and the Developer Award at Awwwards, plus an FWA and a CSSDA. As this goes out, five more are in build on the Astro edition: two portfolios, a SaaS site, an award platform, and a large multilingual site for a fintech, which is where the i18n layer is being tested before it ships in both editions.
The starter won none of that. It only meant the ten days went into the work the judges score rather than into a page builder and cache invalidation, which is the whole argument for a foundation that keeps absorbing what each site teaches it.
A full security audit
The largest single change shipped this week. Both editions went through a full security audit, and the fixes landed as one pull request per edition, each touching close to three hundred files. The Next.js edition now runs 782 tests and the Astro edition 755.
The findings fell into a handful of families. I am keeping this at the level of families on purpose, since sites built on earlier code are still running.
- •Access control. Password-protected pages and the site-wide lock now gate one canonical form of every URL, refuse to serve when they cannot read their own state instead of failing open, and apply a protection change on the first request after publishing rather than minutes later.
- •CMS values. Anything an editor or an API write can store, from links and redirects to colours and media dimensions, is validated before it reaches the build or the page. A value that fails renders as plain text or not at all.
- •Endpoints. The publish webhook, the Studio-backed routes and the contact form check who is calling and refuse requests they never expect, with timeouts and size limits on everything that talks to another service.
- •Headers. Frame protection is enforced, and a Content Security Policy ships in report-only mode so a team can watch it on their own content before enforcing it.
- •Scripts. Dataset commands refuse to overwrite the live dataset unattended, project setup creates a private dataset by default, and the seed is tested so it can never ship a secret.
The access control change is the one I would push on any site with a CMS-managed gate. The proxy reads which pages are protected from Sanity. The question is what it does when that read fails: a cold instance during a Sanity outage, or a rotated token. The old answer was to serve the page. The new answer is to say so and ask the client to come back:
async function requiresBasicAuth(path: string): Promise<boolean | "unknown"> {
try {
const { siteWideEnabled, protectedPathSet } = await getSanityBasicAuthState();
return siteWideEnabled || protectedPathSet.has(path);
} catch (error) {
console.error("proxy: Basic Auth state unavailable, refusing the request", error);
return "unknown";
}
}
// In the gate: "unknown" answers 503 with Retry-After, never the page.A warm instance keeps its last good state for an hour, so visitors only meet that 503 on an instance that has never managed to read the state at all. That is a rare page to see, and it is the right page: a site that cannot tell a locked page from an open one should not be guessing.
The same shape runs through the build. CMS redirects used to go to the framework unchecked, where one bad entry could open-redirect or fail the whole build. Now each one is checked against a strict subset, and the build drops what fails and says why:
Skipping redirect "/old-pricing" -> "https://elsewhere.example/pricing": destination is neither a plain same-site path nor an https URL on this site
Skipping redirect "/blog" -> "/blog": destination is the source's own address, so the redirect would loopThe Studio refuses exactly what the build drops, and a test checks that the two agree.
The CMS values family is the one most Sanity sites share. A link field is a string, and React does not stop every scheme a string can hold, so one helper now decides what is allowed to become an
href anywhere on the site, from rich text to structured data:safeHref("/about"); // "/about"
safeHref("https://sanity.io"); // "https://sanity.io"
safeHref("mailto:hello@example.com"); // "mailto:hello@example.com"
safeHref("javascript:alert(1)"); // null, rendered as plain text
safeHref("//elsewhere.com"); // nullIt is not clever code. It is code that exists in one place, so the rule cannot be right in the footer and wrong in the feed.
The audit itself ships too. The method, the checks and the false positives it ruled out are now a security audit skill in both editions, so an agent working on a project built from the starter can run the same audit again. For projects cloned before this week, the upgrade steps are documented in the repository.
Skills and docs that match the code
Both editions ship around twenty-five agent skills next to the code, from how a section is built to how performance and security are audited, with the external ones pinned through a lock file so a skill cannot drift under a project. A performance audit playbook distilled from real Lighthouse passes sits next to the security one. Each repo also carries its MCP servers in
.mcp.json: Chrome and Firefox DevTools at a fixed 1920 by 1080, plus the framework's own devtools on Next, so an agent can load a page, read the console and check Gecko before a person opens a browser.The last commits before this article were documentation: every feature doc and every skill checked against the code as it is today. An agent reading a stale skill writes stale code with complete confidence, so keeping the docs true is part of shipping a change, not a chore after it.
What a starter is for
The snapshot problem from the opening has one answer, and it is not a better launch day. A starter stays useful only if it keeps absorbing what real projects teach it. Four months in, most of the changes to The Content Architecture trace back to a client site breaking somewhere, once, in a way the demo never would have. Each fix went back into the foundation.
Every project cloned today starts from a version that already knows about the seed that failed, the videos that paused, the form that fell out of the static shell and every URL spelling the gate used to miss. That is the part of a starter nobody sees on launch day, and the part that compounds.
For engineers building on the stack
This is the first changelog, not the last. The list is where the next round of fixes, patterns and deep dives lands first, for both editions.