nextjs-arch
nextjs-arch — Clean Next.js App Router Architecture
This skill enforces a specific project architecture for Next.js App Router applications. It is not generic guidance — it is an opinionated constraint system based on a proven production structure.
Read every rule before writing code. Every rule is testable — you should be able to look at the file tree and answer yes/no.
Directory Structure
This is the canonical layout. Do not invent new top-level directories. Every file has exactly one correct home.
app/
layout.tsx Root layout (html, fonts, providers, toaster)
page.tsx Landing / public entry
globals.css Tailwind v4 config + color tokens
not-found.tsx Global 404
global-error.tsx Root error boundary ("use client")
(app)/ Protected route group
layout.tsx Auth gate + shell (sidebar, header, container)
error.tsx Protected error boundary
[feature]/
page.tsx Server component — fetches data, passes to clients
loading.tsx Skeleton loading state
(auth)/ Public auth route group
layout.tsx Centered layout wrapper
sign-in/page.tsx "use client"
sign-up/page.tsx "use client"
api/
auth/[...all]/route.ts Auth catch-all handler
[feature]/route.ts Feature-specific API routes (uploads, webhooks, streaming)
actions/ Server actions only — nothing else
[feature].ts
components/
[feature].tsx Feature-specific components
ds.tsx Design system layout primitives
forms/
[feature]-form.tsx Client form components
charts/
[feature]-chart.tsx Client chart components (dynamic import, ssr: false)
layout/
[component].tsx Shell layout components
ui/ shadcn/ui primitives — do not put custom components here
lib/
utils.ts cn(), formatDate(), serializeForClient()
validators.ts All Zod schemas + inferred types
auth.ts Auth server config (SERVER ONLY)
auth-client.ts Auth client (signIn, signUp, signOut, useSession)
auth-helpers.ts Server auth helpers (SERVER ONLY)
blob.ts File storage wrappers
ai/
client.ts AI SDK wrappers
db/
index.ts DB connection, exports `db`
schema.ts All Drizzle table definitions + relations
queries.ts Reusable read queries
types/
index.ts Shared TypeScript types
hooks/
use-[name].ts Client-side hooks
Rules
- Route groups for auth boundaries.
(app)is protected,(auth)is public. Parenthetical groups only — no URL impact. - One auth gate. The
(app)/layout.tsxchecks auth and redirects. Individual pages inside(app)never re-check auth. - Features are flat. A feature is a route directory with
page.tsx, optionallyloading.tsx. Do not nest feature directories unless there's a real URL hierarchy. ui/is sacred. Only shadcn/ui primitives live incomponents/ui/. Custom components go incomponents/orcomponents/[category]/.
Server / Client Boundary
This is the single most important architectural decision. Get it wrong and the whole app leaks.
Strictly Server-Only (never import in client components)
lib/db/*— database connection and querieslib/auth.ts— auth server configlib/auth-helpers.ts— session/user helperslib/blob.ts— file storageactions/*— server actions
Client-Only (must have "use client")
- All
components/forms/* - All
components/charts/* - Interactive shell components (sidebar, header with dropdowns)
- Theme provider, mode toggle
lib/auth-client.ts
Server Components (default, no directive)
- All
page.tsxfiles (except auth pages) - All
layout.tsxfiles - Design system primitives (
ds.tsx) - Any component that doesn't need interactivity
The Rule
Default to server. Add "use client" only when you need browser APIs, event handlers, hooks, or interactivity. If a component only renders props, it stays on the server.
Data Flow
This is the one correct data flow pattern. Do not invent alternatives.
Server Component (page.tsx)
→ calls auth helper (getCurrentUser / requireUser)
→ fetches data via db.query or query function from lib/db/queries.ts
→ renders, passing data as props to Client Components
Client Component
→ receives data as props (defaultValues, items, user, etc.)
→ calls Server Action inside startTransition(async () => { ... })
→ shows feedback via toast
Server Action (actions/[feature].ts)
→ "use server" at top
→ calls requireUser() or requireOrg() (auth check first, always)
→ validates input with Zod .parse()
→ mutates DB
→ calls revalidatePath() for affected routes
Rules
- Server components fetch, client components display and mutate. Never fetch data inside a client component. Never call
dbfrom a client component. - Props down, actions up. Data flows down through props. Mutations flow up through server actions.
- Auth check in every action. Every server action starts with
requireUser()orrequireOrg(). No exceptions. - Validate in actions. Parse input with Zod in the server action, not in the client component.
- Revalidate after mutation. Every server action that writes data calls
revalidatePath().
Server Actions Pattern
Every server action follows this exact structure:
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { tableSchema } from "@/lib/db/schema";
import { requireUser } from "@/lib/auth-helpers";
import { eq } from "drizzle-orm";
import { featureSchema, type FeatureInput } from "@/lib/validators";
export async function updateFeature(input: FeatureInput) {
const user = await requireUser();
const validated = featureSchema.parse(input);
await db.update(tableSchema)
.set({ ...validated })
.where(eq(tableSchema.id, validated.id));
revalidatePath("/feature");
}
Rules
- One file per feature domain in the
actions/directory. - Auth first, validate second, mutate third, revalidate fourth. Always in this order.
- Types come from Zod. Use
z.infer<typeof schema>for action input types. Define schemas inlib/validators.ts.
Client Form Pattern
Every form follows this exact structure:
"use client";
import { useTransition } from "react";
import { toast } from "sonner";
import { updateFeature } from "@/actions/feature";
export function FeatureForm({ defaultValues }: { defaultValues: FeatureInput }) {
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
startTransition(async () => {
try {
await updateFeature({
/* extract from formData */
});
toast.success("Updated");
} catch {
toast.error("Failed to update");
}
});
}
return (
<form onSubmit={handleSubmit}>
{/* fields */}
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
</form>
);
}
Rules
useTransition, notuseFormStatus. Wrap server action calls instartTransition.- Native FormData. Extract values from
e.currentTarget, not controlled state (unless the form needs it). defaultValuesas props. Server component passes initial data; client component doesn't fetch.- Sonner for feedback.
toast.success()andtoast.error()— no custom toast components. - Pending state on buttons.
disabled={isPending}and label change during submission.
Database Patterns
Schema (lib/db/schema.ts)
- All tables use
pgTable()fromdrizzle-orm/pg-core - Primary keys are
texttype withcreateId()from@paralleldrive/cuid2as$defaultFn - Timestamps:
timestamp().defaultNow().notNull() - Foreign keys: explicit
.references()withonDeletebehavior (cascadeorset null) - Relations defined separately using
relations()fromdrizzle-orm - Organized by section: Enums → Auth Tables → Application Tables → Relations
Queries (lib/db/queries.ts)
- Pure async functions using
db.query(relational API) withfindFirst/findMany - Named
getXByYpattern (e.g.,getUserById,getOrgBySlug) - No raw SQL unless absolutely necessary
Connection (lib/db/index.ts)
- Single
dbexport, single connection setup - Pass
schemato drizzle for relational queries
Naming Conventions
| What | Convention | Example |
|---|---|---|
| Files | kebab-case | settings-form.tsx, auth-helpers.ts |
| Components | PascalCase exports | SettingsForm, AppSidebar |
| Server actions | camelCase | updateUserSettings |
| DB tables | camelCase variable | organizations, userRole |
| Hooks | use- file prefix, use function prefix |
use-mobile.ts, useIsMobile() |
| Types | PascalCase | AIResponse, SettingsInput |
| Zod schemas | camelCase with Schema suffix |
settingsSchema |
| Route groups | Parenthetical | (app), (auth) |
| Path aliases | @/ prefix |
@/lib/utils |
Import Order
Always in this order, separated by blank lines:
- React / Next.js built-ins (
react,next/navigation,next/cache) - Third-party libraries (
drizzle-orm,zod,sonner) - Internal lib (
@/lib/...) - Internal components (
@/components/...) - Types (
@/types)
Anti-Patterns (Hard Stops)
Do not produce any of the following:
- Client-side data fetching — no
useEffect+fetchfor data that can be fetched in a server component - Auth checks in individual pages — the
(app)/layout.tsxhandles this once dbimported in client components — database access is server-only, always- Custom components in
ui/— that directory is for shadcn primitives only - Server actions without auth — every action starts with
requireUser()orrequireOrg() - Controlled form state for simple forms — use native FormData unless the form genuinely needs controlled inputs
- API routes for mutations — use server actions. API routes are for webhooks, file uploads, streaming, and third-party integrations.
- Nested feature directories — keep routes flat unless the URL hierarchy demands nesting
- Fetching data in client components — server component fetches, passes as props
- Skipping loading.tsx — every route under
(app)should have a loading state with Skeleton components - Hardcoded color classes — use shadcn CSS variable classes (
bg-background,text-foreground), neverbg-gray-*orbg-white
Self-Review (Run Before Finalizing)
Score 1–5 on each. Revise until all are 4+.
| Criterion | Question |
|---|---|
| Boundary | Is every file on the correct side of the server/client boundary? |
| Data flow | Does data flow down as props and mutations flow up as actions? |
| Auth | Is auth checked once in layout, and again in every server action? |
| Structure | Does the file tree follow the canonical layout? |
| Naming | Do all files, functions, and types follow the naming conventions? |
| Loading | Does every route have a loading.tsx with skeletons? |