Authentication for Nuxt
Add email/password sign-up, Google sign-in, and session-protected Vue pages to a Nuxt 3 or 4 app using Noma Project Auth. The whole integration fits in a handful of Nitro routes and one server util.
Nitro for auth, Vue for UI
Noma Project Auth runs on Noma's servers. Your Nuxt app just brokers credentials and keeps a single httpOnly cookie. Nitro routes under /api/auth/* call @nomacms/js-sdk; Vue pages use useFetch/$fetch and never see raw tokens.
If you already use nuxt-auth-utils for OAuth UX helpers, you can replace its in-cookie session with a Noma-backed one. Everything in this guide is additive. The noma-nuxt skill in nomacms-agent-skills captures the conventions below.
Project, settings, providers
- A Noma project at app.nomacms.com with project auth enabled and an email verification policy chosen.
- For Google sign-in: an OAuth 2.0 Client ID from the Google Cloud Console, added to the allowed client IDs on your Noma project.
- A Nitro-capable deployment target (Node, Vercel, Netlify, Cloudflare with Node compat).
Background: Project Auth overview · Sign up & sign in · Social login.
SDK, env, runtimeConfig
npm install @nomacms/js-sdkNUXT_NOMA_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
NUXT_PUBLIC_GOOGLE_CLIENT_ID=xxxxxxxxxx.apps.googleusercontent.com// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
nomaProjectId: "",
public: {
googleClientId: "",
},
},
});Nuxt maps NUXT_* env vars into runtimeConfig automatically. The project id is private; only the Google client id goes under public.
One Nitro util does all the work
A single server/utils/session.ts file handles reading, writing, clearing, and rebuilding an authenticated SDK client from the cookie on every request.
// server/utils/session.ts
import type { H3Event } from "h3";
import { createClient, type AuthTokenResponse } from "@nomacms/js-sdk";
const COOKIE = "noma_session";
type StoredSession = {
accessToken: string;
refreshToken?: string;
expiresAt?: string;
};
export function saveSession(event: H3Event, res: AuthTokenResponse) {
if (!res.access_token) return;
const data: StoredSession = {
accessToken: res.access_token,
refreshToken: res.refresh_token,
expiresAt: res.expires_at,
};
setCookie(event, COOKIE, JSON.stringify(data), {
httpOnly: true,
sameSite: "lax",
secure: !process.dev,
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
}
export function clearSession(event: H3Event) {
deleteCookie(event, COOKIE, { path: "/" });
}
export function readSession(event: H3Event): StoredSession | null {
const raw = getCookie(event, COOKIE);
if (!raw) return null;
try {
return JSON.parse(raw) as StoredSession;
} catch {
return null;
}
}
/** An SDK client for the currently signed-in user, or null. */
export function useNomaUserClient(event: H3Event) {
const config = useRuntimeConfig(event);
const session = readSession(event);
if (!session) return null;
return createClient({
projectId: config.nomaProjectId,
projectUserAuth: {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
autoRefresh: true,
},
});
}Nitro routes backed by the SDK
// server/api/auth/signup.post.ts
import { createClient } from "@nomacms/js-sdk";
export default defineEventHandler(async (event) => {
const body = await readBody<{ email: string; password: string; display_name?: string }>(event);
const config = useRuntimeConfig(event);
const noma = createClient({ projectId: config.nomaProjectId });
const result = await noma.signUp(body);
if (result.verification_required) {
return { verification_required: true, verification_token: result.verification_token };
}
saveSession(event, result);
return { user: result.user };
});// server/api/auth/signin.post.ts
import { createClient } from "@nomacms/js-sdk";
export default defineEventHandler(async (event) => {
const body = await readBody<{ email: string; password: string }>(event);
const config = useRuntimeConfig(event);
const noma = createClient({ projectId: config.nomaProjectId });
const result = await noma.signInWithPassword(body);
saveSession(event, result);
return { user: result.user };
});// server/api/auth/me.get.ts
export default defineEventHandler(async (event) => {
const noma = useNomaUserClient(event);
if (!noma) return { user: null };
const user = await noma.me();
return { user };
});// server/api/auth/signout.post.ts
export default defineEventHandler(async (event) => {
const noma = useNomaUserClient(event);
try {
if (noma) await noma.signOut();
} finally {
clearSession(event);
}
return { ok: true };
});Email verification: when the project requires it, the signup route returns verification_required: true plus a verification_token. Your app delivers the link (SMTP, Resend, SMS, in-app notification) and calls confirmVerificationEmail({ token }) on submission.
Forms and a route middleware
<!-- pages/sign-in.vue -->
<script setup lang="ts">
const form = reactive({ email: "", password: "" });
async function submit() {
await $fetch("/api/auth/signin", { method: "POST", body: form });
await navigateTo("/dashboard");
}
</script>
<template>
<form @submit.prevent="submit" class="space-y-3">
<input v-model="form.email" type="email" required placeholder="Email" />
<input v-model="form.password" type="password" required placeholder="Password" />
<button type="submit">Sign in</button>
</form>
</template><!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({ middleware: "auth" });
const { data } = await useFetch("/api/auth/me");
</script>
<template>
<h1 v-if="data?.user">Welcome, {{ data.user.display_name ?? data.user.email }}</h1>
</template>// middleware/auth.ts
export default defineNuxtRouteMiddleware(async () => {
const { data } = await useFetch("/api/auth/me");
if (!data.value?.user) return navigateTo("/sign-in");
});The middleware hits /api/auth/me, which is cheap because the cookie travels with every SSR request. Client-side navigations reuse the hydrated payload.
"Sign in with Google" in two files
Google Identity Services renders a button in Vue, hands back an ID token, and a single Nitro route exchanges it for a Noma session cookie.
// server/api/auth/social/google.post.ts
import { createClient } from "@nomacms/js-sdk";
export default defineEventHandler(async (event) => {
const { id_token } = await readBody<{ id_token: string }>(event);
const config = useRuntimeConfig(event);
const noma = createClient({ projectId: config.nomaProjectId });
const result = await noma.signInWithSocial({ provider: "google", id_token });
saveSession(event, result);
return { user: result.user };
});<!-- components/GoogleButton.vue -->
<script setup lang="ts">
const config = useRuntimeConfig();
const buttonEl = ref<HTMLDivElement | null>(null);
onMounted(() => {
useHead({ script: [{ src: "https://accounts.google.com/gsi/client", async: true, defer: true }] });
const init = () => {
if (!window.google || !buttonEl.value) return;
window.google.accounts.id.initialize({
client_id: config.public.googleClientId,
callback: async (resp: { credential: string }) => {
await $fetch("/api/auth/social/google", {
method: "POST",
body: { id_token: resp.credential },
});
await navigateTo("/dashboard");
},
});
window.google.accounts.id.renderButton(buttonEl.value, { theme: "outline" });
};
const t = setInterval(() => (window.google ? (clearInterval(t), init()) : null), 100);
});
</script>
<template><div ref="buttonEl" /></template>Drop <GoogleButton /> on your sign-in page and you're done. To add another OIDC provider, copy the route, swap the provider string, and pass the matching id_token.
Before you ship
- No auth tokens under
runtimeConfig.public. Only the Googleclient_idis public. - Cookies are
httpOnly,Secure,SameSite=Lax. - Sign-out calls
noma.signOut()before clearing the cookie. - Protected pages use
definePageMeta({ middleware: "auth" })or a global middleware for sitewide gates. - Handle
429responses from Noma (sign-in lockout, verification cooldown) with theretry_afterhint. - Pick a Node-compatible Nitro preset for deployment.