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:
- A JWT issued by
POST /profile/customer/signin(or/profile/signinfor staff). - An HttpOnly
tokencookie that holds the JWT. - A
middleware.tsthat lets unauthenticated requests through to a small allow-list and redirects everything else to/login. - 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-pathnameheader. It's set on every response so server components can read the current path without re-parsingnextUrl.base-appuses 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/staticexclusion 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:
| Path | Purpose |
|---|---|
/login, /register | Sign-in and sign-up forms |
/auth, /auth/complete-login, /auth/magiclink | OAuth callbacks and magic-link landing |
/forgot-password, /reset-password | Recovery flow |
/login/error | OAuth 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.
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:
- User clicks "Sign in with GitHub" — link to
https://appengine.appmint.io/profile/github?orgid=...&redirect=<your-app>/auth/complete-login. - AppEngine redirects to GitHub, gets the callback, issues a JWT.
- AppEngine redirects to
<your-app>/auth/complete-login?token=<jwt>&user=<base64-user>. /auth/complete-login(a client page or a route handler) reads the token from the query, calls your/api/auth/set-sessionroute, 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 callPOST /profile/refreshwith 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.