Blog
How to ship an Astro site with a headless CMS and built-in user auth
Astro is a great fit for content-heavy sites: fast pages, little client JavaScript, and clean static generation. The usual stack adds a headless CMS for content and a separate auth product for logged-in users. That means two vendors, two docs, and two billing lines before you ship anything.
NomaCMS is a headless CMS with auth built in. You model structured content, deliver it over a REST API and @nomacms/js-sdk, and run project user auth (signup, login, sessions) in the same platform. This guide walks through an Astro blog plus a simple members area, all on one backend.
What you will build
- A Posts collection in NomaCMS
- A static blog index and post pages in Astro
- An Astro endpoint that signs users in with project auth
- A members-only page that checks the session on the server
You need a NomaCMS account (app.nomacms.com) and a fresh Astro project.
1. Create a project and model posts
- Sign up at app.nomacms.com and click New Project.
- Create a collection 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 one with
readat minimum; copy it immediately)
2. Scaffold Astro and install the SDK
npm create astro@latest my-astro-blog
cd my-astro-blog
npm install @nomacms/js-sdkAdd .env:
NOMA_PROJECT_ID=your-project-uuid
NOMA_API_KEY=your-personal-access-tokenDo not prefix these with PUBLIC_. Astro only exposes PUBLIC_ variables to the browser. Your API key must stay on the server.
3. Add a server-only client helper
Create src/lib/nomacms.ts:
import { createClient } from "@nomacms/js-sdk"
export function getNomaCMSServerClient() {
const projectId = import.meta.env.NOMA_PROJECT_ID
const apiKey = import.meta.env.NOMA_API_KEY
if (!projectId || !apiKey) {
throw new Error("Missing NOMA_PROJECT_ID or NOMA_API_KEY")
}
return createClient({
projectId,
apiKey,
})
}4. Build the blog index
Create src/pages/posts/index.astro:
---
import { getNomaCMSServerClient } from "../../lib/nomacms"
const noma = getNomaCMSServerClient()
const result = await noma.content.list("posts", {
state: "published",
paginate: 24,
sort: "created_at:desc",
})
const posts = "data" in result ? result.data : result
---
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/posts/${post.uuid}/`}>
{String(post.fields?.title ?? post.uuid)}
</a>
</li>
))}
</ul>5. Generate post pages with getStaticPaths
Create src/pages/posts/[id].astro:
---
import { getNomaCMSServerClient } from "../../lib/nomacms"
export async function getStaticPaths() {
const noma = getNomaCMSServerClient()
const result = await noma.content.list("posts", {
state: "published",
paginate: 500,
})
const posts = "data" in result ? result.data : result
return posts.map((post) => ({
params: { id: post.uuid },
}))
}
const { id } = Astro.params
const noma = getNomaCMSServerClient()
const post = await noma.content.get("posts", id!, { state: "published" })
---
<article>
<h1>{String(post.fields?.title ?? "")}</h1>
<div set:html={String(post.fields?.body ?? "")} />
</article>Rich text fields return HTML or markdown depending on the field's outputFormat setting. If yours is markdown, render it with a markdown parser instead of set:html.
Rebuild your site when you publish new posts, or switch to server rendering if you need updates without a full deploy.
6. Add project auth on the server
Project auth is for your app's end users, not CMS editors. It runs on the server in Astro, not in client-side scripts with your API key.
Create a second helper for auth routes in src/lib/nomacms-auth.ts:
import { createClient } from "@nomacms/js-sdk"
export function getNomaAuthClient(accessToken?: string, refreshToken?: string) {
const projectId = import.meta.env.NOMA_PROJECT_ID
if (!projectId) {
throw new Error("Missing NOMA_PROJECT_ID")
}
return createClient({
projectId,
projectUserAuth: {
accessToken,
refreshToken,
autoRefresh: true,
},
})
}Add a login endpoint at src/pages/api/auth/login.ts:
import type { APIRoute } from "astro"
import { getNomaAuthClient } from "../../../lib/nomacms-auth"
export const POST: APIRoute = async ({ request, cookies }) => {
const body = await request.json()
const noma = getNomaAuthClient()
const session = await noma.signInWithPassword({
email: body.email,
password: body.password,
})
cookies.set("noma_access", session.access_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
})
if (session.refresh_token) {
cookies.set("noma_refresh", session.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
})
}
return new Response(JSON.stringify({ user: session.user }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}7. Protect a members page
Create src/pages/members.astro:
---
import { getNomaAuthClient } from "../lib/nomacms-auth"
const accessToken = Astro.cookies.get("noma_access")?.value
const refreshToken = Astro.cookies.get("noma_refresh")?.value
if (!accessToken) {
return Astro.redirect("/login")
}
const noma = getNomaAuthClient(accessToken, refreshToken)
const { user } = await noma.me() as { user: { display_name?: string; email: string } }
---
<h1>Members area</h1>
<p>Welcome, {user.display_name ?? user.email}.</p>This keeps tokens in HTTP-only cookies and checks the session before rendering.
8. Optional: wire up AI editors
If you use Cursor or Claude Code, install the MCP server and agent skills so your editor can manage schema and entries while you build the Astro frontend. Dashboard AI and MCP both sit on the same project API.
What to explore next
- Auth for Astro for more auth patterns
- Locales if you need multilingual content
- Webhooks to trigger rebuilds on publish
Every NomaCMS plan includes a 7-day free trial.