Skip to Content

Nuxt Quickstart 💚

Add a complete login experience to a Nuxt 3 app. @faable/auth-js drives the Authorization Code flow with PKCE, stores the session, refreshes tokens, and syncs across tabs.

The one thing to get right in Nuxt: the SDK is client-only. It uses window, localStorage, and PKCE, so it can’t run during server-side rendering. You create it in a client plugin (plugins/faable.client.ts — the .client suffix makes Nuxt run it only in the browser) and expose the session through a useState-backed composable so it survives hydration.

This is still the framework-agnostic core pattern — createClient + onAuthStateChange + getSession — wired into Nuxt’s SSR-safe state. The Vue, JavaScript, SvelteKit, and Angular quickstarts are the same three calls in each framework.

You need one package: @faable/auth-js — Nuxt uses the core client directly, no framework helper required.


✅ Prerequisites

In the Faable Dashboard , create a Client for your app and configure:

  • Allowed Callback URLs: http://localhost:3000/callback (add your production URL later).
  • Allowed Logout URLs: http://localhost:3000.
  • Allowed Web Origins: http://localhost:3000.

Note your auth domain (your-domain.auth.faable.link) and Client ID. Browser apps are public clients — no client secret is involved.


🛠️ Step 1: Create the App and Install

npm create nuxt@latest my-app # or: npx nuxi init my-app cd my-app npm install @faable/auth-js

Step 2: Create the Client in a Client Plugin

The SDK touches window and PKCE storage, so it must never run on the server. A plugin named *.client.ts runs only in the browser — exactly where you want it. Create the client there and provide it to the rest of the app via nuxtApp.provide.

// plugins/faable.client.ts import { createClient } from "@faable/auth-js"; export default defineNuxtPlugin(() => { const auth = createClient({ domain: "your-domain.auth.faable.link", clientId: "YOUR_CLIENT_ID", redirectUri: window.location.origin + "/callback", }); return { provide: { auth }, }; });

provide: { auth } exposes the client as useNuxtApp().$auth everywhere in the app. On creation the client recovers an existing session from storage, or — on the callback URL — exchanges the PKCE ?code= for tokens.

Step 3: Wire a Reactive Session Composable

Wrap the client in a useAuth() composable. It holds the session in Nuxt’s useState — SSR-safe shared state that survives hydration — seeds it once with getSession(), and keeps it updated with onAuthStateChange on login, logout, token refresh, or a change in another tab. All of that runs client-side, guarded by import.meta.client.

// composables/useAuth.ts import type { Session } from "@faable/auth-js"; export function useAuth() { const { $auth } = useNuxtApp(); const session = useState<Session | null>("faable-session", () => null); // Only the browser has $auth (client plugin) and window. if (import.meta.client && $auth) { // Keep state in sync with every auth change (login, refresh, logout, other tabs). $auth.onAuthStateChange((_event, next) => { session.value = next; }); // Seed the initial value once (also auto-refreshes an expired session). $auth.getSession().then(({ data }) => { session.value = data.session; }); } return { auth: $auth, session, user: computed(() => session.value?.user ?? null), signIn: () => $auth?.signInWithOauthConnection({}), signOut: () => $auth?.signOut({ returnTo: window.location.origin }), }; }

onAuthStateChange returns { data: { subscription } }; call subscription.unsubscribe() if you ever need to tear the listener down. Here it lives for the app’s lifetime, so we let it run.

Step 4: Login, User, and Logout

Any page or component reads the composable. Because sign-in state only exists in the browser, wrap the auth UI in <ClientOnly> — it renders nothing on the server and mounts the real UI after hydration, so there’s no flash of the wrong state.

<!-- app.vue --> <script setup lang="ts"> const { session, user, signIn, signOut } = useAuth(); </script> <template> <ClientOnly> <button v-if="!session" @click="signIn">Sign in</button> <div v-else> <p>Hello {{ user?.email }}</p> <button @click="signOut">Sign out</button> </div> <template #fallback> <p>Loading…</p> </template> </ClientOnly> </template>
  • signInWithOauthConnection({}) sends the user to your tenant’s Universal Login with every connection you’ve enabled. Target one directly with { connection_id: "connection_..." }.
  • signOut({ returnTo }) clears the local session and the SSO cookie on the auth server. returnTo must be in Allowed Logout URLs.
  • Both methods redirect the browser on success — the promise intentionally never resolves, so don’t put code after the await. The UI updates through the composable’s useState, not a return value.

Step 5: The Callback Page

Add a /callback page that finishes the exchange, then navigates home. Run handleRedirectCallback() in onMounted so it only fires in the browser. It’s idempotent — the client already started the exchange on creation — and returns { error, returnTo }, so deep links survive the login round-trip if you passed returnTo to signInWithOauthConnection({ returnTo }).

<!-- pages/callback.vue --> <script setup lang="ts"> const { $auth } = useNuxtApp(); const message = ref("Signing you in…"); onMounted(async () => { const { error, returnTo } = await $auth.handleRedirectCallback(); if (error) message.value = error.message; else navigateTo(returnTo ?? "/"); }); </script> <template> <p>{{ message }}</p> </template>

Nuxt’s file-based router serves this at /callback automatically — the same path you registered as your Allowed Callback URL. onMounted never runs during SSR, so the SDK stays client-only.

Step 6: Call Your API

The access token lives on the session. Read it fresh before each call — getSession() auto-refreshes an expired session:

// composables/apiFetch.ts export async function apiFetch(path: string) { const { $auth } = useNuxtApp(); const { data, error } = await $auth.getSession(); if (error || !data.session) throw new Error("Not signed in"); return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${data.session.access_token}` }, }); }

If your backend validates the token’s audience, pass your API identifier when creating the client (createClient({ ..., audience: "https://api.myapp.com" })) — and see Validate Access Tokens for the Express middleware on the other side.


❓ FAQ

Why a client plugin instead of a normal plugin?

@faable/auth-js uses window, localStorage, and PKCE, none of which exist during Nuxt’s server-side render. A plugin named faable.client.ts runs only in the browser — the .client suffix is Nuxt’s built-in signal. Create the client there and provide it, and it never executes on the server. Guard any composable logic with import.meta.client for the same reason.

How do I keep the session reactive without it breaking hydration?

Hold it in useState<Session | null>("faable-session", () => null) — Nuxt’s SSR-safe shared state — update it from onAuthStateChange, and seed it once with getSession() (Step 3). Every component that calls useAuth() shares the same state, and wrapping auth UI in <ClientOnly> avoids a server/client mismatch since the real session only exists in the browser.

How do I get the access token in Nuxt?

From the session: const { data } = await $auth.getSession(), then data.session?.access_token. There is no separate getAccessToken()getSession() already refreshes expired tokens before returning. Read it fresh right before each API call, as in Step 6.

How do I send users straight to one provider?

Pass connection_id to signInWithOauthConnection — e.g. signIn: () => $auth.signInWithOauthConnection({ connection_id: "connection_..." }). Find the ID in Dashboard → Auth → Connections. Without it, users pick on the Universal Login screen.

Why doesn’t the sign-in promise resolve?

signInWithOauthConnection and signOut redirect the browser on success, so the page unloads before the promise settles. Let the composable’s useState drive your UI through onAuthStateChange — don’t put post-login logic after the await.


Last updated on

Last updated on