LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
NestJSIntermediate~2.5 hours

Blog API with TypeORM

Build a database-backed blog API with posts and comments, a one-to-many relation, pagination and SQLite persistence.

TypeORMEntities & RelationsRepositoriesPagination

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.

What You'll Build
  • A TypeORM connection configured in AppModule with autoLoadEntities.
  • A Post entity and a Comment entity linked with @OneToMany / @ManyToOne.
  • Validated DTOs for creating posts, updating posts, adding comments and paginating.
  • A PostsService that uses two injected repositories, including findAndCount() for pagination.
  • A PostsController with routes for posts and a nested POST /posts/:id/comments route.

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

Comments 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-api
cd blog-api
npm install @nestjs/typeorm typeorm better-sqlite3 class-validator class-transformer @nestjs/mapped-types
npm install-scripts approve better-sqlite3 # allow its native build step (npm 12+)
npm rebuild better-sqlite3
nest g module posts
nest g controller posts --no-spec
nest g service posts --no-spec
src/app.module.ts
import { 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 Is for Learning

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.

src/posts/entities/post.entity.ts
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[];
}
src/posts/entities/comment.entity.ts
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;
}
src/posts/posts.module.ts
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.

src/posts/dto/create-post.dto.ts
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;
}
src/posts/dto/update-post.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto.js';
export class UpdatePostDto extends PartialType(CreatePostDto) {}
src/posts/dto/create-comment.dto.ts
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
@MaxLength(60)
author: string;
@IsString()
@IsNotEmpty()
@MaxLength(1000)
text: string;
}
src/posts/dto/pagination.dto.ts
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.

src/posts/posts.service.ts
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

src/posts/posts.controller.ts
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:

src/main.ts
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

Testing with curl

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=true filter by adding a published field to a query DTO and passing it into where.
  • Add a Tag entity with a @ManyToMany relation and a @JoinTable() to tag posts.
  • Switch to MySQL by installing mysql2 and changing only the TypeOrmModule.forRoot() options (read them from ConfigService, as in lesson 11).
  • Add DELETE /posts/:postId/comments/:commentId that checks the comment really belongs to that post.
  • Turn off synchronize and 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.