Overview
Real APIs store data in a database and almost always deal with related data — a post has many comments, an order has many items. This project builds a blog backend where posts and their comments are saved with TypeORM, so you practise entities, a one-to-many relation, injected repositories and paginated queries in one focused codebase.
To keep setup simple, the project uses SQLite through the better-sqlite3 driver: the whole database is a single file (blog.sqlite) created automatically, with no database server to install. Because TypeORM hides the driver behind the same API, moving to MySQL or PostgreSQL later is a configuration change, not a rewrite.
- A TypeORM connection configured in
AppModulewithautoLoadEntities. - A
Postentity and aCommententity linked with@OneToMany/@ManyToOne. - Validated DTOs for creating posts, updating posts, adding comments and paginating.
- A
PostsServicethat uses two injected repositories, includingfindAndCount()for pagination. - A
PostsControllerwith routes for posts and a nestedPOST /posts/:id/commentsroute.
Prerequisites
- Controllers, services and DTO validation — lessons 4 to 6.
- TypeORM entities, repositories and relations — lesson 12.
- Comfort with async/await, since every repository call returns a Promise.
- The Task Manager REST API project (recommended, not required).
Project Structure
blog-api/└── src/ ├── posts/ │ ├── dto/ │ │ ├── create-comment.dto.ts │ │ ├── create-post.dto.ts │ │ ├── pagination.dto.ts │ │ └── update-post.dto.ts │ ├── entities/ │ │ ├── comment.entity.ts │ │ └── post.entity.ts │ ├── posts.controller.ts │ ├── posts.module.ts │ └── posts.service.ts ├── app.module.ts └── main.tsComments only ever exist inside a post, so they live in the same posts feature module rather than a module of their own. PostsModule registers both entities with TypeOrmModule.forFeature(), which makes a repository for each injectable in PostsService.
Step 1: Set Up TypeORM with SQLite
nest new blog-apicd blog-apinpm install @nestjs/typeorm typeorm better-sqlite3 class-validator class-transformer @nestjs/mapped-typesnpm install-scripts approve better-sqlite3 # allow its native build step (npm 12+)npm rebuild better-sqlite3nest g module postsnest g controller posts --no-specnest g service posts --no-specimport { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { PostsModule } from './posts/posts.module.js';
@Module({ imports: [ TypeOrmModule.forRoot({ type: 'better-sqlite3', database: 'blog.sqlite', autoLoadEntities: true, // picks up every entity passed to forFeature() synchronize: true, // creates tables automatically — development only! }), PostsModule, ],})export class AppModule {}synchronize: true rewrites your tables to match your entities on every start. It is perfect for a practice project, but in production you should switch it off and use migrations, as explained in lesson 12.
Step 2: Define the Post & Comment Entities
Each entity is a class whose decorated properties become table columns. The relation is declared on both sides: a Post has many Comments, and each Comment belongs to one Post. onDelete: 'CASCADE' tells the database to delete a post's comments automatically when the post is deleted.
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn,} from 'typeorm';import { Comment } from './comment.entity.js';
@Entity('posts')export class Post { @PrimaryGeneratedColumn() id: number;
@Column({ length: 150 }) title: string;
@Column('text') body: string;
@Column({ default: false }) published: boolean;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;
@OneToMany(() => Comment, (comment) => comment.post) comments: Comment[];}import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';import { Post } from './post.entity.js';
@Entity('comments')export class Comment { @PrimaryGeneratedColumn() id: number;
@Column({ length: 60 }) author: string;
@Column('text') text: string;
@CreateDateColumn() createdAt: Date;
@ManyToOne(() => Post, (post) => post.comments, { onDelete: 'CASCADE' }) post: Post;}import { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { Comment } from './entities/comment.entity.js';import { Post } from './entities/post.entity.js';import { PostsController } from './posts.controller.js';import { PostsService } from './posts.service.js';
@Module({ imports: [TypeOrmModule.forFeature([Post, Comment])], controllers: [PostsController], providers: [PostsService],})export class PostsModule {}Step 3: Create the DTOs
Query string values always arrive as strings, so PaginationDto uses @Type(() => Number) from class-transformer to convert ?page=2 into the number 2 before @Min and @Max check it. Defaults on the properties mean a plain GET /posts still works.
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreatePostDto { @IsString() @IsNotEmpty() @MaxLength(150) title: string;
@IsString() @IsNotEmpty() body: string;
@IsOptional() @IsBoolean() published?: boolean;}import { PartialType } from '@nestjs/mapped-types';import { CreatePostDto } from './create-post.dto.js';
export class UpdatePostDto extends PartialType(CreatePostDto) {}import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class CreateCommentDto { @IsString() @IsNotEmpty() @MaxLength(60) author: string;
@IsString() @IsNotEmpty() @MaxLength(1000) text: string;}import { Type } from 'class-transformer';import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class PaginationDto { @IsOptional() @Type(() => Number) @IsInt() @Min(1) page: number = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) limit: number = 10;}Step 4: Build the PostsService
findAndCount() runs the paginated query and a count query together, which is exactly what a client needs to render "page 2 of 5". findOne() loads the post together with its comments through the relations option, and addComment() reuses findOne() so commenting on a missing post correctly returns 404.
import { Injectable, NotFoundException } from '@nestjs/common';import { InjectRepository } from '@nestjs/typeorm';import { Repository } from 'typeorm';import { CreateCommentDto } from './dto/create-comment.dto.js';import { CreatePostDto } from './dto/create-post.dto.js';import { PaginationDto } from './dto/pagination.dto.js';import { UpdatePostDto } from './dto/update-post.dto.js';import { Comment } from './entities/comment.entity.js';import { Post } from './entities/post.entity.js';
@Injectable()export class PostsService { constructor( @InjectRepository(Post) private readonly posts: Repository<Post>, @InjectRepository(Comment) private readonly comments: Repository<Comment>, ) {}
create(dto: CreatePostDto): Promise<Post> { return this.posts.save(this.posts.create(dto)); }
async findAll({ page, limit }: PaginationDto) { const [data, total] = await this.posts.findAndCount({ order: { createdAt: 'DESC' }, skip: (page - 1) * limit, take: limit, }); return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } }; }
async findOne(id: number): Promise<Post> { const post = await this.posts.findOne({ where: { id }, relations: { comments: true }, order: { comments: { createdAt: 'ASC' } }, }); if (!post) { throw new NotFoundException(`Post ${id} not found`); } return post; }
async update(id: number, dto: UpdatePostDto): Promise<Post> { const post = await this.findOne(id); Object.assign(post, dto); return this.posts.save(post); }
async remove(id: number): Promise<void> { const post = await this.findOne(id); await this.posts.remove(post); }
async addComment(postId: number, dto: CreateCommentDto): Promise<Comment> { const post = await this.findOne(postId); const comment = this.comments.create({ ...dto, post }); const saved = await this.comments.save(comment); // Avoid sending the whole parent post back inside the comment. const { post: _post, ...rest } = saved; return rest as Comment; }}Step 5: Build the PostsController
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query,} from '@nestjs/common';import { CreateCommentDto } from './dto/create-comment.dto.js';import { CreatePostDto } from './dto/create-post.dto.js';import { PaginationDto } from './dto/pagination.dto.js';import { UpdatePostDto } from './dto/update-post.dto.js';import { PostsService } from './posts.service.js';
@Controller('posts')export class PostsController { constructor(private readonly postsService: PostsService) {}
@Post() create(@Body() dto: CreatePostDto) { return this.postsService.create(dto); }
@Get() findAll(@Query() pagination: PaginationDto) { return this.postsService.findAll(pagination); }
@Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.postsService.findOne(id); }
@Patch(':id') update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePostDto) { return this.postsService.update(id, dto); }
@Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) remove(@Param('id', ParseIntPipe) id: number) { return this.postsService.remove(id); }
@Post(':id/comments') addComment(@Param('id', ParseIntPipe) id: number, @Body() dto: CreateCommentDto) { return this.postsService.addComment(id, dto); }}Complete Code
Steps 1–5 contain every file in full. The last piece is main.ts, which enables validation and the type transformation that PaginationDto depends on:
import { ValidationPipe } from '@nestjs/common';import { NestFactory } from '@nestjs/core';import { AppModule } from './app.module.js';
async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), ); await app.listen(process.env.PORT ?? 3000);}await bootstrap();Sample Run
Click Run to see what this code prints.
Stop and restart the server, then list the posts again — the data is still there, because it now lives in blog.sqlite instead of memory.
Extend This Project
- Add a
GET /posts?published=truefilter by adding apublishedfield to a query DTO and passing it intowhere. - Add a
Tagentity with a@ManyToManyrelation and a@JoinTable()to tag posts. - Switch to MySQL by installing
mysql2and changing only theTypeOrmModule.forRoot()options (read them fromConfigService, as in lesson 11). - Add
DELETE /posts/:postId/comments/:commentIdthat checks the comment really belongs to that post. - Turn off
synchronizeand generate your first TypeORM migration.
Summary
You built a persistent blog API with two related entities, injected repositories, relation loading and paginated responses with metadata. These are the exact patterns behind most production NestJS backends, whichever SQL database they use.