Overview
A URL shortener looks tiny from the outside — paste a long link, get a short one — but building one well touches many production concerns: generating unique codes, issuing HTTP redirects, counting clicks safely, stopping abuse with rate limits, and proving it all works with automated tests. That makes it an excellent capstone for the NestJS course.
You will store links in SQLite with TypeORM, redirect GET /:code to the original URL while incrementing a click counter, expose a stats endpoint, limit how fast a client can create links, time every request with an interceptor, and finish with Supertest end-to-end tests that exercise the real HTTP pipeline.
- A
Linkentity with a unique shortcode, the targeturl, aclickscounter and timestamps. - Collision-safe random code generation with Node's built-in
cryptomodule, plus optional custom aliases. - A dynamic
@Redirect()that sends visitors to the original URL with a 302 status. - An atomic click counter using TypeORM's
increment(). - Global rate limiting with
@nestjs/throttlerand a stricter limit on link creation. - A
TimingInterceptorthat adds anX-Response-Timeheader to every response. - End-to-end tests with Supertest against an in-memory database.
Prerequisites
- Controllers, status codes and
@Redirect()— lesson 4. - Validation with class-validator — lesson 6.
- Interceptors and RxJS
tap()— lesson 10. - TypeORM repositories — lesson 12.
- Testing with Vitest and Supertest — lesson 13.
- Rate limiting with
@nestjs/throttler— lesson 14.
Project Structure
url-shortener/├── src/│ ├── common/│ │ └── timing.interceptor.ts│ ├── links/│ │ ├── dto/│ │ │ └── create-link.dto.ts│ │ ├── link.entity.ts│ │ ├── links.controller.ts│ │ ├── links.module.ts│ │ └── links.service.ts│ ├── app.module.ts│ └── main.ts└── test/ └── links.e2e-spec.tsAppModule builds the database connection from an environment variable, which is what lets the e2e tests swap in a throwaway in-memory database. The TimingInterceptor lives in common/ because it is cross-cutting — it is not specific to links.
Step 1: Project Setup & the Link Entity
nest new url-shortenercd url-shortenernpm install @nestjs/typeorm typeorm better-sqlite3 @nestjs/throttler class-validator class-transformernpm install-scripts approve better-sqlite3 # allow its native build step (npm 12+)npm rebuild better-sqlite3nest g module linksnest g controller links --no-specnest g service links --no-specimport { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('links')export class Link { @PrimaryGeneratedColumn() id: number;
@Index({ unique: true }) @Column({ length: 32 }) code: string;
@Column('text') url: string;
@Column({ default: 0 }) clicks: number;
@Column({ type: 'datetime', nullable: true }) lastClickedAt: Date | null;
@CreateDateColumn() createdAt: Date;}import { IsOptional, IsUrl, Matches, MaxLength } from 'class-validator';
export class CreateLinkDto { @IsUrl({ protocols: ['http', 'https'], require_protocol: true }) @MaxLength(2048) url: string;
// Optional custom alias, e.g. "nest-course" → /nest-course @IsOptional() @Matches(/^[a-zA-Z0-9-]{3,32}$/, { message: 'alias must be 3-32 characters: letters, numbers or dashes', }) alias?: string;}Step 2: Generate Codes & Build the LinksService
randomBytes(5).toString('base64url') produces a 7-character URL-safe code with over a trillion possibilities, and the service still checks the database and retries on the rare collision. A small reserved list stops aliases such as links from shadowing your own API routes. increment() updates the counter in a single SQL statement, so two visitors clicking at the same moment can never overwrite each other's count.
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';import { InjectRepository } from '@nestjs/typeorm';import { randomBytes } from 'crypto';import { Repository } from 'typeorm';import { CreateLinkDto } from './dto/create-link.dto.js';import { Link } from './link.entity.js';
const RESERVED = new Set(['links', 'api', 'docs', 'health']);
@Injectable()export class LinksService { constructor(@InjectRepository(Link) private readonly links: Repository<Link>) {}
async create(dto: CreateLinkDto): Promise<Link> { const code = dto.alias ?? (await this.generateUniqueCode()); if (RESERVED.has(code.toLowerCase())) { throw new ConflictException(`"${code}" is a reserved word`); } if (await this.links.existsBy({ code })) { throw new ConflictException(`The alias "${code}" is already taken`); } return this.links.save(this.links.create({ code, url: dto.url })); }
async findByCode(code: string): Promise<Link> { const link = await this.links.findOneBy({ code }); if (!link) { throw new NotFoundException(`No link found for "${code}"`); } return link; }
async registerClick(code: string): Promise<string> { const link = await this.findByCode(code); await this.links.increment({ id: link.id }, 'clicks', 1); // atomic UPDATE ... SET clicks = clicks + 1 await this.links.update({ id: link.id }, { lastClickedAt: new Date() }); return link.url; }
findAll(): Promise<Link[]> { return this.links.find({ order: { clicks: 'DESC' }, take: 20 }); }
private async generateUniqueCode(): Promise<string> { for (let attempt = 0; attempt < 5; attempt++) { const code = randomBytes(5).toString('base64url'); // 7 URL-safe characters if (!(await this.links.existsBy({ code }))) { return code; } } throw new ConflictException('Could not generate a unique code, please retry'); }}Step 3: Redirects & Stats in the Controller
The redirect route is the interesting one. An empty @Redirect() decorator tells Nest "this handler redirects"; whatever object the handler returns — { url, statusCode } — overrides the target dynamically. Because GET /:code would also match /links, the static routes are declared in a separate controller with the links prefix, and LinksModule lists that controller first so its routes are registered before the catch-all.
import { Body, Controller, Get, HttpStatus, Param, Post, Redirect, Req } from '@nestjs/common';import { Throttle } from '@nestjs/throttler';import type { Request } from 'express';import { CreateLinkDto } from './dto/create-link.dto.js';import { LinksService } from './links.service.js';
@Controller('links')export class LinksController { constructor(private readonly linksService: LinksService) {}
@Throttle({ default: { limit: 5, ttl: 60_000 } }) // max 5 new links per minute per client @Post() async create(@Body() dto: CreateLinkDto, @Req() req: Request) { const link = await this.linksService.create(dto); return { ...link, shortUrl: `${req.protocol}://${req.get('host')}/${link.code}` }; }
@Get() findAll() { return this.linksService.findAll(); }
@Get(':code/stats') stats(@Param('code') code: string) { return this.linksService.findByCode(code); }}
@Controller()export class RedirectController { constructor(private readonly linksService: LinksService) {}
@Get(':code') @Redirect() async follow(@Param('code') code: string) { const url = await this.linksService.registerClick(code); return { url, statusCode: HttpStatus.FOUND }; // 302 }}import { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { Link } from './link.entity.js';import { LinksController, RedirectController } from './links.controller.js';import { LinksService } from './links.service.js';
@Module({ imports: [TypeOrmModule.forFeature([Link])], // Order matters: /links routes must be registered before the /:code catch-all. controllers: [LinksController, RedirectController], providers: [LinksService],})export class LinksModule {}Step 4: Rate Limiting & a Timing Interceptor
The ThrottlerGuard registered with APP_GUARD gives every route a default limit of 100 requests per minute, and the @Throttle() override on POST /links tightens that to 5. The interceptor measures how long each handler takes and writes it into a response header using RxJS tap().
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';import { Response } from 'express';import { Observable, tap } from 'rxjs';
@Injectable()export class TimingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { const start = process.hrtime.bigint(); const response = context.switchToHttp().getResponse<Response>(); return next.handle().pipe( tap(() => { const ms = Number(process.hrtime.bigint() - start) / 1_000_000; if (!response.headersSent) { response.setHeader('X-Response-Time', `${ms.toFixed(1)}ms`); } }), ); }}import { Module } from '@nestjs/common';import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';import { TypeOrmModule } from '@nestjs/typeorm';import { TimingInterceptor } from './common/timing.interceptor.js';import { LinksModule } from './links/links.module.js';
@Module({ imports: [ TypeOrmModule.forRoot({ type: 'better-sqlite3', database: process.env.DB_PATH ?? 'links.sqlite', // ':memory:' in tests autoLoadEntities: true, synchronize: true, // development only — use migrations in production }), ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), LinksModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_INTERCEPTOR, useClass: TimingInterceptor }, ],})export class AppModule {}Step 5: End-to-End Tests
New NestJS 12 projects run tests with Vitest and use ES modules, so the test sets DB_PATH to :memory: and then loads AppModule with a dynamic await import() — a normal top-of-file import would run before the variable is set. Every test run therefore starts with an empty database that disappears afterwards. It then drives the real HTTP pipeline: validation, creation, the redirect, the click counter and the rate limit.
import { INestApplication, ValidationPipe } from '@nestjs/common';import { Test } from '@nestjs/testing';import request from 'supertest';
describe('Links (e2e)', () => { let app: INestApplication;
beforeAll(async () => { process.env.DB_PATH = ':memory:'; // Dynamic import so AppModule is evaluated *after* DB_PATH is set. const { AppModule } = await import('../src/app.module.js'); const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init(); }, 30_000); // generous timeout: the first cold start can take a few seconds
afterAll(async () => { await app.close(); });
it('rejects an invalid URL', () => { return request(app.getHttpServer()).post('/links').send({ url: 'not-a-url' }).expect(400); });
it('creates a link, redirects, and counts the click', async () => { const created = await request(app.getHttpServer()) .post('/links') .send({ url: 'https://docs.nestjs.com', alias: 'nest-docs' }) .expect(201); expect(created.body.shortUrl).toMatch(/\/nest-docs$/);
await request(app.getHttpServer()) .get('/nest-docs') .expect(302) .expect('Location', 'https://docs.nestjs.com');
const stats = await request(app.getHttpServer()).get('/links/nest-docs/stats').expect(200); expect(stats.body.clicks).toBe(1); });
it('rejects a duplicate alias', () => { return request(app.getHttpServer()) .post('/links') .send({ url: 'https://example.com', alias: 'nest-docs' }) .expect(409); });
it('returns 404 for an unknown code', () => { return request(app.getHttpServer()).get('/does-not-exist').expect(404); });
it('rate-limits link creation', async () => { // Earlier tests already used 3 of the 5 allowed creations this minute. for (let i = 0; i < 2; i++) { await request(app.getHttpServer()).post('/links').send({ url: `https://example.com/${i}` }); } await request(app.getHttpServer()) .post('/links') .send({ url: 'https://example.com/one-too-many' }) .expect(429); });});Complete Code
Steps 1–5 contain every file in full. The final piece is main.ts, which enables validation for the running server:
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 })); await app.listen(process.env.PORT ?? 3000);}await bootstrap();Run the server with npm run start:dev and the tests with npm run test:e2e.
Sample Run
Click Run to see what this code prints.
Extend This Project
- Add an optional
expiresAtdate and return410 Gonefor expired links. - Record each click in a separate
Clickentity (timestamp, referrer, user agent) and add a daily-clicks chart endpoint. - Protect link creation with the JWT auth service from the previous project so each link has an owner.
- Cache popular redirects in memory with
@nestjs/cache-managerto avoid a database lookup per click. - Add Swagger docs and deploy the service with the Dockerfile from lesson 14.
Summary
You built a production-style service that combines nearly everything from the course: validated input, database persistence with atomic updates, dynamic redirects, global guards and interceptors, rate limiting and end-to-end tests that run against a disposable database. It is a strong, deployable portfolio project that demonstrates real backend judgement.