Noma
Next.js · App Router · 2026

Authentication for Next.js

Add end-user sign-up, sign-in, Google social login, and session-protected routes to a Next.js 15 / 16 App Router app using Noma Project Auth and the official JavaScript SDK. No extra auth server, no third-party provider to glue together.

Overview

What you get in ~20 lines of glue

Noma ships a complete auth backend for your app's end users (email/password, OIDC social login, email verification, refresh rotation, user-owned API keys) behind the same project API you use for content. This guide wires it into a Next.js App Router app with Server Actions, a single httpOnly cookie, and middleware. No database tables, no NextAuth config file, no third-party auth vendor.

Prefer Auth.js v5 / NextAuth? Noma works there too as a Credentials provider. The template lives in the noma-nextjs-auth skill. The rest of this page sticks to the minimal, SDK-first approach.

Prerequisites

Project, settings, providers

  • Create a project at app.nomacms.com and copy the project UUID.
  • Decide your email verification policy in project settings (strict mode blocks login until verified; lenient mode issues a session and marks email_verified_at later).
  • To use Google sign-in, create an OAuth 2.0 Client ID in the Google Cloud Console and add it to the allowed client IDs for your Noma project. The Noma backend validates the token's aud against that list.

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

Install

SDK and environment

npm install @nomacms/js-sdk

Put secrets in .env.local. The project id is safe to expose on the browser (it's just a project identifier), but your Noma API key is not used here. Auth routes do not need it.

NOMA_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Used by the Server Action to exchange the Google credential with Noma.
NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxxxxxxxxx.apps.googleusercontent.com
Session cookie

One helper, one httpOnly cookie

We keep the access token, refresh token, and expiry in a single httpOnly cookie. All reads happen on the server; client components never see the raw tokens.

// lib/session.ts
import { cookies } from "next/headers";
import { createClient, type AuthTokenResponse } from "@nomacms/js-sdk";
 
const COOKIE_NAME = "noma_session";
const projectId = process.env.NOMA_PROJECT_ID!;
 
type StoredSession = {
  accessToken: string;
  refreshToken?: string;
  expiresAt?: string;
};
 
export async function saveSession(res: AuthTokenResponse) {
  if (!res.access_token) return;
  const data: StoredSession = {
    accessToken: res.access_token,
    refreshToken: res.refresh_token,
    expiresAt: res.expires_at,
  };
  const jar = await cookies();
  jar.set(COOKIE_NAME, JSON.stringify(data), {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    path: "/",
    // Align with refresh token TTL; rotate on refresh.
    maxAge: 60 * 60 * 24 * 30,
  });
}
 
export async function clearSession() {
  const jar = await cookies();
  jar.delete(COOKIE_NAME);
}
 
export async function getSession(): Promise<StoredSession | null> {
  const raw = (await cookies()).get(COOKIE_NAME)?.value;
  if (!raw) return null;
  try {
    return JSON.parse(raw) as StoredSession;
  } catch {
    return null;
  }
}
 
/** Authenticated Noma client bound to the current user's session. */
export async function getNomaUserClient() {
  const session = await getSession();
  if (!session) return null;
  return createClient({
    projectId,
    projectUserAuth: {
      accessToken: session.accessToken,
      refreshToken: session.refreshToken,
      autoRefresh: true,
    },
  });
}

The helper getNomaUserClient() returns an SDK client bound to the signed-in user. With autoRefresh: true, the SDK retries once after refreshing when a request returns 401.

Sign up + sign in

Server Actions and plain forms

Two Server Actions cover the happy path. The SDK already validates the responses and returns typed AuthTokenResponse objects.

// app/(auth)/actions.ts
"use server";
import { createClient } from "@nomacms/js-sdk";
import { redirect } from "next/navigation";
import { saveSession } from "@/lib/session";
 
const noma = createClient({ projectId: process.env.NOMA_PROJECT_ID! });
 
export async function signUpAction(formData: FormData) {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");
  const display_name = String(formData.get("name") ?? "").trim() || undefined;
 
  const result = await noma.signUp({ email, password, display_name });
 
  if (result.verification_required) {
    // The API returned a verification_token. Deliver the link/code from your app.
    redirect(`/verify?token=${result.verification_token}`);
  }
 
  await saveSession(result);
  redirect("/dashboard");
}
 
export async function signInAction(formData: FormData) {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");
  const result = await noma.signInWithPassword({ email, password });
  await saveSession(result);
  redirect("/dashboard");
}
// app/(auth)/sign-up/page.tsx
import { signUpAction } from "../actions";
 
export default function SignUpPage() {
  return (
    <form action={signUpAction} className="space-y-3">
      <input name="name" placeholder="Display name" />
      <input name="email" type="email" required placeholder="Email" />
      <input name="password" type="password" required minLength={8} placeholder="Password" />
      <button type="submit">Create account</button>
    </form>
  );
}
// app/(auth)/sign-in/page.tsx
import { signInAction } from "../actions";
 
export default function SignInPage() {
  return (
    <form action={signInAction} className="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>
  );
}

Email verification: when the project requires it, signUp returns verification_required: true and a verification_token that your app delivers to the user (email, SMS, deep link). On completion, call noma.confirmVerificationEmail({ token }).

Protected routes

Middleware and Server Components

Middleware runs at the edge and is the cheapest place to gate private routes. Do a cookie-presence check here; the actual token is validated by the SDK when the page calls Noma.

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
 
export const config = { matcher: ["/dashboard/:path*", "/account/:path*"] };
 
export function middleware(req: NextRequest) {
  const hasSession = req.cookies.has("noma_session");
  if (!hasSession) {
    const url = req.nextUrl.clone();
    url.pathname = "/sign-in";
    url.searchParams.set("redirect", req.nextUrl.pathname);
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}
// app/dashboard/page.tsx
import { getNomaUserClient } from "@/lib/session";
 
export default async function Dashboard() {
  const noma = await getNomaUserClient();
  if (!noma) return <p>Not signed in.</p>;
 
  const user = await noma.me();
  return <h1>Welcome, {String(user?.display_name ?? user?.email ?? "")}</h1>;
}
Social login

"Sign in with Google" in two files

The simplest, production-ready Google login today: Google Identity Services renders a button, returns a short-lived ID token, and a Server Action exchanges it for a Noma session. No OAuth callback URL, no PKCE code to store.

// app/(auth)/social-action.ts
"use server";
import { createClient } from "@nomacms/js-sdk";
import { redirect } from "next/navigation";
import { saveSession } from "@/lib/session";
 
const noma = createClient({ projectId: process.env.NOMA_PROJECT_ID! });
 
export async function socialLoginAction(formData: FormData) {
  const id_token = String(formData.get("id_token") ?? "");
  const result = await noma.signInWithSocial({ provider: "google", id_token });
  await saveSession(result);
  redirect("/dashboard");
}
// components/google-button.tsx (client component)
"use client";
import Script from "next/script";
import { useEffect, useRef } from "react";
import { socialLoginAction } from "@/app/(auth)/social-action";
 
declare global {
  interface Window {
    google?: { accounts: { id: { initialize: Function; renderButton: Function } } };
  }
}
 
export function GoogleButton() {
  const ref = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    if (!window.google || !ref.current) return;
    window.google.accounts.id.initialize({
      client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
      callback: async (resp: { credential: string }) => {
        const fd = new FormData();
        fd.set("id_token", resp.credential);
        await socialLoginAction(fd);
      },
    });
    window.google.accounts.id.renderButton(ref.current, { theme: "outline" });
  }, []);
 
  return (
    <>
      <Script src="https://accounts.google.com/gsi/client" strategy="afterInteractive" />
      <div ref={ref} />
    </>
  );
}

Drop <GoogleButton /> anywhere on your sign-in page. That's the whole social login. The same signInWithSocial call works for any OIDC provider Noma supports. Pass a different provider and the matching id_token.

Sign out

Revoke the Noma session, then clear the cookie

Clearing the cookie alone is not a complete logout. The refresh session stays active on Noma until you call signOut(). Always revoke first.

// app/(auth)/signout-action.ts
"use server";
import { redirect } from "next/navigation";
import { clearSession, getNomaUserClient } from "@/lib/session";
 
export async function signOutAction() {
  const noma = await getNomaUserClient();
  // 1) Revoke the refresh session on Noma first, then 2) clear the cookie.
  try {
    if (noma) await noma.signOut();
  } finally {
    await clearSession();
  }
  redirect("/");
}

Use signOutAll() instead when you want every device/session for that user invalidated (for example after a password change).

Checklist

Before you ship

  • No credentials prefixed with NEXT_PUBLIC_ except the Google client_id (it's public by design).
  • Cookies are httpOnly, Secure, SameSite=Lax.
  • Sign-out calls noma.signOut() before clearing the cookie.
  • Middleware protects every route that renders private data.
  • Rate-limit hits return 429. Handle them in your UI (show a “try again in N seconds” message from retry_after).
  • Verification flow uses your own email/SMS delivery with the returned verification_token.

Project Auth docs · Sessions · SDK setup · CMS for Next.js

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.