The Server and the Browser Must Agree
August 5, 2026
What two years of hydration bugs taught me about authentication, caching, and server-side rendering
When I started migrating Multiforum from Vue to Nuxt in August 2024, I expected the difficult work to be mechanical: replace the router, move pages into Nuxt's file-based structure, and make browser-only libraries behave on the server.
That work was real. It was not the hardest part.
The harder lesson was that server-side rendering changes the meaning of application state. In a client-rendered app, the browser can discover who the user is, fetch their data, and build the interface from there. In an SSR app, the server builds that interface first. A moment later, the browser builds it again and attaches behavior to the existing HTML. If those two renders use different facts, Vue cannot safely hydrate the page.
Over the next two years, I fixed dozens of SSR and hydration failures. Some were visual: a page flashed, a button disappeared, or a responsive navigation tree re-rendered. Others were production-only 500s. The most serious ones revealed that personalized HTML or authentication state could cross boundaries where it did not belong.
The eventual solution was not another ClientOnly wrapper. It was making identity, data, and caching obey the same rule:
The server and the browser must render from the same truth, and that truth must belong to exactly one request.
Why SSR was worth the trouble
Multiforum is a community platform built around public discussions, events, wikis, and downloads. Its content is meant to be searched, shared, and opened from links. Sending useful HTML on the first response matters for search engines, link previews, and perceived speed.
Nuxt was a natural fit, but migrating an existing Vue application exposed assumptions that a client-only runtime had allowed me to ignore:
- Some libraries accessed the DOM as soon as they were imported.
- Responsive components made rendering decisions from a viewport the server could not see.
- Dates could cross a time-zone or clock boundary between renders.
- Apollo queries could resolve in a different order on the server and client.
- Authentication lived in browser storage, which the server could not read.
Hydration turned all of those assumptions into observable failures.
The first phase: isolate the browser without giving up SSR
The early fixes were mostly about runtime boundaries. Three.js, an STL viewer, popovers, a code-diff package, maps, and DOM-dependent directives all needed deliberate treatment. A soft navigation could work perfectly because the code ran only in the browser, while a hard refresh of the same route could fail during server rendering.
ClientOnly was useful, but it was easy to overuse. Wrapping an entire feature silenced the immediate error while also removing meaningful server-rendered content. I eventually removed an unnecessary ClientOnly boundary from the Markdown renderer so public post content would once again appear in the initial HTML.
Even the wrapper itself had edge cases. To understand one of them, it helps to think of hydration in React terms. The server sends completed HTML, and then the browser runs the component code to produce its first virtual-DOM tree. The framework expects that tree to have the same structure as the HTML already on the page. It is attaching event handlers and state to existing elements, not rendering the page from scratch.
In one layout, I gave ClientOnly custom fallback content to display during server rendering. Nuxt surrounded that fallback with invisible HTML comment nodes marking the beginning and end of a fragment. Those comments did not affect what a person saw, but they were still part of the server-rendered tree. When Vue ran in the browser, its first tree expected a single placeholder instead of the server's fragment markers and fallback nodes.
That is roughly analogous to calling React's hydrateRoot on markup whose wrapper elements do not match the client's first render. Vue could no longer safely assume that each client node corresponded to the next server node, so it abandoned hydration for a large section of the layout and rendered that section again. Because the page content lived inside that section, users saw the page briefly disappear and reappear.
The fix was not more fallback markup. I removed the custom fallback slots and let ClientOnly emit one predictable placeholder. The server and the browser then agreed on the surrounding structure, and Vue could hydrate the existing page without replacing it.
The useful rule became: use ClientOnly as a scalpel around an intrinsically browser-only capability, not as a blanket cure for state you have not made deterministic.
Auth was the real architectural problem
The original Auth0 integration used the SPA SDK. It stored its token in localStorage and resolved the user after the application reached the browser. That is a reasonable client-side architecture, but it creates an impossible situation for personalized SSR: the server is always logged out while the browser may be logged in.
At first, I worked around that asymmetry. The application would render a logged-out state on the server, deliberately reproduce it during the first client render, and reveal authenticated controls after mount. Later, I added lightweight auth-hint and username-hint cookies so the server could make a better guess.
Those techniques reduced individual flashes, but they created a small shadow authentication system:
- an Auth0 token in browser storage,
- a cached username,
- hint cookies,
- mounted-state checks,
- watchers that synchronized several reactive variables,
- retry and token-refresh behavior,
- separate rules for ownership-gated UI.
The complexity showed up in bugs with names like “ghost login”: a stale username survived in localStorage after the real session had expired, so the UI believed in a user for whom it had no valid token. Fixing that bug required tighter cache invalidation. Fixing the next hydration flash required deferring username restoration until after mount.
Both fixes were correct within that design. Neither changed the fact that the server and browser began with different sources of truth.
Replacing the workaround stack with server sessions
The turning point came when @auth0/auth0-nuxt made a server-session model practical. Instead of asking the browser to discover identity after SSR, Nuxt could resolve the Auth0 session before rendering.
The resulting request flow became:
- Nitro reads the session cookie and resolves the Auth0 session.
- The server exchanges or refreshes the API access token as needed.
- It resolves the application's own username and profile from the GraphQL backend. Auth0 is trusted for verified identity; the application remains the source of truth for application data.
- A universal Nuxt plugin seeds auth state with
useState. - Nuxt serializes that state into the page payload.
- The browser restores the same values before hydration.
- Server-side Apollo queries receive the same access token that authenticated client queries use.
That last step mattered more than it first appeared. Rendering the correct logged-in navigation was not enough if Apollo still queried anonymously during SSR. Permission-sensitive GraphQL results—ownership controls, moderator data, vote state—would still differ when the browser repeated the query with a token.
Once auth and data used the same request-scoped identity on both sides, the authentication gate became dramatically simpler. RequireAuth.vue, which had accumulated roughly 350 lines of SDK calls, mounted-state checks, token handling, and synchronization logic, fell to about 70 lines of mostly presentational code.
Deleting that code was the architectural win. The component no longer had to manufacture consistency because the request lifecycle provided it.
The bug that looked like an SDK failure
The migration was not a clean library swap. At one point, the new SDK appeared unable to read or refresh the session it had just written. Instrumentation seemed to implicate token expiry units and the refresh-token location. It was a plausible explanation, and it was wrong.
Instead of filing an upstream issue, I built a minimal reproduction. The SDK worked in isolation. That changed the question from “What is wrong with this dependency?” to “What is different about this application?”
The answer was a broad Nitro route rule that cached /api/**.
The browser authenticated /api/auth/token with a session cookie, but Nitro's route cache treated the response as shared and cookie-independent. The token handler therefore ran without the user's cookie, found no session, and returned no token. The visible symptom was an authorization failure several layers downstream in a GraphQL mutation.
A more specific rule—/api/auth/**: { cache: false }—fixed the root cause. An upvote that had failed as unauthenticated completed end to end.
That investigation also uncovered adjacent production requirements:
- A stateless cookie containing ID, access, and refresh tokens could exceed the browser's roughly 4 KB cookie limit, so sessions needed a server-side store.
- An in-memory store disappeared on server restart and could not be shared across serverless instances, so development used persistent filesystem storage and production used Upstash Redis.
- Secure-cookie behavior had to differ between HTTPS production and HTTP local development.
- The GraphQL backend had to recognize the dedicated API audience used by the new token.
- The Apollo module's actual token hook behavior differed from the configuration path I first expected it to use.
None of those problems lived entirely in “the auth component.” The working solution crossed identity provider configuration, cookies, Nitro middleware, Nuxt plugins, storage, Apollo, GraphQL authorization, and deployment infrastructure.
When a hydration bug is a security bug
Making SSR authentication-aware invalidated another earlier assumption: that detail pages were safe to cache because server rendering was anonymous.
They were no longer anonymous.
ISR stands for Incremental Static Regeneration. It is a compromise between generating a page on every request and building every page ahead of time. The first request causes the server to render the page, and the hosting platform saves that finished HTML at the edge—on servers geographically closer to users. Later visitors receive the saved response quickly. After a configured interval, the platform regenerates the page in the background and replaces the cached copy.
In Nuxt, I enabled that behavior with route rules such as '/forums/*/discussions/*': { isr: 300 }. In plain English: cache each matching discussion page and refresh it approximately every five minutes. That was safe while SSR always rendered an anonymous version of the page, because every visitor could receive the same HTML.
The server-session migration changed the premise without changing the cache rule. SSR now used the request's session cookie to include the current user's navigation state, username, voting state, and ownership controls. If the first request after cache expiry came from a logged-in user, the edge could save that personalized HTML as though it were a public page and serve it to later visitors.
That happened in production: an anonymous request received HTML from a logged-in render, including authenticated state and owner controls. The browser then loaded the anonymous visitor's real state, discovered that its first component tree did not match the cached HTML, and triggered a hydration failure. In that sense, hydration was acting like a smoke alarm. The browser corrected the visible page, but only after the server had already sent content generated for a different user.
Displaying an Edit button did not by itself bypass backend authorization; the API still had to enforce permissions. But personalized HTML and serialized state had crossed a user boundary, which made this a security and privacy problem rather than merely a rendering glitch.
The immediate fix was to remove shared ISR caching from personalized routes. Restoring that optimization in the future would require an explicit anonymous-only cache policy or a cache key that safely varies by identity—not the old blanket rule.
A related issue existed inside the server process. Some authentication values lived in module-level Vue refs: code roughly equivalent to const username = ref('') declared at the top level of an imported file. In a normal SPA, that module is loaded once inside one person's browser tab. Using it as a shared singleton is often exactly what the application wants.
SSR changes who shares that singleton. The module is loaded by the server process, and the same loaded module may render pages for many users. A serverless function does not necessarily start from a clean slate either; platforms keep instances warm and reuse them for later requests. If one request changes a top-level ref to cluse, that value can still exist when the next request begins. Concurrent requests make the boundary even less reliable. This pattern is often called cross-request state pollution.
Nuxt's useState looks similar from a component's point of view, but it has SSR-aware ownership. Calling useState('auth:username', () => '') during server rendering stores the value in the current Nuxt request context rather than in one process-wide variable. Request A and request B can use the same state key without sharing a value.
useState also solves the handoff to the browser. Nuxt serializes the current request's state into that page's payload, and the browser restores it before running the first client render. The server might render auth:username as cluse, and the matching browser receives cluse; an anonymous request is explicitly seeded with an empty value and receives that empty value. Each request is isolated, and each browser hydrates from the exact state that produced its HTML.
This was the point where my mental model of hydration changed. A mismatch is not merely Vue being fussy about DOM nodes. It can be evidence that two users, two requests, or two authorization contexts have been allowed to share state.
Data has to be deterministic too
Authentication was the largest source of mismatch, but not the only one.
One mismatch came from a datetime variable passed into a GraphQL query. The query needed a value representing the current hour, so the original code calculated it with DateTime.local().startOf('hour').toISO(). That sounds stable, but “local” meant the server's time zone during SSR and the visitor's time zone in the browser. The same instant could therefore be serialized as 12:00Z by the server and 05:00-07:00 by a browser in Arizona.
GraphQL could interpret those strings as the same instant, but Apollo's cache identity includes the serialized query variables. Different strings meant different cache entries. The client could miss the query result restored from SSR, run the query again, and temporarily render different data while Vue was trying to hydrate the server's HTML.
I changed the value to DateTime.utc().startOf('hour').toISO(), giving both environments one canonical time zone and one string representation. I also changed the variables argument from a plain object containing reactive refs to a function that returned plain values:
const currentHour = () => DateTime.utc().startOf('hour').toISO();
useQuery(GET_CHANNEL, () => ({
uniqueName: channelId.value,
now: currentHour(),
}));
The function lets Vue Apollo track channelId reactively while handing GraphQL an ordinary string rather than a Vue ref. Rounding to the start of the hour also keeps the server and browser from disagreeing merely because a few milliseconds passed between their renders. Together, those changes made the query variables structurally and textually identical on both sides, allowing the client to reuse the server's Apollo result during hydration.
Apollo's normalized cache exposed a subtler problem. Multiple queries wrote the same Channel.Moderators field with different sub-selections. Depending on query completion order, a later write could replace data from an earlier one. SSR and client hydration did not always resolve those queries in the same order, so a “Forum Mod” badge could appear in one render and disappear in the other.
The durable fix was a cache merge policy that unions moderators by stable identity and preserves fields across partial writes. Once the final cache state no longer depended on network timing, the rendered output stopped depending on it too.
The same principle applied elsewhere:
- Query variables must have the same shape, scalar values, and serialized representations on the server and client.
- SSR-fetched list data must not be cleared by an eager client watcher during hydration.
- Time-derived labels need a stable reference instant.
- Responsive libraries need an SSR mode with a consistent initial breakpoint.
- Browser-only behavior still needs a safe server-side definition when it appears in a server-rendered template. For example, I used a Vue directive called
v-click-outsideto close a dropdown when someone clicked elsewhere on the page. That event-listener behavior only makes sense in a browser, but Vue's server renderer still encountered the directive while generating the HTML. I had registered it in a client-only plugin, so the server did not know what it was and crashed with an HTTP 500 on a hard refresh. Navigating to the same page from inside the SPA worked because the browser had registered the directive. I changed the plugin from client-only to universal, then added Vue's server-rendering hook asgetSSRProps() { return {} }. The empty object told Vue that the directive was valid but had no attributes or other output to add to the server-rendered element. The existingbeforeMountandunmountedhooks continued to install and remove the real document click listener only in the browser.
Hydration correctness is deterministic systems work in miniature.
Debugging what only production could see
Several failures appeared only in the Vercel runtime, not in a local production build. One responsive-navigation mismatch came from Vuetify being created without ssr: true; the server and client chose different breakpoint branches. Another required inspecting the precise comment and fragment nodes generated around layout fallbacks.
The useful response was better observability, not random template edits. I temporarily enabled Vue's production hydration mismatch details and added a client-side probe to identify the subtree Vue replaced. I compared hard refreshes with soft navigation, inspected server HTML separately from the hydrated DOM, and used minimal reproductions to separate framework behavior from application integration.
Those tools were temporary. The knowledge became permanent documentation and tests.
What I would carry into the next SSR migration
After two years, these are the lessons I would apply on day one:
- Choose a server-readable authentication model before personalizing SSR. If identity exists only in browser storage, every server-rendered auth decision is a guess or a placeholder.
- Treat cache policy as part of the authorization model. A response authenticated by a cookie must never enter a shared cache accidentally. Personalized HTML needs the same scrutiny as personalized API data.
- Make all server state request-scoped. Module-level reactive state is convenient in the browser and dangerous on a server that handles more than one user.
- Authenticate server-side data fetching too. Matching the nav is insufficient when SSR and client GraphQL queries have different permissions.
- Use
ClientOnlynarrowly. Browser-only rendering is sometimes the correct boundary, but it should not hide public content or compensate for fixable state divergence. - Assume nondeterminism is a dependency. Clocks, viewport size, query order, cache merge behavior, and plugin registration order all influence the render unless explicitly controlled.
- Disprove your favorite diagnosis. Reading library source and building a minimal reproduction saved me from reporting the wrong upstream bug and redirected the investigation toward the real integration failure.
- Preserve the investigation. A fixed bug teaches the next engineer very little unless the invariant, failed hypotheses, and verification method are written down.
The migration did eventually deliver what I wanted: crawlable public content, fast server-rendered pages, and authenticated controls that are correct on first paint. But the more valuable outcome was learning to see hydration errors for what they often are: boundary failures between runtimes, requests, identities, caches, and data sources.
Once those boundaries are explicit, the browser console gets quieter. More importantly, the system becomes easier to reason about.