Admin
Next.js 16 admin panel with WorkOS AuthKit. Every route requires auth.
@darna/admin is a Next.js 16 App Router app behind WorkOS AuthKit. The
landing page reads the signed-in user, hits /admin/me on the backend with
the access token, and shows whether adminMiddleware accepted it.
We use middleware.ts (deprecated in Next 16) rather than the new
proxy.ts. Reason: Next 16 hard-codes proxy.ts to the Node runtime and
forbids the runtime config. OpenNext for Cloudflare only hosts Edge
middleware. The deprecated middleware.ts filename still works in Next 16
and lets us opt into Edge via runtime: "experimental-edge". Two warnings
appear at build time (deprecated filename, experimental runtime); both are
expected. Revisit when OpenNext supports Node middleware.
Layout
apps/admin/src/
├── middleware.ts authkitMiddleware — enforces auth on every route
└── app/
├── layout.tsx wraps children in <AuthKitProvider>
├── page.tsx admin landing — withAuth + backend ping
└── callback/route.ts handleAuth() — OAuth code exchangeAuth enforcement
middleware.ts enables middlewareAuth on every path that isn't a static
asset. Unauthenticated requests get 307'd to the WorkOS-hosted sign-in page.
// src/middleware.ts
import { authkitMiddleware } from "@workos-inc/authkit-nextjs"
export default authkitMiddleware({
middlewareAuth: { enabled: true, unauthenticatedPaths: [] },
})
export const config = {
runtime: "experimental-edge",
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"],
}After the user signs in, WorkOS redirects to /callback?code=…, which is
handled by handleAuth() from the package — it exchanges the code, seals the
session into a cookie via iron-session, and redirects to the original path.
After the user signs in, WorkOS redirects to /callback?code=…, which is
handled by handleAuth() from the package — it exchanges the code, seals the
session into a cookie via iron-session, and redirects to the original path.
Reading the user
In any server component:
import { withAuth } from "@workos-inc/authkit-nextjs"
const { user, accessToken } = await withAuth({ ensureSignedIn: true })accessToken is a short-lived WorkOS JWT, refreshed transparently by
AuthKit's middleware on each request. Pass it as
Authorization: Bearer <accessToken> to call the backend.
Calling the backend
The landing page does the round-trip on every render:
// src/app/page.tsx
const { user, accessToken } = await withAuth({ ensureSignedIn: true })
const res = await fetch(`${BACKEND_URL}/admin/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
})A green pill with the status code means adminMiddleware accepted the token.
Red means something's misaligned — usually the two WORKOS_CLIENT_ID values
disagree.
Env
Local dev reads apps/admin/.env.local:
WORKOS_API_KEY=sk_...
WORKOS_CLIENT_ID=client_...
WORKOS_COOKIE_PASSWORD=... # 32+ chars, openssl rand -base64 32
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
BACKEND_URL=http://localhost:4000The same WORKOS_CLIENT_ID must be set for the backend — the admin signs the
token, the backend verifies it.
Sign-out
A server action on the landing page:
<form action={async () => { "use server"; await signOut() }}>
<button type="submit">Sign out</button>
</form>signOut() clears the cookie and redirects to the WorkOS-configured
sign-out URL (or / by default).