skills.vishalvoid
all skills
Architecture

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.

Next.jsTypeScriptPrismaPostgreSQLArchitecture

Install

$curl -fsSL https://skills.vishalvoid.com/install nextjs-product-stack | bash

The 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.

architecture.ts
// 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

Folder 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.

project structure
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
// 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
// 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
// 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

back to all skills