Noma
Astro 5 · SSR endpoints · 2026

Authentication for Astro

Add email/password sign-up, Google sign-in, and protected pages to an Astro 5 site using Noma Project Auth. Keep most routes statically prerendered; only a handful of API endpoints and private pages need the server.

Overview

Static-by-default, server when you need it

Astro's island-first model is a great match for a content site with an authenticated area. Noma runs the auth backend on its servers; your Astro app exposes thin SSR endpoints at /api/auth/*, stores a signed cookie, and reads the current user in middleware so every page can access Astro.locals.user.

If you want self-hosted user tables, Astro pairs well with Better Auth. Use Noma instead when you want the auth backend, verification delivery hooks, and user-owned API keys without standing up a database. The noma-astro skill in nomacms-agent-skills captures the conventions in this guide.

Prerequisites

Project, adapter, providers

  • A Noma project at app.nomacms.com, with a verification policy chosen (strict mode blocks login until verified).
  • An Astro adapter that supports SSR: @astrojs/node, @astrojs/vercel, @astrojs/netlify, or @astrojs/cloudflare with Node compat.
  • For Google sign-in: an OAuth 2.0 Client ID from the Google Cloud Console, allow-listed in your Noma project.

Background: Project Auth overview · Sign up & sign in · Social login.

Install

SDK, env, adapter

npm install @nomacms/js-sdk @astrojs/node
NOMA_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
PUBLIC_GOOGLE_CLIENT_ID=xxxxxxxxxx.apps.googleusercontent.com
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
 
export default defineConfig({
  output: "static",
  adapter: node({ mode: "standalone" }),
});

output: "static" keeps your content pages prerendered. Auth endpoints and private pages opt into server rendering individually with export const prerender = false.

// src/env.d.ts
/// <reference types="astro/client" />
 
declare namespace App {
  interface Locals {
    user: { id: number; uuid: string; email: string; display_name?: string } | null;
  }
}

Extending App.Locals gives every .astro file typed access to Astro.locals.user.

Session cookie

One helper, one cookie

A tiny src/lib/session.ts module wraps the httpOnly cookie and returns an SDK client bound to the signed-in user.

// src/lib/session.ts
import type { AstroCookies } from "astro";
import { createClient, type AuthTokenResponse } from "@nomacms/js-sdk";
 
const COOKIE = "noma_session";
const projectId = import.meta.env.NOMA_PROJECT_ID!;
 
type StoredSession = {
  accessToken: string;
  refreshToken?: string;
  expiresAt?: string;
};
 
export function saveSession(cookies: AstroCookies, res: AuthTokenResponse) {
  if (!res.access_token) return;
  const data: StoredSession = {
    accessToken: res.access_token,
    refreshToken: res.refresh_token,
    expiresAt: res.expires_at,
  };
  cookies.set(COOKIE, JSON.stringify(data), {
    httpOnly: true,
    sameSite: "lax",
    secure: import.meta.env.PROD,
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
}
 
export function clearSession(cookies: AstroCookies) {
  cookies.delete(COOKIE, { path: "/" });
}
 
export function readSession(cookies: AstroCookies): StoredSession | null {
  const raw = cookies.get(COOKIE)?.value;
  if (!raw) return null;
  try {
    return JSON.parse(raw) as StoredSession;
  } catch {
    return null;
  }
}
 
export function getNomaUserClient(cookies: AstroCookies) {
  const session = readSession(cookies);
  if (!session) return null;
  return createClient({
    projectId,
    projectUserAuth: {
      accessToken: session.accessToken,
      refreshToken: session.refreshToken,
      autoRefresh: true,
    },
  });
}
Middleware

Attach user to every request

Middleware runs before each route. It loads Astro.locals.user from the cookie and redirects protected paths to the sign-in page when the session is missing.

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { getNomaUserClient, clearSession } from "@/lib/session";
 
const PROTECTED = [/^\/dashboard/, /^\/account/];
 
export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = null;
 
  const noma = getNomaUserClient(context.cookies);
  if (noma) {
    try {
      context.locals.user = (await noma.me()) as App.Locals["user"];
    } catch {
      clearSession(context.cookies);
    }
  }
 
  const needsAuth = PROTECTED.some((re) => re.test(context.url.pathname));
  if (needsAuth && !context.locals.user) {
    return context.redirect("/sign-in");
  }
  return next();
});
Sign up + sign in

Form-post to SSR endpoints

// src/pages/api/auth/signup.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { createClient } from "@nomacms/js-sdk";
import { saveSession } from "@/lib/session";
 
const noma = createClient({ projectId: import.meta.env.NOMA_PROJECT_ID! });
 
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
  const form = await request.formData();
  const result = await noma.signUp({
    email: String(form.get("email")),
    password: String(form.get("password")),
    display_name: String(form.get("name") ?? "") || undefined,
  });
 
  if (result.verification_required) {
    return redirect(`/verify?token=${result.verification_token}`);
  }
  saveSession(cookies, result);
  return redirect("/dashboard");
};
// src/pages/api/auth/signin.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { createClient } from "@nomacms/js-sdk";
import { saveSession } from "@/lib/session";
 
const noma = createClient({ projectId: import.meta.env.NOMA_PROJECT_ID! });
 
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
  const form = await request.formData();
  const result = await noma.signInWithPassword({
    email: String(form.get("email")),
    password: String(form.get("password")),
  });
  saveSession(cookies, result);
  return redirect("/dashboard");
};
---
// src/pages/sign-in.astro
---
<form method="post" action="/api/auth/signin" class="space-y-3">
  <input name="email" type="email" required placeholder="Email" />
  <input name="password" type="password" required placeholder="Password" />
  <button type="submit">Sign in</button>
</form>
---
// src/pages/dashboard.astro
export const prerender = false;
const { user } = Astro.locals;
---
 
<h1>Welcome, {user?.display_name ?? user?.email}</h1>

Email verification: when the project enforces it, the signup endpoint receives a verification_token. Deliver the link (your own email, SMS, or in-app channel) and confirm via noma.confirmVerificationEmail({ token }).

Social login

"Sign in with Google" with zero client JS

Google Identity Services can POST the ID token directly to your endpoint. No onclick handlers or fetch calls required. data-login_uri tells GIS where to send the credential; the endpoint exchanges it with Noma and redirects.

// src/pages/api/auth/social/google.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { createClient } from "@nomacms/js-sdk";
import { saveSession } from "@/lib/session";
 
const noma = createClient({ projectId: import.meta.env.NOMA_PROJECT_ID! });
 
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
  const form = await request.formData();
  const id_token = String(form.get("credential") ?? "");
  const result = await noma.signInWithSocial({ provider: "google", id_token });
  saveSession(cookies, result);
  return redirect("/dashboard");
};
---
// src/components/GoogleButton.astro
const clientId = import.meta.env.PUBLIC_GOOGLE_CLIENT_ID;
---
<div
  id="g_id_onload"
  data-client_id={clientId}
  data-login_uri="/api/auth/social/google"
  data-ux_mode="redirect"
></div>
<div class="g_id_signin" data-type="standard" data-theme="outline"></div>
<script is:inline src="https://accounts.google.com/gsi/client" async defer></script>

Drop <GoogleButton /> on your sign-in page. Done. Because the button submits with data-ux_mode="redirect", the entire flow works without shipping any extra JavaScript from your app.

Sign out

Revoke first, then clear the cookie

// src/pages/api/auth/signout.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { getNomaUserClient, clearSession } from "@/lib/session";
 
export const POST: APIRoute = async ({ cookies, redirect }) => {
  const noma = getNomaUserClient(cookies);
  try {
    if (noma) await noma.signOut();
  } finally {
    clearSession(cookies);
  }
  return redirect("/");
};

Call this endpoint from a simple <form method="post" action="/api/auth/signout"> button. signOutAll() is available when you want every device signed out at once.

Checklist

Before you ship

  • Only the Google client id uses the PUBLIC_ prefix. The Noma project id and any keys stay server-only.
  • Cookies are httpOnly, Secure, SameSite=Lax.
  • Sign-out calls noma.signOut() before clearSession().
  • Protected routes are marked export const prerender = false and covered by the middleware matcher.
  • Content pages remain prerendered; personalization moves into server islands so the CDN still caches article HTML.
  • Handle 429 (sign-in lockout, verification cooldown) with the retry_after hint from Noma.

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

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.