# Why generateMetadata in Next.js Must Be Async

- Author: Abdullah Chaudary
- Category: Next.js
- Published: 2026-09-07T20:55:32.392Z
- Updated: 2026-09-07T20:55:32.514Z
- Reading time: 5 min

> 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](https://nextjs.org/docs/app/guides/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](https://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

- [Next.js: How to set up instrumentation](https://nextjs.org/docs/app/guides/instrumentation)
- [Next.js: generateMetadata function](https://nextjs.org/docs/app/api-reference/functions/generate-metadata)
- [schema.org: Person, the `sameAs` property](https://schema.org/Person)
