The nuxthub-better-auth repo by Atinux is a clean reference for integrating BetterAuth into a Nuxt app with full SSR support. The interesting parts are not the server setup but how the client-side pieces fit together: the composable that owns the auth client, two plugins that handle the session lifecycle on server and client, and a global middleware that controls route access.
The useAuth() Composable
app/composables/auth.ts is the single interface all components and pages use. It creates the BetterAuth client, owns shared session state, and exposes auth actions.
import { defu } from "defu";
import { createAuthClient } from "better-auth/client";
import type { InferSessionFromClient, InferUserFromClient, ClientOptions } from "better-auth/client";
import type { RouteLocationRaw } from "vue-router";
interface RuntimeAuthConfig {
redirectUserTo: RouteLocationRaw | string;
redirectGuestTo: RouteLocationRaw | string;
}
export function useAuth() {
const url = useRequestURL();
const headers = import.meta.server ? useRequestHeaders() : undefined;
const client = createAuthClient({
baseURL: url.origin,
fetchOptions: {
headers,
},
});
const options = defu(useRuntimeConfig().public.auth as Partial<RuntimeAuthConfig>, {
redirectUserTo: "/",
redirectGuestTo: "/",
});
const session = useState<InferSessionFromClient<ClientOptions> | null>("auth:session", () => null);
const user = useState<InferUserFromClient<ClientOptions> | null>("auth:user", () => null);
const sessionFetching = import.meta.server ? ref(false) : useState("auth:sessionFetching", () => false);
const fetchSession = async () => {
if (sessionFetching.value) {
console.log("already fetching session");
return;
}
sessionFetching.value = true;
const { data } = await client.getSession({
fetchOptions: {
headers,
},
});
session.value = data?.session || null;
user.value = data?.user || null;
sessionFetching.value = false;
return data;
};
if (import.meta.client) {
client.$store.listen("$sessionSignal", async (signal) => {
if (!signal) return;
await fetchSession();
});
}
return {
session,
user,
loggedIn: computed(() => !!session.value),
signIn: client.signIn,
signUp: client.signUp,
async signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) {
const res = await client.signOut();
session.value = null;
user.value = null;
if (redirectTo) {
await navigateTo(redirectTo);
}
return res;
},
options,
fetchSession,
client,
};
}
A few things worth noting:
createAuthClientis constructed on every call. This is intentional for SSR:baseURLmust resolve to the current request origin, and request headers (including cookies) must be forwarded so the server-side fetch can read the session cookie.sessionFetchinguses a plainrefon the server, notuseState. UsinguseStatehere would cause cross-request state leakage since Nitro reuses module scope across requests.$sessionSignalis BetterAuth's internal store event that fires after sign-in, sign-up, and sign-out — subscribing to it keeps session state reactive without manual refetching after every action.signOutclears local state immediately without waiting for a re-fetch, and returns the raw response for callers that need it.- The raw
clientis exposed for lower-level calls likeclient.listAccounts()orclient.linkSocial(...). optionsmergesruntimeConfig.public.authwith fallback defaults viadefu, and can be overridden per-page via route meta.
Plugins
Two plugins manage when fetchSession runs, depending on how the page was delivered.
Server Plugin
export default defineNuxtPlugin({
name: "better-auth-fetch-plugin",
enforce: "pre",
async setup(nuxtApp) {
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
await useAuth().fetchSession();
}
},
});
enforce: 'pre' ensures this runs before any other plugin so session state is populated before components mount on the server. It skips fetching if the response is pre-rendered (static) or coming from a cache — in those cases the serialized payload would be stale, so the client plugin handles the re-fetch instead. It also stamps a custom isCached flag onto the payload so the client plugin can read it.
Client Plugin
export default defineNuxtPlugin(async (nuxtApp) => {
if (!nuxtApp.payload.serverRendered) {
await useAuth().fetchSession();
} else if (nuxtApp.payload.prerenderedAt || nuxtApp.payload.isCached) {
nuxtApp.hook("app:mounted", async () => {
await useAuth().fetchSession();
});
}
});
Three cases:
| Scenario | Action |
|---|---|
| SPA navigation (no SSR) | Fetch immediately — no server-side session was populated |
| Pre-rendered or cached response | Defer to app:mounted — avoids hydration mismatch since server payload has no live session |
| Normal SSR | Do nothing — server plugin already populated state, payload is fresh |
Global Route Middleware
The middleware is global (runs on every navigation) but can be opted out or configured per-page via definePageMeta.
// Disable auth entirely for a page
definePageMeta({ auth: false });
// Redirect logged-in users away (login/signup pages)
definePageMeta({
auth: {
only: "guest",
redirectUserTo: "/user",
},
});
export default defineNuxtRouteMiddleware(async (to) => {
if (to.meta?.auth === false) return;
const { loggedIn, options, fetchSession } = useAuth();
const { only, redirectUserTo, redirectGuestTo } = defu(to.meta?.auth, options);
// Redirect authenticated users away from guest-only pages
if (only === "guest" && loggedIn.value) {
if (to.path === redirectUserTo) return;
return navigateTo(redirectUserTo);
}
// Re-fetch session on every client-side navigation
if (import.meta.client) {
await fetchSession();
}
// Redirect unauthenticated users
if (!loggedIn.value) {
if (to.path === redirectGuestTo) return;
return navigateTo(redirectGuestTo);
}
});
Key points:
defumerges per-page meta with global defaults, so per-page config only needs to specify what differs.fetchSessionis called on every client-side navigation, keeping the session fresh after expiry or sign-out in another tab.- On the server, the server plugin already fetched the session before the middleware runs, so there's no duplicate fetch.
- Infinite redirect guards (
to.path === redirectTo) prevent redirect loops when the target route itself would trigger the same condition.
Server Side (for reference)
The server half is minimal. server/utils/auth.ts exports a lazy singleton serverAuth() configured with the database, secondary KV storage for sessions, plugins (anonymous, admin), and OAuth providers. The catch-all route wires it to Nitro:
export default eventHandler((event) => serverAuth().handler(toWebRequest(event)));
toWebRequest converts an H3 event to a standard Web API Request object that BetterAuth's handler expects. The server Nitro plugin (server/plugins/auth.ts) auto-runs DB migrations in dev mode on startup using getMigrations from better-auth/db.