Documentation

Add auth to Next.js

Cookie-JWT sessions plus middleware-based route gating, the canonical AppEngine pattern.

This is the auth pattern used by both reference sites (base-app, yugo). It works with the App Router, plays nicely with React Server Components, and doesn't ship a session secret to the browser. The pieces:

  1. A JWT issued by POST /profile/customer/signin (or /profile/signin for staff).
  2. An HttpOnly token cookie that holds the JWT.
  3. A middleware.ts that lets unauthenticated requests through to a small allow-list and redirects everything else to /login.
  4. A getSession() helper for server components.

What the middleware does

The pattern is small enough that the whole file fits on screen. This is the actual base-app/middleware.ts:

// base-app/middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const publicPaths = [
    '/login',
    '/register',
    '/auth',
    '/auth/complete-login',
    '/auth/magiclink',
    '/login/error',
    '/forgot-password',
    '/reset-password',
  ];

  const isPublicPath = publicPaths.some(path =>
    pathname.startsWith(path) ||
    pathname === '/' ||
    pathname.startsWith('/_next') ||
    pathname.includes('/public/') ||
    pathname.includes('.') // Static files
  );

  const token = request.cookies.get('token')?.value;

  if (!isPublicPath && !token) {
    const url = new URL('/login', request.url);
    url.searchParams.set('callbackUrl', encodeURIComponent(pathname));
    return NextResponse.redirect(url);
  }

  if (token && (pathname.startsWith('/login') || pathname.startsWith('/register'))) {
    return NextResponse.redirect(new URL('/', request.url));
  }

  const response = NextResponse.next();
  response.headers.set('x-pathname', pathname);
  return response;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)'
  ]
};

Three things worth highlighting:

  • No JWT validation in middleware. The token is a presence check, nothing more. The actual check happens server-side every time you call AppEngine — invalid tokens get a 401 and you handle it. Validating in middleware would mean shipping the JWT secret to the edge runtime; you don't want that.
  • The x-pathname header. It's set on every response so server components can read the current path without re-parsing nextUrl. base-app uses this to drive runtime asset loading (which CMS-defined components to fetch for the current page).
  • The static-file exception. pathname.includes('.') lets through anything that looks like a file (/logo.svg, /manifest.json) without auth — pair with the matcher's _next/static exclusion to skip Next's own assets.

Public paths — pick yours

The default list is for a SaaS app. Adjust based on what your app actually exposes without auth:

PathPurpose
/login, /registerSign-in and sign-up forms
/auth, /auth/complete-login, /auth/magiclinkOAuth callbacks and magic-link landing
/forgot-password, /reset-passwordRecovery flow
/login/errorOAuth error landing page
/Marketing home — keep this public if your home page is unauthenticated
/_next/...Next.js internals — never gate these

If you have a public marketing site at /marketing/*, add it. If your / is gated, drop the pathname === '/' check.

API routes

The matcher catches /api/* too. Inside an API route handler you can either re-check the cookie yourself or rely on the middleware redirect (browser-driven calls hit the redirect; server-side internal fetches inside RSCs are fine because they don't go through middleware). Most teams do another check inside the handler — middleware is a guardrail, not the whole fence.

Sign-in flow

The login page is a client component because the form interactions are interactive:

// src/app/(auth)/login/page.tsx
'use client';

import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function LoginPage() {
  const router = useRouter();
  const params = useSearchParams();
  const callbackUrl = params.get('callbackUrl') || '/';
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);

    const res = await fetch('/api/auth/sign-in', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) {
      const j = await res.json().catch(() => ({}));
      setError(j.message || 'Sign-in failed');
      return;
    }

    router.push(decodeURIComponent(callbackUrl));
  }

  return (
    <form onSubmit={onSubmit} className="...">
      {/* fields */}
    </form>
  );
}

The actual AppEngine call lives in a route handler so the JWT lands in an HttpOnly cookie:

// src/app/api/auth/sign-in/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { appengine } from '@/lib/appmint-client';

export async function POST(req: Request) {
  const { email, password } = await req.json();

  try {
    const res = await appengine.request<{ token: string; user: any }>(
      'post',
      'profile/customer/signin',
      { email, password },
    );

    const c = await cookies();
    c.set('token', res.token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 60 * 24 * 7,
    });
    // user object is fine to expose to client-side reads
    c.set('user', JSON.stringify(res.user), {
      httpOnly: false,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 60 * 24 * 7,
    });

    return NextResponse.json({ ok: true });
  } catch (e: any) {
    return NextResponse.json(
      { message: e.response?.data?.message || 'Sign-in failed' },
      { status: 401 },
    );
  }
}

Two cookies, two purposes:

  • token — HttpOnly. Only readable on the server, where you'd attach it to AppEngine calls.
  • user — readable by client JS. Convenience for "show user's email in nav" without an extra round-trip. Don't put anything sensitive here; if a value is server-only, leave it out and re-fetch.

Read the session in a server component

// src/lib/get-session.ts
import { cookies } from 'next/headers';

export type Session = { token: string; user: any };

export async function getSession(): Promise<Session | null> {
  const c = await cookies();
  const token = c.get('token')?.value;
  const userRaw = c.get('user')?.value;
  if (!token || !userRaw) return null;
  try {
    return { token, user: JSON.parse(userRaw) };
  } catch {
    return null;
  }
}

Use it from any RSC:

// src/app/account/page.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/get-session';
import { appengine } from '@/lib/appmint-client';

export default async function AccountPage() {
  const session = await getSession();
  if (!session) redirect('/login');

  const profile = await appengine.request(
    'get',
    'client-data/profile',
    null,
    session.token,
  );

  return (
    <div>
      <h1>{profile.data.name}</h1>
      <p>{profile.data.email}</p>
    </div>
  );
}

The redirect('/login') is belt-and-braces — middleware should already have redirected, but the server component is the last line of defense if a route slips off the gated list.

Sign out

// src/app/api/auth/sign-out/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function POST() {
  const c = await cookies();
  c.delete('token');
  c.delete('user');
  return NextResponse.json({ ok: true });
}

Hook it from a client button:

'use client';

import { useRouter } from 'next/navigation';

export function SignOutButton() {
  const router = useRouter();
  return (
    <button
      onClick={async () => {
        await fetch('/api/auth/sign-out', { method: 'POST' });
        router.push('/login');
      }}
    >
      Sign out
    </button>
  );
}

OAuth and magic-link

AppEngine handles OAuth and magic-link redirects entirely on the backend. The flow:

  1. User clicks "Sign in with GitHub" — link to https://appengine.appmint.io/profile/github?orgid=...&redirect=<your-app>/auth/complete-login.
  2. AppEngine redirects to GitHub, gets the callback, issues a JWT.
  3. AppEngine redirects to <your-app>/auth/complete-login?token=<jwt>&user=<base64-user>.
  4. /auth/complete-login (a client page or a route handler) reads the token from the query, calls your /api/auth/set-session route, and redirects to the post-login destination.
// src/app/auth/complete-login/page.tsx
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function CompleteLogin() {
  const router = useRouter();
  const params = useSearchParams();
  useEffect(() => {
    const token = params.get('token');
    const userRaw = params.get('user');
    if (!token || !userRaw) {
      router.push('/login/error');
      return;
    }
    const user = JSON.parse(atob(userRaw));
    fetch('/api/auth/set-session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token, user }),
    }).then(() => router.push('/'));
  }, [params, router]);
  return <div>Signing you in...</div>;
}

The /auth/complete-login path is in the public allow-list so the middleware doesn't bounce it to /login while the cookie is being set.

Token refresh

JWTs from AppEngine expire after a few days (configurable in admin → Org settings). When they do, AppEngine returns 401 on the next call. You have two reasonable strategies:

  • Re-prompt on 401 — catch the 401 in your client wrapper, redirect to /login?callbackUrl=.... Simple, fits a SaaS dashboard.
  • Refresh-token flow — the sign-in response carries a refreshToken; store it in a separate HttpOnly cookie (refresh_token), and on 401 call POST /profile/refresh with the refresh token to get a new access JWT.

base-app/src/lib/appmint-client.ts shows the second pattern — the renewTries counter and getAppToken() method handle automatic refresh against appkey. For a customer-session JWT, the refresh-token endpoint is the one you want.

When to use API keys instead

Cookie-JWT is for human sessions. For server-to-server calls — a webhook receiver, a build-time data fetch, a Chrome-extension client where cookies don't fit — use an API key:

const data = await fetch('https://appengine.appmint.io/data/contact', {
  headers: {
    orgid: 'my-org',
    'x-api-key': process.env.APPENGINE_API_KEY!,
  },
}).then(r => r.json());

API keys carry their own role/permissions and don't expire. Generate them in admin → API keys; rotate on schedule.