According to the client, they had gone looking in Studio for a place to update the homepage title and description themselves, and found none. So I moved both into the homepage settings document in Sanity Studio, and only those two. The fuller shared SEO object stayed closed on purpose, because it carries noindex and canonical, and one mis-click there is enough to pull the homepage out of the index.
The site runs on Next.js 16, and the homepage generateMetadata does not simply await the query. It reads the meta from a function that keeps one value at module level, returns that value at once, and refreshes it through after() once the response has gone out. As a result, a Studio edit shows up from the request after the one that triggered the refresh. The design came out of a different problem, homepage meta getting pushed into the body on the first request after a webhook, and that is a story of its own. Simplified, with the names changed, its shape before any guard was roughly this:
// simplified version, names changed
import { after } from "next/server";
let cached: SiteMeta | null = null;
// reload() fetches the settings document through the CMS fetch helper,
// then stores its meta in cached
export async function getSiteMeta(): Promise<SiteMeta> {
if (!cached) return reload();
after(reload);
return cached;
}What this design had not answered yet was one simple question: whose requests are allowed to fill cached?
A draft render that would fill the public value
The gap was caught in review of that homepage meta change on 14 September 2026, before release. The review's finding was this: after() keeps the request's draft state, so a render from the Presentation tool, or any other draft render, would store an unpublished, half-typed title in the shared value and then serve it to Googlebot.
A draft render of / can come from three directions: the Presentation tool, a VisualEditing refresh, or an editor whose browser carries the draft cookie. In this project the Presentation tool turns draft mode on through an API route, and the root layout renders <VisualEditing /> whenever draft mode is on. Once draft mode is on, the CMS fetch helper switches perspective:
// CMS fetch helper, simplified, names changed
const isDraft = (await draftMode()).isEnabled;
return client.fetch(query, params, {
next: {
// Skip the baseline TTL in draft mode so previews stay fresh.
revalidate: isDraft ? 0 : BASE_TTL,
},
perspective: isDraft ? "previewDrafts" : "published",
});For a preview, that is the right behavior. Editors need to see unpublished edits, and the baseline TTL is skipped so previews stay fresh. The problem is where the result of that fetch gets stored. If a draft render also calls getSiteMeta() in a process that already holds a value, after(reload) is scheduled from a draft request, and reload() runs after the response with draft mode still on. The fetch inside it picks previewDrafts and revalidate: 0, the result overwrites cached, and the next visitor, Googlebot included, would receive a title that was still half typed.
Taken out of this case, the sentence becomes a general one: a process-wide cache refreshed from after() will store unpublished Studio edits and serve them to the public unless draft requests bypass it.
after() carries the originating request's stores
The heart of the problem is after() itself. Its callback runs after the response has been sent, but with the stores of the request that scheduled it, so draftMode() inside it still answers enabled. The note I wrote at the time pointed at two places: after-context.js wraps callbacks in bindSnapshot, which keeps the request's AsyncLocalStorage, and draft-mode.js has no guard for phase === "after".
This change was committed on Next 16.2.4, and the project moved to 16.3.6 on 28 September. The excerpts below come from 16.3.6, so they are not exactly the files in use at the time, but in that version the mechanism reads plainly:
// next/dist/server/after/after-context.js, Next 16.3.6, trimmed
workUnitStore.phase = 'after'; // when the request closes
// Bind the callback to the current execution context (i.e. preserve all currently available ALS-es).
const wrappedCallback = (0, _asynclocalstorage.bindSnapshot)(/* ... */);
// next/dist/server/request/draft-mode.js, Next 16.3.6, trimmed
case 'request':
return createOrGetCachedDraftMode(workUnitStore.draftMode, workStore);
get isEnabled() {
if (this._provider !== null) {
return this._provider.isEnabled;
}
return false;
}When the request closes, the work unit store's phase is switched to 'after'. The callback, however, was already bound to the execution context at the moment after() was called, and the Next comment says every available AsyncLocalStorage is preserved, so draft state is not the only thing that comes along. For a work unit of type request, draftMode() returns that store's draft state directly, and the isEnabled getter only forwards whatever its provider holds.
The word phase appears exactly once in draft-mode.js, in a phase === 'after' check inside a function that is only called from enable() and disable(). So a phase check does exist, but the read path through isEnabled never passes through it. The error message on that check even says so outright:
The enabled status of `draftMode()` can be read inside `after()` but you cannot enable or disable `draftMode()`.That means in 16.3.6, reading draft state inside after() is permitted behavior, not an oversight waiting for a patch. The guard has to live in application code.
The guard on the first line
The fix makes draft requests bypass that memory entirely. The guard became the first line of the function, ahead of reading cached and scheduling after(reload), and a draft request fetches the settings document directly and returns its meta. If calling draftMode() itself throws, the request is treated as not a draft. Still with the names changed:
// simplified version, names changed
async function isPreviewRequest(): Promise<boolean> {
try {
return (await draftMode()).isEnabled;
} catch {
return false;
}
}
export async function getSiteMeta(): Promise<SiteMeta> {
// A draft render reads unpublished edits, and an after() callback keeps the
// request's draft state. Letting it touch the shared value would serve a
// half-typed Studio title to the next visitor, Googlebot included. Editors
// previewing do not need the in-head guarantee, so they bypass it entirely.
if (await isPreviewRequest()) return (await getSettingsDoc()).meta;
if (!cached) return reload();
after(reload);
return cached;
}The comment above the guard also records the decision: editors who are previewing do not need the in-head guarantee, so they skip the shared value entirely. The consequence is that every draft render waits on its own fetch, which is a fair price for a preview. On the calling side, the homepage generateMetadata carries a comment asking anyone to read this module first before changing it to a plain await.
Two tests, and proof that both can fail
The tests mock draftMode from next/headers with an isEnabled that each test can set, and mock after from next/server so its callbacks are only collected in an array. From that array, a test can see whether a request scheduled a refresh or not. The two new tests, simplified:
// simplified version, names changed
vi.mock("next/headers", () => ({
draftMode: vi.fn(async () => ({ isEnabled: draftEnabled })),
}));
vi.mock("next/server", () => ({
after: vi.fn((callback: () => unknown) => {
afterCallbacks.push(callback);
}),
}));
it("never lets a draft preview reach what public visitors are served", async () => {
mockedDoc.mockResolvedValueOnce(docWith("Published"));
await getSiteMeta();
draftEnabled = true;
mockedDoc.mockResolvedValueOnce(docWith("Half-typed draft"));
expect((await getSiteMeta()).title).toBe("Half-typed draft");
expect(afterCallbacks).toHaveLength(0);
draftEnabled = false;
expect((await getSiteMeta()).title).toBe("Published");
});
it("does not seed a cold process from a draft preview", async () => {
draftEnabled = true;
mockedDoc.mockResolvedValueOnce(docWith("Draft"));
await getSiteMeta();
draftEnabled = false;
mockedDoc.mockResolvedValueOnce(docWith("Published"));
expect((await getSiteMeta()).title).toBe("Published");
});The first test checks that a draft request receives the draft title without scheduling a single after() callback, and that a public request afterwards still receives "Published". The second covers the other way in, a process that has no value at all yet: a draft request in that process must not be the first thing to fill cached, and the next public request has to receive "Published".
A mutation test proved both tests fail when the guard is removed. That step is what makes the tests worth trusting, because a test that stays green without the guard is not actually testing the guard.
What is proven, and where it stops
The change reached production on 14 September 2026 and was verified live. Its commit message states that draft-mode renders bypass the shared value, so unpublished edits never reach public visitors or crawlers, and the module's git history holds only that commit. The guard and the memory went in together, so a version without the guard never existed in git. The finding itself came before release.
Production checks afterwards showed 12 of 12 homepage requests carrying all meta inside the head. The same day, the client published a new title and description from Studio, and an end-to-end check on 15 September showed production HTML serving both inside the head on 6 of 6 requests, as Googlebot and as an SEO crawler.
All of those production checks, though, exercise the public path. The draft guard itself is proven by the two unit tests and their mutation test, and there is no record of a draft preview being tested in production. The leak scenario also remains hypothetical: the half-typed title would have been stored and served had the guard been missing, and because the finding came before release, there is no real incident to point to.
A rule for any memo filled at request time
The rule I took from this reaches beyond this homepage. Any module-level or process-level memo fed by request-time fetches has to check draft mode first, and for draft requests it neither reads the shared value, nor writes it, nor schedules after(). The third one is the slipperiest, because after() looks like work that has already left the request behind, while it still carries that request's draft state.
The testing rule comes along with it: add a test that mocks draft mode as enabled and asserts the public value is untouched, then remove the guard, and that test must fail. If it does not fail, the test is not testing what it thinks it is testing.