0%

0000000

0x00

Why generateMetadata in Next.js Must Be Async

The abchaudary.me home page: a full-bleed photographic hero under the site wordmark, with the sidebar menu control top left.

A synchronous metadata builder reading an unprimed module-scope snapshot put localhost:3000 in page titles and emptied their Person JSON-LD across ten routes.

TL;DR: This site's SEO builders read a module-scope snapshot of site settings, primed once at server start. Ten routes called the synchronous builder before it had primed, which put localhost:3000 in page titles and emptied their JSON-LD Person node to three properties. The fix: convert those ten call sites to an async generateMetadata() that awaits a fresh context.

What causes a Next.js metadata bug that only hits some routes?

This site keeps one shared SeoContext, a resolved copy of the site's global settings (name, sameAs links, default description, canonical origin), because rebuilding it on every render would mean a Payload query per page. src/lib/seo/context.ts exposes two ways to read it: an async getSeoContext() that always fetches a fresh copy, and a sync currentSeoContext() that reads a module-scope variable instead:

```ts
let snapshot: SeoContext = toSeoContext(null);

export const currentSeoContext = (): SeoContext => snapshot;

export function warmSeoContext(): Promise<SeoContext> {
warming ??= getSeoContext()
.then((ctx) => {
snapshot = ctx;
return ctx;
})
.catch(() => snapshot)
.finally(() => {
warming = null;
});
return warming;
}
```

warmSeoContext() is called once from src/instrumentation.ts's register() function, which Next.js runs once per server instance, before any route module is evaluated (Next.js: instrumentation documents register() as required to complete "before the server is ready to handle requests"). That works for a page whose metadata is declared as a module-scope constant and evaluated at build time, because by then the snapshot has already been primed. It does not work for any route that reads currentSeoContext() from inside a request-scoped function, because Next's caching and route isolation mean that read can happen on a code path the initial priming never touched, and the variable is still sitting at its toSeoContext(null) default.

Ten call sites did exactly that: blog/[slug], blog/category/[slug], tags/[slug], authors/[slug], projects/page.tsx, plus the blog and photo index components. Two call sites, about/page.tsx and projects/[slug]/page.tsx, already awaited a fresh context and were unaffected. One root cause, ten broken routes, two healthy ones, which is exactly the kind of inconsistency that makes a bug like this hard to spot from a single page load: the homepage looks fine, so you assume the site is fine.

Why didn't the sync builder just fail loudly?

It degrades instead of failing, on purpose. toSeoContext(null) returns real fallback values (an env-derived origin, an empty description) rather than throwing, because a missing global should never take a page down. That's the right call for resilience and the wrong call for visibility: a silently degraded page looks like a working page in a browser tab, it just carries the wrong values.

Measured directly against the running dev server, the damage split into two independent symptoms sharing one cause. First, titles: /blog/test-post-01's <title> read [TEST] Pinning ScrollTrigger on a mobile… | localhost:3000, and /projects read Selected Work & Case Studies | localhost:3000, with og:site_name carrying the same value. In production that becomes the wrong site name silently, which is worse than an obviously broken one: it ships without anyone noticing. /authors/[slug] and /photos/[category] additionally shipped with no meta description at all, because ctx.defaultDescription resolves to an empty string on an unprimed snapshot.

Second, the entity graph. Every page's JSON-LD emits the same @id for the site owner, #person, so a healthy route and a broken one define the identical identifier two different ways. Counted directly from the rendered <script type="application/ld+json"> blocks:

| Route | Properties on #person | sameAs links |
|---|---|---|
| / | 11 | 5 |
| /about | 11 | 5 |
| /projects | 11 | 5 |
| /blog | 3 | 0 |
| /blog/test-post-01 | 3 | 0 |
| /photos | 3 | 0 |
| /tags/test-threejs | 3 | 0 |
| /authors/abdullah-chaudary | 3 | 0 |

sameAs is the property an answer engine leans on hardest to resolve one entity across sites (schema.org: Person defines it as the property that links a Thing to other pages unambiguously identifying it). Stripping it from sameAs on exactly the pages that get crawled and cited most, the blog and the tag archives, undermines the whole reason the @graph exists.

What I actually shipped

The fix is mechanical once the cause is clear: every route reading currentSeoContext() from a request-scoped function switches to buildMetadataAsync(), which always resolves a fresh context, inside an async generateMetadata() export instead of a sync helper or a module-scope constant. src/lib/seo/metadata.ts already carried both entry points; the bug was ten call sites choosing the wrong one:

```ts
export function buildMetadata(input: MetadataInput, ctx: SeoContext = currentSeoContext()): Metadata {
return composeMetadata(input, ctx);
}

/**

  • The same builder with a guaranteed-fresh SiteSettings read, for callers that are already
  • async (any generateMetadata). Preferred wherever await is available.
    */
    export async function buildMetadataAsync(input: MetadataInput): Promise<Metadata> {
    return composeMetadata(input, await getSeoContext());
    }
    ```

The blog index's generateMetadata now looks like this:

```ts
export async function blogIndexMetadata(page: number): Promise<Metadata> {
return buildMetadataAsync({
description: BLOG_DESCRIPTION,
path: blogIndexPath(page),
title: page > 1 ? ${BLOG_TITLE} (page ${page}) : BLOG_TITLE,
...(page > 1 ? { noindex: true } : {}),
});
}
```

and the code comment above it now records the reason, so the next contributor doesn't quietly revert it back to the sync form for convenience:

```ts
/* Async, and awaiting a resolved context. The sync buildMetadata reads a process-wide
snapshot that is not primed on this route, which put localhost:3000 in the title and
left the Person node hollow (audit 2026-09-02). */
```

Two call sites still use the sync buildMetadata() at module scope: the homepage and the 404 page. I left both alone. Their metadata is a genuine export const metadata evaluated once when the module loads, on a code path warmSeoContext() has already run by the time it's reached, per the same guarantee register() gives every route module. Converting them to generateMetadata() would have been consistent for its own sake, not because they were broken.

Did the fix actually work?

Every previously-broken route now resolves through buildMetadataAsync(), and re-running the same JSON-LD audit against the ten fixed routes showed 11 properties and 5 sameAs links on #person, matching the three routes that were healthy the whole time. Titles carry the actual site name instead of localhost:3000. This was a straightforward before/after check against the rendered output, not a synthetic test; I re-ran the same manual audit against the dev server that surfaced the bug originally.

Where does this fix not apply?

The pattern generalizes past this specific bug: any module-scope cache primed by an instrumentation hook is only safe to read synchronously from a code path that hook is guaranteed to run before. A module-scope export const metadata at build time qualifies. A generateMetadata() function, a server component body, or anything invoked per-request does not, even though it can look identical at a glance to the code that does. If your framework doesn't give you an explicit ordering guarantee the way register() does here, treat every "shared context" read as request-scoped and pay the extra await.

References