You’re building a React app with Next.js’ App Router and React Server Components (RSC). Users can sign in, navigate around, then hit refresh—and sometimes the UI “forgets” they were authenticated until a client request revalidates. That flash of “signed out” is jarring and can cause extra fetches or even 401s on client-only routes. The fix is to let the server be the source of truth (via HTTP-only cookies or a server session) while the client mirrors that state with React Context, hydrating it at first render so refreshes don’t reset the UI. We’ll set that up end-to-end, using the App Router, Server Actions/Route Handlers, and a small client provider. We’ll deploy anywhere you like—if you plan to put this on DigitalOcean App Platform, make sure you have a DigitalOcean account ready up front.
What “RSC-safe” means here
React Server Components render on the server, can read cookies/headers at request time, and never ship sensitive secrets to the browser. In Next.js App Router, read cookies in Server Components to make routing decisions, and write cookies only from Server Actions or Route Handlers. Treat the cookie as your durable auth signal, and hydrate a client context from it so the UI starts in the right state after reloads (no localStorage tokens). This approach avoids leaking tokens client-side and opts pages into dynamic rendering only when needed.
The shape we’re building
- A signed cookie holds a session id (or opaque token).
- A Route Handler (
/api/auth/session
) returns the current user (or null) from that cookie. - A Server Action logs in/out and sets/clears the cookie.
- A Client Context Provider (
AuthProvider
) exposes{user, status, login, logout}
. - A Server “Hydrator” component reads the cookie and passes the initial session to the provider to prevent the refresh flash.
This pattern keeps secrets server-only, minimizes client work, and plays nicely with RSC boundaries.
Project baseline
- Next.js (App Router) with React 18+.
app/
directory enabled.- A server utility that can resolve a user from a session id (shown here as
getUserBySession
/createSession
). - Cookies are HTTP-only,
Secure
,SameSite=Lax
(orStrict
), with a sensiblemaxAge
.
Next uses cookies()
/headers()
on the server; calling these makes a route dynamic at request time, which is what you want for per-user auth.
1) Define minimal auth types
// lib/auth/types.ts
export type User = {
id: string;
email: string;
name?: string;
};
export type Session = {
user: User | null;
};
2) Server utilities for session I/O
Replace the in-memory stubs with your DB/KV of choice (e.g., Postgres + Prisma, Redis, or your own API).
// lib/auth/server.ts
import { cookies } from "next/headers";
import { type Session, type User } from "./types";
// Pretend store (replace with DB/Redis)
const sessions = new Map<string, User>();
export async function getUserBySession(sessionId: string): Promise<User | null> {
return sessions.get(sessionId) ?? null;
}
export async function createSession(user: User): Promise<string> {
const id = crypto.randomUUID();
sessions.set(id, user);
return id;
}
export async function destroySession(sessionId: string) {
sessions.delete(sessionId);
}
const SESSION_COOKIE = "sid";
export async function readSessionFromCookie(): Promise<Session> {
// RSC-safe cookie read at request time
const jar = await cookies();
const sid = jar.get(SESSION_COOKIE)?.value;
if (!sid) return { user: null };
const user = await getUserBySession(sid);
return { user };
}
export async function setSessionCookie(sessionId: string) {
// Must be called from a Server Action or Route Handler
const jar = await cookies();
jar.set({
name: SESSION_COOKIE,
value: sessionId,
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
}
export async function clearSessionCookie() {
const jar = await cookies();
jar.set({
name: SESSION_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 0,
});
}
In RSC,
cookies()
is available for reading; writes must occur in a Server Action or Route Handler. We’ll do both below.
3) Route Handler to expose the current session to the client
A single lightweight endpoint lets the client provider confirm the current user and re-sync on navigation or tab focus.
// app/api/auth/session/route.ts
import { NextResponse } from "next/server";
import { readSessionFromCookie } from "@/lib/auth/server";
export async function GET() {
const session = await readSessionFromCookie();
return NextResponse.json(session, { status: 200 });
}
Route Handlers are the App Router’s way to define HTTP endpoints; we’ll call this from the client provider.
4) Server Actions for login and logout
Use a server-only function to validate credentials, create a server session, and set the cookie. Call it from a <form action={login}>
or imperatively via useTransition
. This keeps secrets out of the client bundle and ensures cookies are written in a supported context.
// app/(auth)/actions.ts
"use server";
import { z } from "zod";
import { createSession, setSessionCookie, clearSessionCookie, destroySession } from "@/lib/auth/server";
const Login = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function login(formData: FormData) {
const parsed = Login.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) {
return { ok: false, error: "Invalid credentials" };
}
// Replace with real credential check
const { email } = parsed.data;
const user = { id: "u_" + crypto.randomUUID(), email, name: email.split("@")[0] };
const sid = await createSession(user);
await setSessionCookie(sid);
return { ok: true };
}
export async function logout() {
// If you track session id server-side, clear it too
// Read cookie explicitly if you need to destroy a server session record
// (left minimal here)
await clearSessionCookie();
return { ok: true };
}
5) Client Auth Context and Provider
The provider hydrates from an initial session (passed from the server) and can refetch on demand. It exposes simple helpers and avoids token storage in localStorage
.
// components/auth/AuthContext.tsx
"use client";
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Session, User } from "@/lib/auth/types";
type Status = "loading" | "authenticated" | "unauthenticated";
type AuthContextValue = {
status: Status;
user: User | null;
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth() {
const ctx = use(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider />");
return ctx;
}
async function fetchSession(): Promise<Session> {
const res = await fetch("/api/auth/session", { credentials: "include" });
if (!res.ok) throw new Error("Failed to load session");
return res.json();
}
export function AuthProvider({ initial, children }: { initial: Session; children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(initial.user);
const [status, setStatus] = useState<Status>(initial.user ? "authenticated" : "unauthenticated");
const mounted = useRef(false);
const refresh = useCallback(async () => {
setStatus("loading");
const data = await fetchSession();
setUser(data.user);
setStatus(data.user ? "authenticated" : "unauthenticated");
}, []);
// Optional: keep session fresh on visibility changes
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
const handler = () => void refresh();
window.addEventListener("focus", handler);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") handler();
});
return () => {
window.removeEventListener("focus", handler);
document.removeEventListener("visibilitychange", handler);
};
}, [refresh]);
const value = useMemo<AuthContextValue>(() => ({ status, user, refresh }), [status, user, refresh]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
6) Server “Hydrator” to pass the initial session into the client tree
Render this near the root so every client page sees a consistent initial auth state after SSR or a hard reload.
// components/auth/AuthHydrator.tsx
import { readSessionFromCookie } from "@/lib/auth/server";
import { AuthProvider } from "./AuthContext";
export default async function AuthHydrator({ children }: { children: React.ReactNode }) {
// Runs on the server; safe to read cookies here
const session = await readSessionFromCookie();
return <AuthProvider initial={session}>{children}</AuthProvider>;
}
Reading cookies in a Server Component is supported and will make the route dynamic, giving you per-request user awareness with zero client secrets.
7) Wire it up in your root layout
Place the hydrator where your UI needs auth—typically at app/layout.tsx
, just inside <body>
.
// app/layout.tsx
import "./globals.css";
import AuthHydrator from "@/components/auth/AuthHydrator";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthHydrator>{children}</AuthHydrator>
</body>
</html>
);
}
8) Use the context in client components
// components/NavBar.tsx
"use client";
import Link from "next/link";
import { useAuth } from "@/components/auth/AuthContext";
import { logout } from "@/app/(auth)/actions";
import { useTransition } from "react";
export default function NavBar() {
const { status, user, refresh } = useAuth();
const [isPending, start] = useTransition();
return (
<nav className="flex items-center gap-4 p-3 border-b">
<Link href="/">Home</Link>
<div className="ml-auto">
{status === "authenticated" ? (
<div className="flex items-center gap-3">
<span className="opacity-80">{user?.email}</span>
<button
onClick={() =>
start(async () => {
await logout(); // server action clears cookie
await refresh(); // re-sync client state
})
}
disabled={isPending}
className="px-3 py-1 border rounded"
>
{isPending ? "…" : "Sign out"}
</button>
</div>
) : (
<Link href="/login">Sign in</Link>
)}
</div>
</nav>
);
}
9) A simple login page that uses a Server Action
Submitting the form runs on the server, writes the cookie, and navigates back. The client context calls refresh()
on mount of the destination page if needed, but the initial hydration will already be correct thanks to the server hydrator.
// app/login/page.tsx
import { login } from "../(auth)/actions";
export default function LoginPage() {
return (
<form action={login} className="max-w-sm mx-auto mt-16 space-y-4">
<h1 className="text-2xl font-semibold">Sign in</h1>
<input name="email" type="email" required placeholder="you@example.com" className="w-full border p-2 rounded" />
<input name="password" type="password" required placeholder="••••••••" className="w-full border p-2 rounded" />
<button type="submit" className="w-full border p-2 rounded">Continue</button>
</form>
);
}
Server Actions let forms update data and set cookies server-side. This keeps credential handling off the client.
Why this persists across reloads without leaking secrets
First, the durable state lives in a secure cookie, which the browser includes on every request. On a full page load or refresh, the server reads that cookie during RSC rendering and computes the initial Session
. The server then renders your tree with <AuthHydrator initial={session}>
, so the client context starts “hot”—no flicker and no client token lookup. Because the cookie is HTTP-only, the client cannot read or exfiltrate it; it only mirrors the derived user object that you deliberately return from /api/auth/session
. This aligns with Next’s guidance for reading cookies in Server Components and modifying them in Server Actions/Route Handlers.
Production notes and edge cases
First, await cookies()
in async server code where applicable, and remember that using cookies()
/headers()
opts the route into dynamic mode. That’s expected when rendering user-specific UI. Second, don’t store raw JWTs in localStorage; prefer short-lived, HTTP-only cookies with rotation handled server-side. Third, keep the provider as low as practical in the tree to maximize static work above it. Finally, when deploying to environments like DigitalOcean, ensure your app runs behind HTTPS so Secure
cookies are honored in production.
Testing the flow locally
- Start the dev server, visit
/login
, submit credentials, and land on/
. - Open DevTools → Application → Cookies; confirm
sid
is HTTP-only andSecure
(in HTTPS). - Hit refresh: the navbar should already show the user email without a flash.
- Click “Sign out”; verify the cookie clears and the provider transitions to
unauthenticated
.
Variations you can adopt later
- Session refresh: If your backend rotates sessions, call
refresh()
on tab focus or at an interval. - SSR-guarded routes: In a Server Component page, read the cookie and redirect unauthenticated users before rendering (keeps private pages server-only).
- Auth libraries: NextAuth/Auth.js, Auth0, Supabase, or custom backends can slot in if they set an HTTP-only cookie your server can validate. Whichever you choose, keep the client provider thin; the server remains the authority.
What you now have
You persisted auth across reloads without storing secrets in the browser and without client-only races. The server reads the cookie, the provider hydrates from that truth, and any subsequent changes are reflected by a small /api/auth/session
check. This pattern is stable across Next.js App Router releases and keeps you on the happy path for RSC, cookies, and Server Actions. If you’re deploying to DigitalOcean, connect your repo, set NEXT_PUBLIC_…
vars as needed, and ship—your auth state will survive page reloads from the first request.