feat(client): add hydrateSession for SSR session hydration (#8733)

This commit is contained in:
Taesu
2026-04-22 21:09:45 +00:00
committed by GitHub
parent d3bde2d21f
commit 4e8e4c7fc5
11 changed files with 350 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": minor
---
Add `hydrateSession` to seed the client with a server-fetched session so `useSession` returns data on the first render.
+41
View File
@@ -459,6 +459,47 @@ if(error?.code){
}
```
### SSR Hydration
With SSR frameworks like Next.js, Nuxt, or SvelteKit, `useSession` fetches the session on the client, causing a loading flash. Pass the server-fetched session to `hydrateSession` and use it as a fallback so the first render has data on both the server and the client:
```tsx title="page.tsx (server component)"
import { auth } from "@/lib/auth"
import { headers } from "next/headers"
import { SessionCard } from "./session-card"
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
})
return <SessionCard initialSession={session} />
}
```
```tsx title="session-card.tsx (client component)"
"use client"
import { authClient } from "@/lib/auth-client"
type Props = {
initialSession: typeof authClient.$Infer.Session | null
}
export function SessionCard({ initialSession }: Props) {
authClient.hydrateSession(initialSession)
const { data, isPending, isRefetching } = authClient.useSession()
const session = isPending && !isRefetching ? initialSession : data
if (!session) return <p>Not signed in</p>
return <pre>{JSON.stringify(session, null, 2)}</pre>
}
```
<Callout>
* Only the first non-null call to `hydrateSession` takes effect.
* Passing `null` is safe. It will be ignored and `useSession` will fetch as usual.
</Callout>
### Plugins
You can extend the client with plugins to add more functionality. Plugins can add new functions to the client or modify existing ones.
@@ -118,6 +118,240 @@ describe("run time proxy", async () => {
vi.useRealTimers();
});
it("should hydrate session on an existing client", async () => {
vi.useFakeTimers();
const client = createSolidClient({
fetchOptions: {
customFetchImpl: async () =>
new Response(
JSON.stringify({
user: {
id: "2",
email: "fresh@email.com",
},
session: {
id: "session-2",
},
}),
),
baseURL: "http://localhost:3000",
},
});
client.hydrateSession({
user: {
id: "1",
name: "Hydrated User",
email: "hydrated@email.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
},
session: {
id: "session-1",
userId: "1",
expiresAt: new Date(),
token: "session-token-1",
createdAt: new Date(),
updatedAt: new Date(),
},
});
const session = client.useSession();
expect(session()).toMatchObject({
data: {
user: {
id: "1",
email: "hydrated@email.com",
},
session: {
id: "session-1",
},
},
error: null,
isPending: false,
isRefetching: false,
});
await vi.runAllTimersAsync();
expect(session()).toMatchObject({
data: {
user: {
id: "2",
email: "fresh@email.com",
},
session: {
id: "session-2",
},
},
error: null,
isPending: false,
isRefetching: false,
});
});
it("should not overwrite session when already hydrated", async () => {
const client = createSolidClient({
fetchOptions: {
customFetchImpl: async () =>
new Response(
JSON.stringify({
user: { id: "1", email: "fresh@email.com" },
session: { id: "session-1" },
}),
),
baseURL: "http://localhost:3000",
},
});
client.hydrateSession({
user: {
id: "1",
name: "First",
email: "first@email.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
},
session: {
id: "session-1",
userId: "1",
expiresAt: new Date(),
token: "token-1",
createdAt: new Date(),
updatedAt: new Date(),
},
});
// Second call should be a no-op
client.hydrateSession({
user: {
id: "2",
name: "Second",
email: "second@email.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
},
session: {
id: "session-2",
userId: "2",
expiresAt: new Date(),
token: "token-2",
createdAt: new Date(),
updatedAt: new Date(),
},
});
const session = client.useSession();
expect(session().data?.user?.email).toBe("first@email.com");
});
it("should not hydrate when session is null", () => {
const client = createSolidClient({
fetchOptions: {
customFetchImpl: async () => new Response(JSON.stringify(null)),
baseURL: "http://localhost:3000",
},
});
client.hydrateSession(null);
const session = client.useSession();
expect(session().data).toBeNull();
expect(session().isPending).toBe(true);
});
/**
* Re-running hydrateSession on a later render (e.g. after sign-out cleared
* the atom) must not restore the stale initial session. Hydration is
* one-shot per client instance.
*/
it("should not re-hydrate after the session atom has been cleared", () => {
const client = createVanillaClient({
fetchOptions: {
customFetchImpl: async () => new Response(JSON.stringify(null)),
baseURL: "http://localhost:3000",
},
});
const initialSession = {
user: {
id: "1",
name: "Hydrated",
email: "hydrated@email.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
},
session: {
id: "session-1",
userId: "1",
expiresAt: new Date(),
token: "token-1",
createdAt: new Date(),
updatedAt: new Date(),
},
};
client.hydrateSession(initialSession);
// Simulate sign-out clearing the session atom.
const sessionAtom = client.$store.atoms.session!;
sessionAtom.set({
...sessionAtom.get(),
data: null,
isPending: false,
});
// A later render calls hydrateSession again with the same initialSession.
client.hydrateSession(initialSession);
expect(sessionAtom.get().data).toBeNull();
});
/**
* The auth client is a module-level singleton, so the session atom is shared
* across concurrent SSR requests. Writing during server render would leak one
* request's session into another. Hydration must be a no-op on the server.
*/
it("should not write to the shared atom during server render", () => {
const client = createVanillaClient({
fetchOptions: {
customFetchImpl: async () => new Response(JSON.stringify(null)),
baseURL: "http://localhost:3000",
},
});
vi.stubGlobal("window", undefined);
try {
client.hydrateSession({
user: {
id: "user-a",
name: "User A",
email: "a@email.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
},
session: {
id: "session-a",
userId: "user-a",
expiresAt: new Date(),
token: "token-a",
createdAt: new Date(),
updatedAt: new Date(),
},
});
} finally {
vi.unstubAllGlobals();
}
expect(client.$store.atoms.session!.get().data).toBeNull();
});
it("should allow second argument fetch options", async () => {
let called = false;
const client = createSolidClient({
@@ -201,6 +435,21 @@ describe("type", () => {
isPending: boolean;
}>();
});
it("should infer hydrateSession react", () => {
const client = createReactClient({
plugins: [testClientPlugin()],
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl: async () => {
return new Response();
},
},
});
type HydrateSession = typeof client.hydrateSession;
expectTypeOf<HydrateSession>().toMatchTypeOf<
(session: ReturnType<typeof client.useSession>["data"]) => void
>();
});
it("should infer resolved hooks react", () => {
const client = createReactClient({
plugins: [testClientPlugin()],
+11 -1
View File
@@ -8,7 +8,8 @@ import type { WritableAtom } from "nanostores";
import { getBaseURL } from "../utils/url";
import { redirectPlugin } from "./fetch-plugins";
import { parseJSON } from "./parser";
import { getSessionAtom } from "./session-atom";
import type { SessionData } from "./session-atom";
import { getSessionAtom, hydrateSessionAtom } from "./session-atom";
const resolvePublicAuthUrl = (basePath?: string) => {
if (typeof process === "undefined") return undefined;
@@ -94,6 +95,14 @@ export const getClientConfig = (
$fetch,
options,
);
let hasHydrated = false;
const hydrateSession = (sessionData: SessionData | null) => {
if (hasHydrated || sessionData === null) return;
hasHydrated = true;
hydrateSessionAtom(session, sessionData);
};
const plugins = options?.plugins || [];
let pluginsActions = {} as Record<string, any>;
const pluginsAtoms = {
@@ -181,6 +190,7 @@ export const getClientConfig = (
pluginsAtoms,
pluginPathMethods,
atomListeners,
hydrateSession,
$fetch,
$store,
};
@@ -51,6 +51,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginPathMethods,
pluginsActions,
pluginsAtoms,
hydrateSession,
$fetch,
$store,
atomListeners,
@@ -63,6 +64,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
const routes = {
...pluginsActions,
...resolvedHooks,
hydrateSession,
$fetch,
$store,
};
@@ -85,6 +87,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
ClientAPI &
InferActions<Option> & {
hydrateSession: (session: NonNullable<Session> | null) => void;
useSession: () => {
data: Session;
isPending: boolean;
@@ -51,6 +51,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginPathMethods,
pluginsActions,
pluginsAtoms,
hydrateSession,
$fetch,
$store,
atomListeners,
@@ -63,6 +64,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
const routes = {
...pluginsActions,
...resolvedHooks,
hydrateSession,
$fetch,
$store,
};
@@ -85,6 +87,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
ClientAPI &
InferActions<Option> & {
hydrateSession: (session: NonNullable<Session> | null) => void;
useSession: () => {
data: Session;
isPending: boolean;
@@ -6,20 +6,40 @@ import type { AuthQueryAtom } from "./query";
import { useAuthQuery } from "./query";
import { createSessionRefreshManager } from "./session-refresh";
export type SessionAtom = AuthQueryAtom<{
export type SessionData = {
user: User;
session: Session;
}>;
};
export type SessionAtom = AuthQueryAtom<SessionData>;
export function hydrateSessionAtom(
sessionAtom: SessionAtom,
session: SessionData | null,
) {
// The client is a module-level singleton, so writing during SSR would leak
// one request's session into concurrent requests sharing the same process.
if (typeof window === "undefined") {
return;
}
const currentSession = sessionAtom.get();
if (currentSession.data !== null || session === null) {
return;
}
sessionAtom.set({
...currentSession,
data: session,
error: null,
isPending: false,
});
}
export function getSessionAtom(
$fetch: BetterFetch,
options?: BetterAuthClientOptions | undefined,
) {
const $signal = atom<boolean>(false);
const session: SessionAtom = useAuthQuery<{
user: User;
session: Session;
}>($signal, "/get-session", $fetch, {
const session = useAuthQuery<SessionData>($signal, "/get-session", $fetch, {
method: "GET",
});
@@ -52,6 +52,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginPathMethods,
pluginsActions,
pluginsAtoms,
hydrateSession,
$fetch,
atomListeners,
} = getClientConfig(options);
@@ -62,6 +63,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
const routes = {
...pluginsActions,
...resolvedHooks,
hydrateSession,
};
const proxy = createDynamicPathProxy(
routes,
@@ -83,6 +85,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
InferActions<Option> & {
hydrateSession: (session: NonNullable<Session> | null) => void;
useSession: () => Accessor<{
data: Session;
isPending: boolean;
@@ -47,6 +47,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginPathMethods,
pluginsActions,
pluginsAtoms,
hydrateSession,
$fetch,
atomListeners,
$store,
@@ -58,6 +59,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
const routes = {
...pluginsActions,
...resolvedHooks,
hydrateSession,
$fetch,
$store,
};
@@ -81,6 +83,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
InferActions<Option> & {
hydrateSession: (session: NonNullable<Session> | null) => void;
useSession: () => Atom<{
data: Session;
error: BetterFetchError | null;
@@ -47,6 +47,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginPathMethods,
pluginsActions,
pluginsAtoms,
hydrateSession,
$fetch,
atomListeners,
$store,
@@ -58,6 +59,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
const routes = {
...pluginsActions,
...resolvedHooks,
hydrateSession,
$fetch,
$store,
};
@@ -81,6 +83,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
ClientAPI &
InferActions<Option> & {
hydrateSession: (session: NonNullable<Session> | null) => void;
useSession: Atom<{
data: Session;
error: BetterFetchError | null;
@@ -55,6 +55,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginPathMethods,
pluginsActions,
pluginsAtoms,
hydrateSession,
$fetch,
$store,
atomListeners,
@@ -118,6 +119,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
const routes = {
...pluginsActions,
...resolvedHooks,
hydrateSession,
useSession,
$fetch,
$store,
@@ -134,6 +136,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
InferActions<Option> & {
hydrateSession: (session: NonNullable<Session> | null) => void;
useSession: typeof useSession;
$Infer: {
Session: NonNullable<Session>;