Tutorial: Next.js¶
Build the same Team Notes app from the React tutorial, but with a Next.js App Router frontend. Same backend, different frontend stack.
What you'll build: A Next.js frontend with Edge Middleware for route protection, server components for data fetching, and client components for interactive UI -- all using @sentinel-auth/nextjs.
Prerequisites¶
- Same as the React tutorial
- Completed backend from React tutorial Steps 1-3 (or use
demo-authz/backend/) - Client app registered with redirect URI
http://localhost:3000/auth/callback - Node.js 18+
Step 1: Backend¶
Use the same FastAPI backend from the React tutorial. The backend is framework-agnostic -- it validates dual tokens regardless of what frontend sends them.
If you haven't built it yet, follow React tutorial Steps 1-3.
Step 2: Next.js Setup¶
npx create-next-app@latest frontend --app --typescript --tailwind
cd frontend
npm install @sentinel-auth/js @sentinel-auth/nextjs
Auth Client¶
Create a shared SentinelAuthz instance. This is the same client from the React tutorial -- @sentinel-auth/nextjs re-exports the React hooks and wraps them with Next.js-specific helpers.
// lib/auth.ts
import { SentinelAuthz, IdpConfigs } from "@sentinel-auth/js";
export const authzClient = new SentinelAuthz({
sentinelUrl: process.env.NEXT_PUBLIC_SENTINEL_URL || "http://localhost:9003",
idps: {
google: IdpConfigs.google(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""),
},
});
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:9200";
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
return authzClient.fetchJson<T>(`${BACKEND_URL}${path}`, options);
}
Step 3: Edge Middleware¶
Protect routes at the edge. Unauthenticated users are redirected to /login.
// middleware.ts
import { withSentinelAuthz } from "@sentinel-auth/nextjs/middleware";
export default withSentinelAuthz({
publicPaths: ["/login", "/auth/callback"],
loginPath: "/login",
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
Step 4: Layout + Provider¶
Wrap the app in AuthzProvider so hooks work in client components.
// app/layout.tsx
import { AuthzProvider } from "@sentinel-auth/nextjs";
import { authzClient } from "@/lib/auth";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthzProvider client={authzClient}>
{children}
</AuthzProvider>
</body>
</html>
);
}
Step 5: Pages¶
Login¶
// app/login/page.tsx
"use client";
import { useAuthz } from "@sentinel-auth/nextjs";
export default function LoginPage() {
const { login } = useAuthz();
return <button onClick={() => login("google")}>Sign in with Google</button>;
}
OAuth Callback¶
// app/auth/callback/page.tsx
"use client";
import { AuthzCallback } from "@sentinel-auth/nextjs";
import { useRouter } from "next/navigation";
export default function CallbackPage() {
const router = useRouter();
return (
<AuthzCallback
onSuccess={() => router.replace("/notes")}
workspaceSelector={({ workspaces, onSelect, isLoading }) => (
<div>
<h2>Select Workspace</h2>
{workspaces.map((ws) => (
<button key={ws.id} onClick={() => onSelect(ws.id)} disabled={isLoading}>
{ws.name} ({ws.role})
</button>
))}
</div>
)}
/>
);
}
Note List¶
A client component that uses useAuthzUser() for role checks and apiFetch for data.
// app/notes/page.tsx
"use client";
import { useAuthzUser } from "@sentinel-auth/nextjs";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/auth";
import Link from "next/link";
export default function NotesPage() {
const user = useAuthzUser();
const [notes, setNotes] = useState<any[]>([]);
const canCreate = ["editor", "admin", "owner"].includes(user.workspaceRole);
useEffect(() => {
apiFetch<any[]>("/notes").then(setNotes);
}, []);
return (
<div>
<h1>Notes</h1>
{canCreate && <button>New Note</button>}
<ul>
{notes.map((note) => (
<li key={note.id}>
<Link href={`/notes/${note.id}`}>{note.title}</Link>
</li>
))}
</ul>
</div>
);
}
Note Detail¶
Entity-level ACL checks happen on the backend. If permissions.can() denies access, the API returns 403 and the frontend shows the error.
// app/notes/[id]/page.tsx
"use client";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/auth";
import Link from "next/link";
export default function NoteDetailPage() {
const { id } = useParams<{ id: string }>();
const [note, setNote] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<any>(`/notes/${id}`)
.then(setNote)
.catch((e) => setError(e.message));
}, [id]);
if (error) return <p>Access denied: {error}</p>;
if (!note) return <p>Loading...</p>;
return (
<div>
<Link href="/notes">Back</Link>
<h1>{note.title}</h1>
<p>{note.content}</p>
<p>by {note.owner_name}</p>
</div>
);
}
Step 6: Run It¶
# Terminal 1: backend (same as React tutorial)
cd backend && uvicorn main:app --port 9200 --reload
# Terminal 2: Next.js frontend
cd frontend && npm run dev
Result¶
| Component | React version | Next.js version |
|---|---|---|
| Provider | AuthzProvider from @sentinel-auth/react |
AuthzProvider from @sentinel-auth/nextjs |
| Route guard | AuthzGuard in JSX |
withSentinelAuthz Edge Middleware |
| Hooks | useAuthz, useAuthzUser from @sentinel-auth/react |
Same hooks from @sentinel-auth/nextjs |
| Callback | AuthzCallback from @sentinel-auth/react |
AuthzCallback from @sentinel-auth/nextjs |
| Data fetching | React Query + apiFetch |
apiFetch (or React Query) |
The backend is identical. The authorization model (three tiers) is backend-enforced and frontend-agnostic.