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,
};
}
Enter fullscreen mode Exit fullscreen mode
// 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} />;
}
Enter fullscreen mode Exit fullscreen mode
// 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>;
}
Enter fullscreen mode Exit fullscreen mode
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 } });
}
Enter fullscreen mode Exit fullscreen mode
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,
};
});
Enter fullscreen mode Exit fullscreen mode
// composables/useSession.ts
export function useSession() {
return useFetch('/api/session', {
key: 'session',
default: () => ({ user: null, roles: [] }),
});
}
Enter fullscreen mode Exit fullscreen mode
<!-- app.vue or a layout -->
<script setup lang="ts">
const { data: session } = await useSession();
</script>
<template>
<AccountMenu :session="session" />
</template>
Enter fullscreen mode Exit fullscreen mode
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'] });
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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'],
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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. “NuxtuseCookie 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
답글 남기기