BetterAuth in Nuxt

// published 23 Dec 2024 · 10 min read

[nuxt][vue][auth][better-auth]

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.

app/composables/auth.ts
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:

  • createAuthClient is constructed on every call. This is intentional for SSR: baseURL must resolve to the current request origin, and request headers (including cookies) must be forwarded so the server-side fetch can read the session cookie.
  • sessionFetching uses a plain ref on the server, not useState. Using useState here would cause cross-request state leakage since Nitro reuses module scope across requests.
  • $sessionSignal is 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.
  • signOut clears local state immediately without waiting for a re-fetch, and returns the raw response for callers that need it.
  • The raw client is exposed for lower-level calls like client.listAccounts() or client.linkSocial(...).
  • options merges runtimeConfig.public.auth with fallback defaults via defu, 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

app/plugins/auth.server.ts
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

app/plugins/auth.client.ts
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:

ScenarioAction
SPA navigation (no SSR)Fetch immediately — no server-side session was populated
Pre-rendered or cached responseDefer to app:mounted — avoids hydration mismatch since server payload has no live session
Normal SSRDo 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.

app/pages/index.vue
// Disable auth entirely for a page
definePageMeta({ auth: false });

// Redirect logged-in users away (login/signup pages)
definePageMeta({
  auth: {
    only: "guest",
    redirectUserTo: "/user",
  },
});
app/middleware/auth.global.ts
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:

  • defu merges per-page meta with global defaults, so per-page config only needs to specify what differs.
  • fetchSession is 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:

server/api/[...auth].ts
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.

Source