Supporting login state across SSR and CSR is not merely a question of whether a cookie travels with the request. The real work is verifying identity on the server, reusing that result for the first client render, recovering a good browser experience later, and still authorizing every protected operation on the server.
Start with three minimal implementations
Before discussing abstractions, here are representative ways that Next.js, Nuxt, and Cabloy carry server-verified login state into the first client render.
All three examples deliberately expose only a UI-safe public DTO. A raw session token, refresh token, password hash, and server-internal fields do not belong in a hydration payload.
Next.js App Router: a Server Component passes a safe DTO explicitly
// app/types/session.ts
export type PublicSession = {
user: { id: string; name: string };
roles: string[];
} | null;
// app/lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';
import type { PublicSession } from '@/app/types/session';
export async function getPublicSession(): Promise<PublicSession> {
const token = (await cookies()).get('session')?.value;
if (!token) return null;
const session = await verifySessionOnServer(token);
if (!session) return null;
return {
user: {
id: session.user.id,
name: session.user.name,
},
roles: session.roles,
};
}
// app/dashboard/page.tsx — Server Component
import { DashboardClient } from './dashboard-client';
import { getPublicSession } from '@/app/lib/session';
export default async function DashboardPage() {
const initialSession = await getPublicSession();
return <DashboardClient initialSession={initialSession} />;
}
// app/dashboard/dashboard-client.tsx
'use client';
import type { PublicSession } from '@/app/types/session';
export function DashboardClient({
initialSession,
}: {
initialSession: PublicSession;
}) {
if (!initialSession) return <a href="/login">Sign in</a>;
return <p>Hello, {initialSession.user.name}</p>;
}
A protected operation still establishes identity and authorizes the exact resource on the server:
// app/lib/projects.ts
import 'server-only';
import { getPublicSession } from './session';
export async function getProjectForViewer(projectId: string) {
const session = await getPublicSession();
if (!session) throw new Error('Unauthenticated');
await assertCanReadProject(session.user.id, projectId);
return await db.project.findUniqueOrThrow({ where: { id: projectId } });
}
Nuxt: a server session endpoint plus a useFetch payload
// server/api/session.get.ts
import { getCookie } from 'h3';
export default defineEventHandler(async event => {
const token = getCookie(event, 'session');
if (!token) return { user: null, roles: [] };
const session = await verifySessionOnServer(token);
if (!session) return { user: null, roles: [] };
return {
user: {
id: session.user.id,
name: session.user.name,
},
roles: session.roles,
};
});
// composables/useSession.ts
export function useSession() {
return useFetch('/api/session', {
key: 'session',
default: () => ({ user: null, roles: [] }),
});
}
<!-- app.vue or a layout -->
<script setup lang="ts">
const { data: session } = await useSession();
</script>
<template>
<AccountMenu :session="session" />
</template>
Cabloy: server mem, client local, bridged by the Model Query Cache
This is the current shape of Cabloy Basic's ModelPassport:
// zova/src/suite/a-home/modules/home-passport/src/model/passport.ts
this.passport = process.env.CLIENT
? this.$useStateLocal({ queryKey: ['passport'] })
: this.$useStateMem({ queryKey: ['passport'] });
The server confirms the passport using the authentication information for the current request:
async ensurePassport() {
if (process.env.CLIENT) return this.passport;
if (!this.sys.config.ssr.cookieDisabledOnServer && !this.isAuthenticated && this.accessToken) {
this.passport = await this.$api.homeUserPassport.current();
this._setLocaleTz();
}
return this.passport;
}
The surface APIs differ, but the shared principle is the same:
The browser receives a server-confirmed, UI-safe projection of login state. Authentication and authorization decisions remain on the server.
What each implementation is actually solving
Next.js: the Server Component is the start of the bridge
The natural boundary in the Next.js App Router is between Server Components and Client Components.
HttpOnly Cookie
→ server-only session verification
→ Server Component
→ serializable initialSession prop
→ Client Component hydration
cookies() is read along a server execution path. getPublicSession() reads the request cookie, verifies the session, and constructs a small, explicit PublicSession DTO. The Server Component then passes it as initialSession to the Client Component.
Two details matter:
-
The Client Component receives a DTO, not the cookie or raw session.
HttpOnlyexists specifically to keep the credential unavailable to browser JavaScript. -
initialSessionsolves first-render consistency. It avoids a server-rendered signed-in UI becoming a signed-out client UI during hydration. It does not authorize a later Server Action, Route Handler, or data-access operation.
Next.js does not decide your global client session store for you. The application may put initialSession in Context, a state store, a Query cache, or pass it farther through the component tree. That explicitness is a feature: the application owns both the session DTO and its later client-state policy.
Nuxt: keyed async data bridges the SSR payload
In Nuxt, the primary bridge is commonly useFetch or useAsyncData:
HttpOnly Cookie
→ /api/session server endpoint
→ useFetch('/api/session', { key: 'session' })
→ Nuxt SSR payload
→ Client hydration reuses payload
The server /api/session endpoint reads and validates the request cookie, then returns only public display data. A page or layout calls the same useFetch path. On SSR, the result enters the Nuxt payload; during initial hydration, the client reuses the result under that async-data identity instead of treating the same session read as an independent request.
key: 'session' provides stable identity for this async data. It also makes later refreshes such as refreshNuxtData('session') possible. It is not a permission key and does not authorize any protected endpoint.
Projects that want a fuller Nuxt session abstraction can evaluate the official nuxt-auth-utils module and its useUserSession(), setUserSession(), and requireUserSession() APIs. This article uses /api/session plus useFetch because it exposes the fundamental SSR-payload-to-client-reuse chain directly.
Cabloy: Model-owned Query Cache identity is the bridge
Cabloy's ModelPassport uses different helpers for the initial SSR source and the browser persistence policy, then joins them through the same effective Query Key:
request cookie / token
→ ModelPassport.ensurePassport()
→ server $useStateMem(['passport'])
→ request-scoped Query Cache
→ dehydrate after render
→ client QueryClient hydrate
→ client $useStateLocal(['passport'])
→ Query Cache first, localStorage fallback
The server selects $useStateMem(...) because it has no browser localStorage, and the identity for this request must be confirmed through the request credential and ensurePassport(). A successful memory Query Cache entry that passes the dehydration policy can enter the SSR snapshot.
The client selects $useStateLocal(...) because it must both reuse the initial SSR snapshot and support CSR-only entry or browser-session recovery when no snapshot exists. $useStateLocal(...) checks the Query Cache first. When a hydration entry exists, the server-confirmed passport wins. Only when no cache value exists does it attempt synchronous localStorage restoration and then fall back to meta.defaultData.
Two details are frequently misunderstood:
- The dehydrated entry is the Query entry created by server
$useStateMem(...), not the client$useStateLocal(...)wrapper. - Hydration restores the Query Cache. Reading that value does not automatically save it to
localStorage; a later assignment through the client local-state wrapper updates the cache and writes the local record.
Also, ['passport'] is not a global key. Zova prefixes the key with Model identity, and selector-enabled Models include selector identity as well. Reuse therefore requires the same Model, applicable selector, and logical queryKey.
One table for the three bridge designs
| Dimension | Next.js App Router | Nuxt | Cabloy |
|---|---|---|---|
| Server identity confirmation | Server-only code / Server Component reads and verifies Cookie |
/api/session endpoint reads and verifies Cookie |
ModelPassport.ensurePassport() confirms the passport for the current request |
| Initial client-state carrier | Explicit initialSession prop through the RSC payload |
Nuxt payload from useFetch
|
Dehydrated and hydrated Model Query Cache entry |
| Later client-state policy | Context, store, or Query policy chosen by the application |
useFetch cache; higher-level auth/state policy chosen by the application |
$useStateLocal restores localStorage only when hydrated cache is absent |
| Identity rule | The Server/Client Component props contract | Async-data key, such as session
|
Model identity + selector when applicable + logical queryKey
|
| Protected server operations | Server Actions, Route Handlers, and DAL authorize independently | Server APIs, handlers, and services authorize independently | Vona passport guard and business permission logic authorize independently |
| Main architectural emphasis | Server/client component boundary and safe DTOs | SSR-aware fetching and payload reuse | Model ownership, Query Cache identity, and helper lifecycle |
None of these designs "hydrates a cookie into the browser." A cookie is a request credential. Hydrated props, a Nuxt payload, and a Model Query Cache entry are derived state for rendering and interaction.
The real security boundary: client login state improves UX only
Many implementations go wrong here: a page can render the user name and hide buttons by role, so the client state is treated as an authorization result.
It is not.
1. An HttpOnly cookie is not client state
Browser JavaScript should not read an HttpOnly cookie. The browser attaches it to requests within the cookie's scope; the server then validates its signature, expiry, session record, revocation state, or user status.
Do not conflate these two channels:
Cookie / session credential
→ server-side authentication input
hydrated public session DTO / passport cache
→ client rendering input
2. A client route guard is a UX guard, not an authorization point
A client-side guard can redirect early, avoid a visible unauthorized page, or hide unavailable controls. A caller can still issue an HTTP request directly and can still alter display state in browser memory.
Every protected Server Action, Route Handler, Nuxt server API, Vona Controller/service, or equivalent operation must independently:
- establish the current identity from request authentication data;
- validate that the user, tenant, and session remain valid;
- authorize the exact action against the exact resource;
- validate its input.
3. A smaller DTO is both safer and more stable
The first client render commonly needs no more than:
{
user: { id, name, avatar },
roles: ['admin'],
}
It does not need a raw session, refresh token, upstream API token, password field, internal audit field, or an entire database user record. A safe DTO reduces both direct exposure and accidental leakage as server-side data models evolve.
4. Stale display state is normal
Logout, expiration, role revocation, tenant changes, and a logout on another device can all make a browser's previous login-state projection stale.
The objective of an SSR/CSR bridge is therefore:
Make the first paint and ordinary interactions coherent, not give the client a permanently trusted copy of identity.
Cookie configuration should match the application: HttpOnly, Secure, SameSite, path, expiry, and rotation strategy all matter. Cookie-authenticated state-changing requests also need a CSRF approach appropriate to the deployment, cross-site requirements, embedded contexts, and session architecture.
Why Cabloy's bridge deserves separate attention
The Next.js and Nuxt examples usually keep one visible application abstraction across the boundary:
Next: server DTO → client prop / provider
Nuxt: server endpoint → same useFetch key
Cabloy's distinguishing feature is that the same business field can deliberately use different state helpers on server and client:
SSR Server: $useStateMem(['passport'])
SSR Client: $useStateLocal(['passport'])
Bridge: Model-owned effective Query Cache identity
This separates two needs instead of conflating them:
-
memmeans request-confirmed runtime state for the current SSR pass, without a browser storage backend; -
localmeans client state that can persist and recover in the browser, without overriding a hydrated server conclusion; - the common effective key joins those paths during the first hydration.
The trade-off is equally explicit. Developers must understand Model ownership, Query Cache lifecycle, dehydrate/hydrate behavior, selectors, and helper families rather than treating this as an ordinary Vue ref or one global store. For applications where SSR, caching, persistence, and resource models coexist, that explicit ownership can be easier to sustain than an accumulation of local conventions.
Common mistakes and better formulations
| Misleading shortcut | More accurate formulation |
|---|---|
| "Next.js hydrates the session cookie into React." | Next.js reads the Cookie in a Server Component or server-only path and passes a serializable safe DTO to a Client Component. |
"Nuxt useCookie lets the client read an HttpOnly login cookie." |
useCookie is SSR-aware, but browser JavaScript still cannot read an HttpOnly cookie; the client should consume a safe DTO returned by the server. |
"A useFetch key is a permission key." |
The key identifies async data and payload/cache reuse. The server still authorizes requests from their credentials and target resources. |
"Cabloy's $useStateLocal is dehydrated directly by the server." |
In this flow, the server $useStateMem entry is dehydrated; the client $useStateLocal wrapper reuses hydrated cache through the same effective key. |
| "An SSR snapshot automatically persists to localStorage." | SSR hydration restores initial cache. Cabloy saves browser-local state through later assignments to the local-state wrapper. |
| "An API is safe because the UI hides its button by role." | UI control is an experience layer; every protected server operation must authenticate, authorize, and validate input independently. |
When each structure fits
Choose an explicit Next.js DTO bridge
This fits applications centered on the App Router and Server Components that want precise control over every server-to-client boundary. The exposure surface is highly visible; the trade-off is that the application chooses and maintains its own client session provider, cache, and refresh policy.
Choose a keyed Nuxt useFetch bridge
This fits Nuxt applications centered on server APIs and SSR data fetching. It offers SSR-payload reuse with little plumbing; the trade-off is that a team must keep async-data keys, endpoint response shapes, and refresh/invalidation policy disciplined as the application grows.
Use Cabloy's ModelPassport bridge
This fits Cabloy/Zova Model applications that want SSR handoff, client cache identity, and browser persistence fallback under one Model-owned state runtime. It provides an existing passport bridge pattern; the trade-off is following the architectural rules around Model/selector/Query Key identity and helper lifecycle.
Whichever framework you choose, the stable final principle is unchanged:
The server verifies identity, the client reuses display state, and protected server operations authorize again.
References and source material
Next.js
- Next.js Authentication
- Next.js
cookies()API - Next.js Server and Client Components
- Next.js Data Security
Nuxt
- Nuxt
useFetch - Nuxt Data Fetching
- Nuxt
useRequestFetch - Nuxt
useCookie - Nuxt Sessions and Authentication
Top comments (0)