After building multiple production Next.js apps — including a full business management platform — I have settled on an architecture that handles complexity without becoming unmanageable. This is not a theoretical framework. Every pattern here comes from real shipping code.
The Problem with Most Next.js Architectures
Most Next.js tutorials show you a flat folder structure that works for a 5-page site. The moment you add authentication, multiple data models, server actions, and shared components, that structure falls apart.
The other extreme is over-engineering — hexagonal architecture, domain-driven design, and abstraction layers that add complexity without solving real problems at the scale most teams operate at.
The goal is something in between: organized enough to find things, flat enough to move fast.
Folder Structure
Here is the structure I use for production apps:
src/
├── app/ # Routes and layouts
│ ├── (dashboard)/ # Route group: authenticated pages
│ │ ├── invoices/
│ │ ├── products/
│ │ └── layout.tsx # Dashboard shell with sidebar
│ ├── (auth)/ # Route group: login, register
│ ├── api/ # API routes
│ └── layout.tsx # Root layout
├── components/ # Shared UI components
│ ├── ui/ # Primitives (Button, Input, Card)
│ └── [feature]/ # Feature-specific components
├── lib/ # Core utilities
│ ├── db.ts # Database client
│ ├── auth.ts # Auth configuration
│ └── utils.ts # Shared helpers
├── actions/ # Server actions, grouped by domain
│ ├── invoices.ts
│ └── products.ts
└── types/ # Shared TypeScript types
The key decisions:
- Route groups
(dashboard)and(auth)share layouts without affecting URL structure - Server actions live in a dedicated
actions/directory, not scattered inside components - Components are split between
ui/primitives and feature-specific components - No
services/orrepositories/layer — for most apps, server actions calling the database directly is the right level of abstraction
Data Layer
For the database layer, I use Drizzle ORM with PostgreSQL. The schema definition lives alongside the database client:
// lib/db.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });// lib/schema.ts
import { pgTable, text, timestamp, numeric } from "drizzle-orm/pg-core";
export const invoices = pgTable("invoices", {
id: text("id").primaryKey(),
clientName: text("client_name").notNull(),
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
status: text("status", {
enum: ["draft", "sent", "paid", "overdue"],
}).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});Why Drizzle over Prisma? Drizzle generates zero runtime overhead, produces SQL you can read, and does not require a binary engine. For serverless deployments, this matters.
Server Actions
Server actions are the data mutation layer. Each action validates input, hits the database, and revalidates the cache:
// actions/invoices.ts
"use server";
import { db } from "@/lib/db";
import { invoices } from "@/lib/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
export async function updateInvoiceStatus(
id: string,
status: "draft" | "sent" | "paid" | "overdue"
) {
await db
.update(invoices)
.set({ status })
.where(eq(invoices.id, id));
revalidatePath("/invoices");
}The pattern is simple: one file per domain, one exported function per action. No abstraction layers, no service classes. If an action gets complex enough to need decomposition, extract helper functions within the same file first.
Authentication
For auth, I use a session-based approach with encrypted cookies. The specific library matters less than the pattern:
// lib/auth.ts
import { cookies } from "next/headers";
import { db } from "./db";
export async function getSession() {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (!token) return null;
const session = await db.query.sessions.findFirst({
where: (s, { eq }) => eq(s.token, token),
with: { user: true },
});
return session?.user ?? null;
}This function is called in layouts and server components. It runs on every request but is cached within a single render pass by React's request deduplication.
Deployment
For deployment, Vercel is the path of least resistance for Next.js. But the architecture works on any platform that supports Node.js:
- Vercel — zero config, automatic preview deployments
- Railway / Render —
next startwith a Dockerfile - Self-hosted — Node.js process behind Caddy or nginx
The important thing is that the app does not depend on Vercel-specific features. Server actions, route handlers, and middleware all work on any Node.js hosting.
Key Takeaways
- Use route groups to share layouts without affecting URLs
- Keep server actions in a dedicated directory, one file per domain
- Skip the service/repository abstraction layer unless you genuinely need it
- Drizzle ORM is lighter than Prisma for serverless deployments
- Session-based auth with encrypted cookies is simpler than JWT for most apps
- Design for portability — do not lock into a single deployment platform