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.
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.
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_atlater). - 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
audagainst that list.
Background reading: Project Auth overview · Sign up & sign in · Social login.
SDK and environment
npm install @nomacms/js-sdkPut 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.comOne 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.
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 }).
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>;
}"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.
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).
Before you ship
- No credentials prefixed with
NEXT_PUBLIC_except the Googleclient_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 fromretry_after). - Verification flow uses your own email/SMS delivery with the returned
verification_token.