Noma
Nuxt 3 & 4 · Nitro · 2026

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.

Overview

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.

Prerequisites

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.

Install

SDK, env, runtimeConfig

npm install @nomacms/js-sdk
NUXT_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.

Session cookie

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,
    },
  });
}
Sign up + sign in

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.

Vue pages

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.

Social login

"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.

Checklist

Before you ship

  • No auth tokens under runtimeConfig.public. Only the Google client_id is 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 429 responses from Noma (sign-in lockout, verification cooldown) with the retry_after hint.
  • Pick a Node-compatible Nitro preset for deployment.

Project Auth docs · Sessions · SDK setup · CMS for Nuxt

Now available

Start building with Noma

Create a free account, spin up a project, and ship structured content with our API, SDK, and AI tools.