Next.js Product Stack
A battle-tested blueprint for building production Next.js applications with clear separation of concerns across 9 layers — from database schema to UI. Each layer has one job, communicates only with adjacent layers, and can be tested in isolation. Scales from MVP to enterprise without accruing architectural debt.
Install
curl -fsSL https://skills.vishalvoid.com/install nextjs-product-stack | bashThe 9 layers
Each layer is a ring that isolates a concern. Inner layers (1–3) are pure business logic with no framework dependency. Outer layers (6–9) are framework-specific. This separation means you can swap your ORM, change your API style, or migrate UI frameworks without touching business logic.
// The stack, from bottom to top:
//
// 1. DATABASE — Prisma schema, migrations
// 2. REPOSITORY — Raw DB queries, returns plain objects
// 3. SERVICE — Business logic, calls repositories
// 4. SERVER ACTION — Form/mutation handler, calls services
// 5. API ROUTE — REST/webhook endpoint, calls services
// 6. MIDDLEWARE — Auth checks, redirects, headers
// 7. DATA HOOK — Client-side TanStack Query fetches
// 8. COMPONENT — State, event handlers, business UI
// 9. UI — Presentational only, no logic// The stack, from bottom to top:
//
// 1. DATABASE — Prisma schema, migrations
// 2. REPOSITORY — Raw DB queries, returns plain objects
// 3. SERVICE — Business logic, calls repositories
// 4. SERVER ACTION — Form/mutation handler, calls services
// 5. API ROUTE — REST/webhook endpoint, calls services
// 6. MIDDLEWARE — Auth checks, redirects, headers
// 7. DATA HOOK — Client-side TanStack Query fetches
// 8. COMPONENT — State, event handlers, business UI
// 9. UI — Presentational only, no logicFolder structure
Each layer lives in a predictable location. The rule: imports only flow downward — a service can import a repository, but a repository must never import a service.
src/
├── lib/
│ ├── db.ts # Prisma client singleton
│ ├── repositories/
│ │ ├── user.repository.ts # Layer 2
│ │ └── post.repository.ts
│ ├── services/
│ │ ├── user.service.ts # Layer 3
│ │ └── post.service.ts
│ └── validations/
│ └── post.schema.ts # Zod schemas (shared)
├── app/
│ ├── api/
│ │ └── posts/route.ts # Layer 5 (REST)
│ ├── (dashboard)/
│ │ └── posts/
│ │ ├── page.tsx # Layer 8/9
│ │ └── actions.ts # Layer 4 (Server Actions)
│ └── middleware.ts # Layer 6
└── components/
├── ui/ # Layer 9 (pure presentational)
└── features/ # Layer 8 (wired components)src/
├── lib/
│ ├── db.ts # Prisma client singleton
│ ├── repositories/
│ │ ├── user.repository.ts # Layer 2
│ │ └── post.repository.ts
│ ├── services/
│ │ ├── user.service.ts # Layer 3
│ │ └── post.service.ts
│ └── validations/
│ └── post.schema.ts # Zod schemas (shared)
├── app/
│ ├── api/
│ │ └── posts/route.ts # Layer 5 (REST)
│ ├── (dashboard)/
│ │ └── posts/
│ │ ├── page.tsx # Layer 8/9
│ │ └── actions.ts # Layer 4 (Server Actions)
│ └── middleware.ts # Layer 6
└── components/
├── ui/ # Layer 9 (pure presentational)
└── features/ # Layer 8 (wired components)Layer 2: Repository
Repositories are the only layer that imports from Prisma. They accept plain parameters, run one query, and return a plain object. No business logic, no validation, no conditionals based on business rules — just raw data access.
// lib/repositories/post.repository.ts
import { db } from '@/lib/db'
export async function findPostById(id: string) {
return db.post.findUnique({
where: { id },
select: {
id: true,
title: true,
content: true,
published: true,
authorId: true,
createdAt: true,
},
})
}
export async function findPublishedPosts(limit = 20, offset = 0) {
return db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
select: { id: true, title: true, createdAt: true },
})
}
export async function createPost(data: {
title: string
content: string
authorId: string
}) {
return db.post.create({ data })
}// lib/repositories/post.repository.ts
import { db } from '@/lib/db'
export async function findPostById(id: string) {
return db.post.findUnique({
where: { id },
select: {
id: true,
title: true,
content: true,
published: true,
authorId: true,
createdAt: true,
},
})
}
export async function findPublishedPosts(limit = 20, offset = 0) {
return db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
select: { id: true, title: true, createdAt: true },
})
}
export async function createPost(data: {
title: string
content: string
authorId: string
}) {
return db.post.create({ data })
}Layer 3: Service
Services contain all business logic. They orchestrate repositories, enforce rules, and throw typed errors. A service never touches `req`, `res`, or any framework concept.
// lib/services/post.service.ts
import { findPostById, createPost } from '@/lib/repositories/post.repository'
import { findUserById } from '@/lib/repositories/user.repository'
export class PostNotFoundError extends Error {
constructor(id: string) {
super(`Post ${id} not found`)
this.name = 'PostNotFoundError'
}
}
export class UnauthorizedError extends Error {
constructor() {
super('You do not have permission to perform this action')
this.name = 'UnauthorizedError'
}
}
export async function getPost(id: string) {
const post = await findPostById(id)
if (!post) throw new PostNotFoundError(id)
return post
}
export async function publishPost(postId: string, requestingUserId: string) {
const post = await findPostById(postId)
if (!post) throw new PostNotFoundError(postId)
if (post.authorId !== requestingUserId) throw new UnauthorizedError()
return updatePost(postId, { published: true })
}// lib/services/post.service.ts
import { findPostById, createPost } from '@/lib/repositories/post.repository'
import { findUserById } from '@/lib/repositories/user.repository'
export class PostNotFoundError extends Error {
constructor(id: string) {
super(`Post ${id} not found`)
this.name = 'PostNotFoundError'
}
}
export class UnauthorizedError extends Error {
constructor() {
super('You do not have permission to perform this action')
this.name = 'UnauthorizedError'
}
}
export async function getPost(id: string) {
const post = await findPostById(id)
if (!post) throw new PostNotFoundError(id)
return post
}
export async function publishPost(postId: string, requestingUserId: string) {
const post = await findPostById(postId)
if (!post) throw new PostNotFoundError(postId)
if (post.authorId !== requestingUserId) throw new UnauthorizedError()
return updatePost(postId, { published: true })
}Layer 4: Server Action
Server Actions are thin — they parse form data, call a service, and return a typed result. No business logic lives here. Errors from the service are caught and surfaced to the client as structured responses.
// app/(dashboard)/posts/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { publishPost, PostNotFoundError, UnauthorizedError } from '@/lib/services/post.service'
type ActionResult =
| { success: true }
| { success: false; error: string }
export async function publishPostAction(postId: string): Promise<ActionResult> {
const session = await auth()
if (!session) return { success: false, error: 'Not authenticated' }
try {
await publishPost(postId, session.user.id)
return { success: true }
} catch (err) {
if (err instanceof PostNotFoundError) {
return { success: false, error: 'Post not found' }
}
if (err instanceof UnauthorizedError) {
return { success: false, error: 'Permission denied' }
}
return { success: false, error: 'Something went wrong' }
}
}// app/(dashboard)/posts/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { publishPost, PostNotFoundError, UnauthorizedError } from '@/lib/services/post.service'
type ActionResult =
| { success: true }
| { success: false; error: string }
export async function publishPostAction(postId: string): Promise<ActionResult> {
const session = await auth()
if (!session) return { success: false, error: 'Not authenticated' }
try {
await publishPost(postId, session.user.id)
return { success: true }
} catch (err) {
if (err instanceof PostNotFoundError) {
return { success: false, error: 'Post not found' }
}
if (err instanceof UnauthorizedError) {
return { success: false, error: 'Permission denied' }
}
return { success: false, error: 'Something went wrong' }
}
}Why strict layers prevent the most common failure modes
The #1 failure mode in Next.js apps is server actions that import Prisma directly, creating untestable spaghetti. The #2 failure is components fetching data directly with fetch(), coupling UI to network. The #3 failure is circular service imports. The 9-layer rule makes all three physically impossible — the folder structure enforces the constraint.
Related skills