Most Next.js tutorials dump all their logic into page components or API routes. That works for a demo. It collapses fast when you add a second feature, a second developer, or a second requirement.
This boilerplate uses a lightweight module structure inspired by Domain-Driven Design. Each feature lives in modules/<name>/ and follows a fixed 7-file layout. Here is how it works and why.
Every module has exactly these files:
modules/post/
├── post.schema.ts -- Drizzle table definition
├── post.relations.ts -- Drizzle relations (joins)
├── post.types.ts -- TypeScript types (inferred + custom)
├── post.validation.ts -- Zod schemas for API input
├── post.repo.ts -- DB queries only, no business logic
├── post.service.ts -- Business logic, throws HttpError
└── index.ts -- Public exports
Each layer has exactly one responsibility. Nothing leaks between them.
// modules/post/post.schema.ts
import { pgTable, text, boolean, timestamp, uuid } from 'drizzle-orm/pg-core';
export const postTable = pgTable('posts', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').notNull().default(false),
authorId: uuid('author_id').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
Naming rules: table export is camelCase + Table suffix. Columns are snake_case. Never name this PostSchema.
Register it in db/drizzle.ts alongside its relations file -- Drizzle requires both in the same client instance.
// modules/post/post.relations.ts
import { relations } from 'drizzle-orm';
import { postTable } from './post.schema';
import { userTable } from '../user/user.schema';
import { commentTable } from '../comment/comment.schema';
export const postRelations = relations(postTable, ({ one, many }) => ({
author: one(userTable, {
fields: [postTable.authorId],
references: [userTable.id],
}),
comments: many(commentTable),
}));
Without this file, db.query.postTable.findMany({ with: { author: true } }) throws a runtime error. Always create a *.relations.ts alongside every *.schema.ts and register both in db/drizzle.ts.
// modules/post/post.types.ts
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
import { postTable } from './post.schema';
export type Post = InferSelectModel<typeof postTable>;
export type NewPost = InferInsertModel<typeof postTable>;
export type PostSummary = Pick<
Post,
'id' | 'title' | 'published' | 'createdAt'
>;
Never infer DB types directly inside UI components or API routes. Centralise them here and import from the module index. Use Omit<Post, 'passwordHash'> patterns in user modules to strip sensitive fields before returning them.
// modules/post/post.validation.ts
import { z } from 'zod';
export const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().optional().default(false),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
Key rule: validation schemas describe exactly what the client sends. Never include authorId, id, or timestamps -- those come from the JWT or are generated server-side. Always use safeParse, never parse, so you can return a 400 instead of throwing.
// modules/post/post.repo.ts
import { db } from '@/db/drizzle';
import { postTable } from './post.schema';
import { eq } from 'drizzle-orm';
import type { NewPost, Post } from './post.types';
export const postRepo = {
async create(data: NewPost): Promise<Post> {
const [post] = await db.insert(postTable).values(data).returning();
return post;
},
async findById(id: string): Promise<Post | undefined> {
return db.query.postTable.findFirst({
where: eq(postTable.id, id),
});
},
async findByAuthor(authorId: string): Promise<Post[]> {
return db.query.postTable.findMany({
where: eq(postTable.authorId, authorId),
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
},
};
No business logic here. No HTTP. No auth checks. Just queries. If you find yourself writing an if that is not about query construction, it belongs in the service layer.
// modules/post/post.service.ts
import { postRepo } from './post.repo';
import { HttpError } from '@/lib/errors';
import type { CreatePostInput } from './post.validation';
import type { Post } from './post.types';
export const postService = {
async create(input: CreatePostInput, authorId: string): Promise<Post> {
return postRepo.create({ ...input, authorId });
},
async getById(id: string, requesterId: string): Promise<Post> {
const post = await postRepo.findById(id);
if (!post) throw new HttpError(404, 'Post not found');
if (post.authorId !== requesterId) throw new HttpError(403, 'Forbidden');
return post;
},
async getByAuthor(authorId: string): Promise<Post[]> {
return postRepo.findByAuthor(authorId);
},
};
Services throw HttpError -- never return error objects. Ownership checks live here, not in routes. Services must not import Request or Response. This keeps them testable and decoupled from the HTTP layer.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getUserFromRequest } from '@/lib/auth';
import { postService } from '@/modules/post';
import { createPostSchema } from '@/modules/post/post.validation';
import { handleError } from '@/lib/errors';
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req);
const body = await req.json();
const parsed = createPostSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
const post = await postService.create(parsed.data, user.id);
return NextResponse.json(post, { status: 201 });
} catch (error: unknown) {
return handleError(error);
}
}
Routes do exactly three things: validate input, call a service, return a response. handleError converts any HttpError into the right status code automatically.
// modules/post/index.ts
export { postService } from './post.service';
export type { Post, PostSummary, NewPost } from './post.types';
Import from the index everywhere outside the module, never from internal files directly. This means you can refactor post.repo.ts without touching any caller outside modules/post/.
When a bug appears, you know exactly which file to open:
post.repo.tspost.service.tspost.validation.tspost.relations.tsNew team members onboard fast because every module looks the same. Claude Code follows it reliably because the pattern is described in CLAUDE.md -- and the feature-module skill scaffolds all 7 files in one command.
The next time you add a feature, run /feature-module <name> and you get the full skeleton. Fill in the schema, write a couple of queries, add the business rule, and wire the route. Predictable structure is the only kind that survives a second developer.