Authentication patterns for Next.js apps using Clerk, including middleware setup, role-based access, organization scoping, and dev-mode fallbacks.
Overview
Clerk provides authentication, user management, and organization-based RBAC for Next.js apps. In Neexo projects, Clerk handles sign-in, sign-up, organization switching, and role enforcement.
Next.js 16 Middleware
Next.js 16 uses proxy.ts (named proxy export) instead of middleware.ts:
// src/proxy.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)", "/api/public(.*)"]);
export const proxy = clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: ["/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)"],
};
Auth Context Helper
import { auth } from "@clerk/nextjs/server";
export async function getAuthContext() {
const { userId, orgId, orgRole } = await auth();
if (!userId || !orgId) {
throw new Error("Unauthorized");
}
return { userId, orgId, orgRole };
}
Role-Based Access
const STUDIO_ROLES = new Set(["org:admin", "org:editor"]);
export function hasStudioAccess(orgRole: string | undefined) {
return orgRole ? STUDIO_ROLES.has(orgRole) : false;
}
// In an API route or server action:
const { orgRole } = await getAuthContext();
if (!hasStudioAccess(orgRole)) {
return new Response("Forbidden", { status: 403 });
}
Organization Scoping
Every database query must filter by organization:
// ✅ Correct — always scope by orgId
const items = await db.select().from(machines).where(eq(machines.orgId, orgId));
// ❌ Wrong — returns data across all organizations
const items = await db.select().from(machines);
Dev Mode Fallback
For local development without Clerk keys:
const DEV_USER_ID = "dev-user";
const DEV_ORG_ID = "dev-org";
const DEV_ORG_ROLE = "org:admin";
export async function getAuthContext() {
if (process.env.NODE_ENV === "development" && !process.env.CLERK_SECRET_KEY) {
return { userId: DEV_USER_ID, orgId: DEV_ORG_ID, orgRole: DEV_ORG_ROLE };
}
// ... real auth
}
Common Pitfalls
Do not call auth() in client components — it's server-only
Use useUser() and useOrganization() for client-side auth state
Wrap Clerk components (UserButton, OrganizationSwitcher) in safe client wrappers
Never expose CLERK_SECRET_KEY to the client — only NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
Raw contentCopy this into your project — e.g. .instructions.md, .agent.md, or SKILL.md
## Overview
Clerk provides authentication, user management, and organization-based RBAC for Next.js apps. In Neexo projects, Clerk handles sign-in, sign-up, organization switching, and role enforcement.
## Next.js 16 Middleware
Next.js 16 uses `proxy.ts` (named `proxy` export) instead of `middleware.ts`:
```tsx
// src/proxy.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)", "/api/public(.*)"]);
export const proxy = clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: ["/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)"],
};
```
## Auth Context Helper
```tsx
import { auth } from "@clerk/nextjs/server";
export async function getAuthContext() {
const { userId, orgId, orgRole } = await auth();
if (!userId || !orgId) {
throw new Error("Unauthorized");
}
return { userId, orgId, orgRole };
}
```
## Role-Based Access
```tsx
const STUDIO_ROLES = new Set(["org:admin", "org:editor"]);
export function hasStudioAccess(orgRole: string | undefined) {
return orgRole ? STUDIO_ROLES.has(orgRole) : false;
}
// In an API route or server action:
const { orgRole } = await getAuthContext();
if (!hasStudioAccess(orgRole)) {
return new Response("Forbidden", { status: 403 });
}
```
## Organization Scoping
Every database query must filter by organization:
```tsx
// ✅ Correct — always scope by orgId
const items = await db.select().from(machines).where(eq(machines.orgId, orgId));
// ❌ Wrong — returns data across all organizations
const items = await db.select().from(machines);
```
## Dev Mode Fallback
For local development without Clerk keys:
```tsx
const DEV_USER_ID = "dev-user";
const DEV_ORG_ID = "dev-org";
const DEV_ORG_ROLE = "org:admin";
export async function getAuthContext() {
if (process.env.NODE_ENV === "development" && !process.env.CLERK_SECRET_KEY) {
return { userId: DEV_USER_ID, orgId: DEV_ORG_ID, orgRole: DEV_ORG_ROLE };
}
// ... real auth
}
```
## Common Pitfalls
- Do not call `auth()` in client components — it's server-only
- Use `useUser()` and `useOrganization()` for client-side auth state
- Wrap Clerk components (UserButton, OrganizationSwitcher) in safe client wrappers
- Never expose `CLERK_SECRET_KEY` to the client — only `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`