diff --git a/.changeset/add-hydrate-session.md b/.changeset/add-hydrate-session.md new file mode 100644 index 0000000000..9fa8d74f91 --- /dev/null +++ b/.changeset/add-hydrate-session.md @@ -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. diff --git a/docs/content/docs/concepts/client.mdx b/docs/content/docs/concepts/client.mdx index 69a5f8b3b2..1da4dedfe4 100644 --- a/docs/content/docs/concepts/client.mdx +++ b/docs/content/docs/concepts/client.mdx @@ -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 +} +``` + +```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

Not signed in

+ return
{JSON.stringify(session, null, 2)}
+} +``` + + + * Only the first non-null call to `hydrateSession` takes effect. + * Passing `null` is safe. It will be ignored and `useSession` will fetch as usual. + + ### Plugins You can extend the client with plugins to add more functionality. Plugins can add new functions to the client or modify existing ones. diff --git a/packages/better-auth/src/client/client.test.ts b/packages/better-auth/src/client/client.test.ts index fd68ff7202..3ca5cf84c2 100644 --- a/packages/better-auth/src/client/client.test.ts +++ b/packages/better-auth/src/client/client.test.ts @@ -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().toMatchTypeOf< + (session: ReturnType["data"]) => void + >(); + }); it("should infer resolved hooks react", () => { const client = createReactClient({ plugins: [testClientPlugin()], diff --git a/packages/better-auth/src/client/config.ts b/packages/better-auth/src/client/config.ts index 8130a4a6da..3909361ceb 100644 --- a/packages/better-auth/src/client/config.ts +++ b/packages/better-auth/src/client/config.ts @@ -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; const pluginsAtoms = { @@ -181,6 +190,7 @@ export const getClientConfig = ( pluginsAtoms, pluginPathMethods, atomListeners, + hydrateSession, $fetch, $store, }; diff --git a/packages/better-auth/src/client/lynx/index.ts b/packages/better-auth/src/client/lynx/index.ts index cab5ac789a..9523ce7f50 100644 --- a/packages/better-auth/src/client/lynx/index.ts +++ b/packages/better-auth/src/client/lynx/index.ts @@ -51,6 +51,7 @@ export function createAuthClient