Adding a Module
Walks through adding a new feature module end-to-end: backend schema → zod + DTO → service → controller → register → regenerate spec → consume from frontend. The example is a fictional Tasks module with a small CRUD surface (list mine, create, update, delete).
The Items module already in the template (backend/src/items/) follows this exact pattern; read it side-by-side if you get stuck.
1. Define the persistence layer
yarn migrate:make tasks (run from backend/) generates a timestamped Knex migration. Fill it in:
// backend/migrations/<timestamp>_tasks.ts
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('tasks', (t) => {
t.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
t.string('title').notNullable();
t.boolean('completed').notNullable().defaultTo(false);
// users.id is varchar (NextAuth adapter ids), not uuid - an FK column
// must match the referenced column's type or the migration fails.
t.string('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
t.timestamps(true, true); // created_at, updated_at
t.index(['user_id']);
t.index(['created_at']);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('tasks');
}
Then add the row interface in backend/src/db/types.ts:
export interface TaskRow {
id: string;
title: string;
completed: boolean;
user_id: string;
created_at: Date;
updated_at: Date;
}
Run yarn migrate to apply.
2. Define schemas + DTOs
*.schemas.ts is the heart of every feature. It holds the zod schemas, the DTO classes (via nestjs-zod's createZodDto), and the extractor function.
// backend/src/tasks/tasks.schemas.ts
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { TaskRow } from 'src/db/types';
// ========================================
// Response schemas
// ========================================
export const TaskSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
userId: z.string(),
createdAt: z.string(),
});
export class TaskDto extends createZodDto(TaskSchema) {}
export const DeleteResultSchema = z.object({ success: z.boolean() });
export class DeleteResultDto extends createZodDto(DeleteResultSchema) {}
// ========================================
// Request schemas
// ========================================
export const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
});
export class CreateTaskDto extends createZodDto(CreateTaskSchema) {}
export const UpdateTaskSchema = z
.object({
title: z.string().min(1).max(200).optional(),
completed: z.boolean().optional(),
})
.strict();
export class UpdateTaskDto extends createZodDto(UpdateTaskSchema) {}
// ========================================
// DTO Extractor Function
// ========================================
export const extractTaskDtoFromRow = (row: TaskRow) => ({
id: row.id,
title: row.title,
completed: row.completed,
userId: row.user_id,
createdAt: row.created_at.toISOString(),
});
The global ZodValidationPipe (wired in app.module.ts) automatically validates every @Body() input: TaskDto against the underlying schema. Bad input → automatic 400 with field-level errors.
3. Write the service
// backend/src/tasks/tasks.service.ts
import { Inject, Injectable } from '@nestjs/common';
import type { Knex } from 'knex';
import { KNEX_CONNECTION } from 'src/knex/knex.module';
import { TaskRow } from 'src/db/types';
@Injectable()
export class TasksService {
constructor(@Inject(KNEX_CONNECTION) private readonly db: Knex) {}
async findByUser(userId: string): Promise<TaskRow[]> {
return this.db<TaskRow>('tasks').where({ user_id: userId }).orderBy('created_at', 'desc');
}
async findById(id: string): Promise<TaskRow | undefined> {
return this.db<TaskRow>('tasks').where({ id }).first();
}
async create(input: { title: string; userId: string }): Promise<TaskRow> {
const [row] = await this.db<TaskRow>('tasks')
.insert({ title: input.title, user_id: input.userId })
.returning('*');
return row;
}
async update(input: { id: string; title?: string; completed?: boolean }): Promise<TaskRow | undefined> {
const { id, ...set } = input;
if (Object.keys(set).length === 0) return this.findById(id);
const [row] = await this.db<TaskRow>('tasks')
.where({ id })
.update({ title: set.title, completed: set.completed })
.returning('*');
return row;
}
async delete(id: string): Promise<boolean> {
const n = await this.db<TaskRow>('tasks').where({ id }).delete();
return n > 0;
}
}
4. Write the controller
REST + zod via NestJS decorators. Stack SessionGuard for auth, throw ForbiddenException for ownership checks. Add @ApiTags, @ApiOperation, and @ApiOkResponse({ type: ... }) so the OpenAPI spec carries the right metadata.
// backend/src/tasks/tasks.controller.ts
import {
Body, Controller, Delete, ForbiddenException, Get,
NotFoundException, Param, Patch, Post, UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags,
} from '@nestjs/swagger';
import { TasksService } from './tasks.service';
import {
CreateTaskDto, DeleteResultDto, TaskDto, UpdateTaskDto,
extractTaskDtoFromRow,
} from './tasks.schemas';
import { SessionGuard } from 'src/auth/auth.guard';
import { CurrentUser } from 'src/auth/decorators';
import { UserRow } from 'src/db/types';
@ApiTags('tasks')
@Controller('tasks')
@UseGuards(SessionGuard)
@ApiBearerAuth()
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Get()
@ApiOperation({ summary: 'List my tasks' })
@ApiOkResponse({ type: TaskDto, isArray: true })
async list(@CurrentUser() user: UserRow): Promise<TaskDto[]> {
const rows = await this.tasks.findByUser(user.id);
return rows.map(extractTaskDtoFromRow) as TaskDto[];
}
@Post()
@ApiOperation({ summary: 'Create a task' })
@ApiOkResponse({ type: TaskDto })
async create(
@Body() body: CreateTaskDto,
@CurrentUser() user: UserRow,
): Promise<TaskDto> {
const row = await this.tasks.create({ title: body.title, userId: user.id });
return extractTaskDtoFromRow(row) as TaskDto;
}
@Patch(':id')
@ApiOperation({ summary: 'Update a task' })
@ApiOkResponse({ type: TaskDto })
async update(
@Param('id') id: string,
@Body() body: UpdateTaskDto,
@CurrentUser() user: UserRow,
): Promise<TaskDto> {
const existing = await this.tasks.findById(id);
if (!existing) throw new NotFoundException('Task not found');
if (existing.user_id !== user.id && !user.is_admin) {
throw new ForbiddenException('Not your task');
}
const row = await this.tasks.update({ id, ...body });
if (!row) throw new NotFoundException('Task not found');
return extractTaskDtoFromRow(row) as TaskDto;
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a task' })
@ApiOkResponse({ type: DeleteResultDto })
async delete(
@Param('id') id: string,
@CurrentUser() user: UserRow,
): Promise<DeleteResultDto> {
const existing = await this.tasks.findById(id);
if (!existing) throw new NotFoundException('Task not found');
if (existing.user_id !== user.id && !user.is_admin) {
throw new ForbiddenException('Not your task');
}
const success = await this.tasks.delete(id);
return { success } as DeleteResultDto;
}
}
Pick the right guards based on access level:
- Public route: omit
@UseGuards. - Logged-in route:
@UseGuards(SessionGuard)+@ApiBearerAuth(). - Admin route:
@UseGuards(SessionGuard, AdminGuard).
For non-trivial query/param shapes (anything beyond a string), define a small zod schema and .safeParse() inline: see blog.controller.ts's tag parameter for the canonical pattern.
5. Wire the module
// backend/src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from 'src/auth/auth.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [AuthModule],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}
KNEX_CONNECTION is provided globally by KnexModule, so no extra imports are needed for data access.
Register in the root module:
// backend/src/app.module.ts
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
// ... existing modules ...
TasksModule,
],
})
export class AppModule {}
6. Regenerate the OpenAPI spec
The canonical template/openapi.json is the contract: regenerate it after any controller, schema, or DTO change. CI fails on drift.
cd backend
yarn build
npx ts-node -r tsconfig-paths/register scripts/dump-openapi.ts
# emits openapi.runtime.json in cwd; copy to canonical:
cp openapi.runtime.json ../openapi.json
rm openapi.runtime.json
CI's openapi-drift job runs the dump script and diff -u against the canonical file. If it drifts, the build fails; regenerate before merging.
7. Regenerate frontend types
cd frontend
yarn generate-types
This runs openapi-typescript ../openapi.json -o types/api.d.ts. CI also runs this and git diff --exit-code; uncommitted drift fails the build.
While you're at it, re-export the new DTO types from lib/api.ts so consumers don't have to dig into components['schemas']:
// frontend/lib/api.ts
type Schemas = components['schemas'];
// ... existing ones ...
export type Task = Schemas['TaskDto'];
8. Consume from the frontend
Read from a Server Component
// app/tasks/page.tsx
import { serverApi } from '@/lib/api';
export default async function Page() {
const client = await serverApi();
const { data, error } = await client.GET('/tasks');
if (error || !data) return <p>Failed to load.</p>;
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
serverApi() reads the NextAuth httpOnly cookie via next/headers and attaches the Bearer header server-side: the token never touches client JS.
Mutate via a Server Action
// app/tasks/actions.ts
'use server';
import { serverApi } from '@/lib/api';
import { revalidatePath } from 'next/cache';
export type CreateTaskState = { error: string | null };
export async function createTaskAction(
_prev: CreateTaskState,
formData: FormData,
): Promise<CreateTaskState> {
const client = await serverApi();
const { error } = await client.POST('/tasks', {
body: { title: String(formData.get('title')) },
});
if (error) return { error: 'Create failed' };
revalidatePath('/tasks');
return { error: null };
}
// app/tasks/NewTaskForm.tsx (client island)
'use client';
import { useActionState } from 'react';
import { createTaskAction } from './actions';
export function NewTaskForm() {
const [state, formAction, pending] = useActionState(
createTaskAction,
{ error: null },
);
return (
<form action={formAction}>
<input name="title" required />
<button type="submit" disabled={pending}>Add</button>
{state.error ? <p role="alert">{state.error}</p> : null}
</form>
);
}
openapi-fetch types both halves: body is checked against CreateTaskSchema, data is the typed TaskDto.
Checklist
- Knex migration + row interface in
db/types.ts -
*.schemas.tswith zod schemas, DTO classes, extractor -
*.service.tswith data access -
*.controller.tswith REST decorators, guards,@ApiOkResponse -
*.module.tsregistering controller/service - Module added to
app.module.ts -
yarn build && npx ts-node scripts/dump-openapi.ts, copied totemplate/openapi.json -
yarn generate-typesinfrontend/ - Frontend consumer (RSC + Server Action) wired
Related
- Architecture - Module structure overview, REST surface, DTO extractor pattern
- Auth Flow - Guard reference, RSC + Server Action patterns
- Home - Project overview