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