NomaCMS

Blog

How to ship an Astro site with a headless CMS and built-in user auth

Raşit Apalak
Raşit ApalakJuly 10, 2026

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

  1. Sign up at app.nomacms.com and click New Project.
  2. Create a collection called Posts with slug posts.
  3. Add fields:
    • title (Text)
    • body (Rich Text)
  4. 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 read at 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-sdk

Add .env:

NOMA_PROJECT_ID=your-project-uuid NOMA_API_KEY=your-personal-access-token

Do 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

Every NomaCMS plan includes a 7-day free trial.

Learn more