Blog
How to add end-user auth to a Nuxt site with a headless CMS
Most Nuxt CMS guides pick a tool for modules, visual editing, or preview. That gets you published pages. It does not answer the next question many apps hit: where do end users sign up and log in?
If you stitch a headless CMS to Auth0, Clerk, or a custom JWT layer, you own two products, two docs, and two billing lines. This guide shows a different shape: content and project auth in one hosted platform, wired into Nuxt on the server with @nomacms/js-sdk.
You will build a small Nuxt app that lists posts from NomaCMS and protects a members page with end-user sessions. No second auth vendor.
What you will build
- A Posts collection in NomaCMS
- A Nuxt blog index that fetches published entries on the server
- Server routes for sign up and sign in with project auth
- A members page that checks the session before rendering
You need a NomaCMS account (app.nomacms.com) and a Nuxt 3 project (or a fresh one from nuxi).
Why this matters for Nuxt
Nuxt apps often start as content sites and grow into member areas: customer portals, gated articles, or simple SaaS frontends. A content-only CMS leaves login to someone else. A CMS with project auth keeps signup, login, refresh, logout, profile, and sessions next to your collections and fields.
In NomaCMS:
- Personal access tokens (API keys from User Settings → API Keys) are for you (developers and editors) calling collections and entries
- Project auth is for your product’s end users, isolated per project
Keep both on the server in Nuxt. Never put a personal access token in runtimeConfig.public or the browser.
1. Create a project and model posts
- Sign up at app.nomacms.com and click New Project.
- Inside the project, click New Collection and create one called Posts with slug
posts. - Add fields:
- title (Text)
- body (Rich Text)
- Create one or two entries, save them, and publish when ready.
Copy two values from the dashboard:
- Project ID from the project home page or Project Settings → API Access
- API key from User Settings → API Keys (create the key while that project’s workspace is selected; copy it immediately)
2. Scaffold Nuxt and install the SDK
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app
npm install @nomacms/js-sdk3. Add runtime config (server only)
Define secrets in nuxt.config.ts under runtimeConfig without putting them in public:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
nomaProjectId: "",
nomaApiKey: "",
},
})Set values in .env:
NUXT_NOMA_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
NUXT_NOMA_API_KEY=noma_...Nuxt maps NUXT_* keys to runtimeConfig automatically. Only runtimeConfig.public reaches the browser, so keep these keys private.
4. Add a server-only CMS client
// server/utils/noma.ts
import { createClient } from "@nomacms/js-sdk"
export function useNomaCMSServerClient() {
const config = useRuntimeConfig()
if (!config.nomaProjectId || !config.nomaApiKey) {
throw new Error("Missing nomaProjectId or nomaApiKey in runtime config")
}
return createClient({
projectId: config.nomaProjectId as string,
apiKey: config.nomaApiKey as string,
})
}5. Fetch posts through a Nitro route
// server/api/posts.get.ts
export default defineEventHandler(async () => {
const noma = useNomaCMSServerClient()
return noma.content.list("posts", {
state: "published",
paginate: 12,
sort: "created_at:desc",
})
})<!-- pages/posts/index.vue -->
<script setup lang="ts">
const { data, error } = await useFetch("/api/posts")
</script>
<template>
<div>
<h1>Posts</h1>
<p v-if="error">Could not load posts.</p>
<ul v-else-if="data?.data">
<li v-for="post in data.data" :key="post.uuid">
<NuxtLink :to="`/posts/${post.uuid}`">
{{ post.fields?.title }}
</NuxtLink>
</li>
</ul>
</div>
</template>Optional detail page: add server/api/posts/[id].get.ts with noma.content.get("posts", id, { state: "published" }) and a matching pages/posts/[id].vue. Keep every createClient call under server/ so keys never ship to the client.
6. Add project auth helpers
Project auth does not use your Content API key for login. Sign-up and sign-in only need the Project ID. Create a separate helper for auth routes:
// server/utils/noma-auth.ts
import { createClient } from "@nomacms/js-sdk"
export function useNomaAuthClient(accessToken?: string, refreshToken?: string) {
const config = useRuntimeConfig()
const projectId = config.nomaProjectId as string | undefined
if (!projectId) {
throw new Error("Missing nomaProjectId in runtime config")
}
return createClient({
projectId,
projectUserAuth: {
accessToken,
refreshToken,
autoRefresh: true,
},
})
}7. Sign up and sign in on the server
Store access and refresh tokens in HTTP-only cookies. Do the token exchange in Nitro routes, not in a client-only composable with secrets.
Sign up
// server/api/auth/signup.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{
email?: string
password?: string
display_name?: string
}>(event)
if (!body.email || !body.password) {
throw createError({ statusCode: 400, statusMessage: "Email and password required" })
}
const noma = useNomaAuthClient()
// 202 verification responses return a body without access_token (SDK treats 2xx as success).
// 4xx validation errors throw from the SDK.
let session
try {
session = await noma.signUp({
email: body.email,
password: body.password,
display_name: body.display_name,
})
} catch (err: unknown) {
const status = (err as { statusCode?: number })?.statusCode ?? 500
const message =
(err as { message?: string })?.message ?? "Sign-up failed"
throw createError({ statusCode: status, statusMessage: message })
}
// Some projects require email verification before tokens are issued (HTTP 202).
if (!session.access_token) {
return {
verification_required: session.verification_required ?? true,
message: session.message ?? "Email verification required.",
user: session.user,
}
}
setCookie(event, "noma_access", session.access_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
})
if (session.refresh_token) {
setCookie(event, "noma_refresh", session.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
})
}
return { user: session.user }
})Sign in
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; password?: string }>(event)
if (!body.email || !body.password) {
throw createError({ statusCode: 400, statusMessage: "Email and password required" })
}
const noma = useNomaAuthClient()
// Invalid credentials (401), verification required (403), and lockouts (429) throw from the SDK.
let session
try {
session = await noma.signInWithPassword({
email: body.email,
password: body.password,
})
} catch (err: unknown) {
const status = (err as { statusCode?: number })?.statusCode ?? 500
const message =
(err as { message?: string })?.message ?? "Sign-in failed"
throw createError({ statusCode: status, statusMessage: message })
}
if (!session.access_token) {
throw createError({
statusCode: 403,
statusMessage: session.message ?? "Sign-in did not return a session",
})
}
setCookie(event, "noma_access", session.access_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
})
if (session.refresh_token) {
setCookie(event, "noma_refresh", session.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
})
}
return { user: session.user }
})A minimal login page can POST JSON to /api/auth/login with $fetch, then navigate to /members.
Password rules from the API: email required, password min 8 characters. Optional display_name on sign-up.
Cookie note: autoRefresh refreshes tokens inside the SDK client. It does not write new tokens back to your cookies unless you add a tokenStorage adapter (or re-set cookies after refreshSession()). For this short guide, sessions last until the access token expires or the user signs in again.
8. Protect a members page
Add a small “who am I” route that reads cookies and calls me():
// server/api/auth/me.get.ts
export default defineEventHandler(async (event) => {
const accessToken = getCookie(event, "noma_access")
const refreshToken = getCookie(event, "noma_refresh")
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: "Not signed in" })
}
const noma = useNomaAuthClient(accessToken, refreshToken)
const profile = (await noma.me()) as {
user: { email: string; display_name?: string | null }
}
return profile
})<!-- pages/members.vue -->
<script setup lang="ts">
const { data, error } = await useFetch("/api/auth/me")
if (error.value) {
await navigateTo("/login")
}
</script>
<template>
<div v-if="data?.user">
<h1>Members area</h1>
<p>Welcome, {{ data.user.display_name || data.user.email }}.</p>
</div>
</template>Tokens stay in HTTP-only cookies. The page never sees your Content API key.
9. Optional next steps
- Email verification: depending on project settings, sign-up may return
verification_required(HTTP 202) instead of tokens. Use the sign-up docs for resend and confirm flows. - Social login: exchange a provider OIDC
id_token(Google today) withsignInWithSocialon the server. See Social Login. - Logout: build a client with
projectUserAuth: { accessToken }from the cookie, callnoma.signOut()(no arguments), then clear thenoma_accessandnoma_refreshcookies. - AI editors: install the MCP server and agent skills so Cursor or Claude Code can help with schema while you build the Nuxt frontend.
When this stack fits
This pattern fits when you want:
- Nuxt (or another JS frontend) talking to a hosted content API
- End-user signup and login without Clerk/Auth0 glue
- Server-first secrets (Nitro routes, no public API keys)
It is less ideal if you need the CMS schema as TypeScript inside your repo (code-first CMS-in-app), or if you only need a marketing site with no members.
Learn more
Every NomaCMS plan includes a 7-day free trial.