Skip to content

Hydration mismatch on SWR-cached pages: app:mounted fires before async layout hydration completes #496

@nathanchase

Description

@nathanchase

Description

When using <AuthState> in layout components (e.g., a site header) on SWR-cached routes, hydration mismatches occur for logged-in users. The session.client.js plugin defers the session fetch to app:mounted for cached pages, but app:mounted fires before async components (layouts wrapped in <AsyncComponentWrapper>) finish hydrating.

On fast connections (especially localhost), the /api/_auth/session fetch completes quickly enough that authReady becomes true while layout components are still hydrating against the cached server HTML — which correctly has empty AuthState comment nodes (<!--[--><!--]-->). Vue then detects a mismatch between the server-rendered empty slots and the client's ready=true rendered content.

Reproduction

  1. Configure an SWR-cached route:
// nuxt.config.ts
routeRules: {
  '/about': { swr: 300 },
}
  1. Use <AuthState> in the default layout (e.g., in a header component):
<AuthState v-slot="{ loggedIn }">
  <nav v-if="loggedIn">...</nav>
</AuthState>
  1. Log in, then navigate to the cached route.
  2. Console shows:
[Vue warn]: Hydration node mismatch:
- rendered on server: Comment
- expected on client: nav
  at <AuthState>
  at <SiteHeader>
  at <AsyncComponentWrapper>
  ...

Root Cause

In entry.js, Nuxt calls:

vueApp.mount(vueAppRootContainer);          // line 65 — starts hydration
await nuxt.hooks.callHook("app:mounted");   // line 66 — fires immediately after

vueApp.mount() mounts the root app, but async components (layouts loaded via <AsyncComponentWrapper>) haven't finished hydrating yet. They hydrate asynchronously, tracked by hydratingCount in nuxt.js.

The session.client.js plugin registers the fetch on app:mounted:

nuxtApp.hook("app:mounted", async () => {
  await useUserSession().fetch();
});

This fetch completes and sets authReady = true during layout hydration, causing the mismatch.

Suggested Fix

Use app:suspense:resolve instead of app:mounted in session.client.js for cached/prerendered pages:

// Before (causes hydration mismatch)
nuxtApp.hook("app:mounted", async () => {
  await useUserSession().fetch();
});

// After (waits for all async hydration to complete)
// We only want to fetch the session once during initial hydration
// The app:suspense:resolve hook fires again on subsequent client-side navigations that involve
// suspense, and we don't want to re-fetch the session each time.
nuxtApp.hookOnce("app:suspense:resolve", async () => {
  await useUserSession().fetch();
});

app:suspense:resolve fires when hydratingCount reaches 0 (in nuxt.js line 55-57), meaning all async components including layouts have finished hydrating. This ensures AuthState components hydrate with ready=false (matching the server HTML) before the session fetch triggers a reactive update.

Current Workaround

Set loadStrategy: 'none' and create custom session plugins that use app:suspense:resolve:

// nuxt.config.ts
auth: {
  loadStrategy: 'none',
}
// plugins/session-fetch.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  if (!nuxtApp.payload.serverRendered) {
    useUserSession().fetch();
  } else if (Boolean(nuxtApp.payload.prerenderedAt) || Boolean(nuxtApp.payload.isCached)) {
    nuxtApp.hook('app:suspense:resolve', () => {
      useUserSession().fetch();
    });
  }
});
// plugins/session-fetch.server.ts
export default defineNuxtPlugin({
  name: 'session-fetch-server',
  enforce: 'pre',
  async setup(nuxtApp) {
    nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
    if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
      await useUserSession().fetch();
    }
  },
});

Related

Environment

  • nuxt-auth-utils: 0.5.28
  • nuxt: 4.x (compatibilityVersion: 5)
  • Affects all SWR-cached and prerendered routes with AuthState in async layout components

 

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions