DevelopmentStack

Next.js 15 Full-Stack App Architecture That Scales

The architecture patterns I use for production Next.js apps. Folder structure, data layer, auth, and deployment strategies that actually work.

April 2, 20264 min read
Next.jsArchitectureFull-Stack

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/ or repositories/ 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 / Rendernext start with 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

Follow Code_Racoon

New guides, benchmarks, and tools.