DEV Community

Cover image for Add Click-Tracking to the Nest.js Short Link Service
Leapcell
Leapcell

Posted on

Add Click-Tracking to the Nest.js Short Link Service

Cover

In the previous article, we built a basic short link service.

To become a comprehensive short link service, it's necessary to add data analysis functionality. Data analysis is one of the core values of a short link service. By tracking link clicks, we can understand its propagation effectiveness, user profiles, and other information.

Next, we will add click-tracking capabilities to our service, recording the time, IP address, and device information for each click.

1. Create the Database Entity for a Click Record

First, we need a new database table to store each click record. Similarly, we will define its structure by creating a TypeORM entity.

Create a new folder named click in the src directory, and within it, create a click.entity.ts file:

// src/click/click.entity.ts

import { ShortLink } from '../short-link/short-link.entity';
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  ManyToOne,
  JoinColumn,
} from 'typeorm';

@Entity()
export class Click {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  // Establish a many-to-one relationship with the ShortLink entity
  // This means one short link can correspond to many click records
  @ManyToOne(() => ShortLink, (shortLink) => shortLink.clicks)
  @JoinColumn({ name: 'shortLinkId' }) // Creates a foreign key column in the database
  shortLink: ShortLink;

  @CreateDateColumn()
  clickedAt: Date;

  @Column({ type: 'varchar', length: 45 }) // To store IPv4 or IPv6 addresses
  ipAddress: string;

  @Column({ type: 'text' })
  userAgent: string;
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • @ManyToOne: This decorator establishes the relationship between Click and ShortLink. Many (Many) clicks can be associated with one (One) short link.
  • @JoinColumn: Specifies the name of the foreign key column, which is used to associate the two tables at the database level.

2. Update the ShortLink Entity

To be able to query all click records from a short link, we also need to update the short-link.entity.ts file to establish a bidirectional relationship.

Open src/short-link/short-link.entity.ts and add the clicks property:

// src/short-link/short-link.entity.ts
import { Click } from '../click/click.entity'; // Import the Click entity
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  Index,
  OneToMany,
} from 'typeorm';

@Entity()
export class ShortLink {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  // ... other existing fields ...
  @Column({ unique: true })
  @Index()
  shortCode: string;

  @Column({ type: 'text' })
  longUrl: string;

  @CreateDateColumn()
  createdAt: Date;

  // New property
  @OneToMany(() => Click, (click) => click.shortLink)
  clicks: Click[];
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • @OneToMany: Establishes a one-to-many relationship from ShortLink to Click. One (One) short link can have many (Many) click records.

3. Create the Module and Logic for the Click Service

Just like with ShortLink, we will create a dedicated module and service for Click to handle related logic and maintain code modularity.

Use the Nest CLI to quickly generate the necessary files:

nest generate resource click
Enter fullscreen mode Exit fullscreen mode

Configure ClickModule

Open src/click/click.module.ts, import TypeOrmModule, and register the Click entity.

// src/click/click.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickService } from './click.service';
import { Click } from './click.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Click])],
  providers: [ClickService],
  exports: [ClickService], // Export ClickService so it can be used by other modules
})
export class ClickModule {}
Enter fullscreen mode Exit fullscreen mode

Implement ClickService

Open src/click/click.service.ts and write the logic for recording clicks.

// src/click/click.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Click } from './click.entity';
import { ShortLink } from '../short-link/short-link.entity';

@Injectable()
export class ClickService {
  constructor(
    @InjectRepository(Click)
    private readonly clickRepository: Repository<Click>
  ) {}

  async recordClick(shortLink: ShortLink, ipAddress: string, userAgent: string): Promise<Click> {
    const newClick = this.clickRepository.create({
      shortLink,
      ipAddress,
      userAgent,
    });

    return this.clickRepository.save(newClick);
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Integrate Click Tracking During Redirection

Now, we will integrate the tracking logic into the redirect method of the ShortLinkController.

Update ShortLinkModule

First, ShortLinkModule needs to import ClickModule to be able to use ClickService.

// src/short-link/short-link.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShortLinkController } from './short-link.controller';
import { ShortLinkService } from './short-link.service';
import { ShortLink } from './short-link.entity';
import { ClickModule } from '../click/click.module'; // Import ClickModule

@Module({
  imports: [TypeOrmModule.forFeature([ShortLink]), ClickModule], // Add ClickModule
  controllers: [ShortLinkController],
  providers: [ShortLinkService],
})
export class ShortLinkModule {}
Enter fullscreen mode Exit fullscreen mode

Modify ShortLinkController

Open src/short-link/short-link.controller.ts, inject ClickService, and call it in the redirect method.

// src/short-link/short-link.controller.ts
import { Controller, Get, Post, Body, Param, Res, NotFoundException, Req } from '@nestjs/common';
import { Response, Request } from 'express';
import { ShortLinkService } from './short-link.service';
import { CreateShortLinkDto } from './dto/create-short-link.dto';
import { ClickService } from '../click/click.service'; // Import ClickService

@Controller()
export class ShortLinkController {
  constructor(
    private readonly shortLinkService: ShortLinkService,
    private readonly clickService: ClickService // Inject ClickService
  ) {}

  // ... createShortLink method remains unchanged ...
  @Post('shorten')
  // ...
  @Get(':shortCode')
  async redirect(
    @Param('shortCode') shortCode: string,
    @Res() res: Response,
    @Req() req: Request // Inject the Express Request object
  ) {
    const link = await this.shortLinkService.findOneByCode(shortCode);

    if (!link) {
      throw new NotFoundException('Short link not found.');
    }

    // Record the click asynchronously without waiting for it to complete,
    // to avoid delaying the user's redirection
    this.clickService.recordClick(link, req.ip, req.get('user-agent') || '');

    // Perform the redirect
    return res.redirect(301, link.longUrl);
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Changes:

  1. We injected ClickService.
  2. In the redirect method, we obtained the express Request object via the @Req() decorator.
  3. From the req object, we got req.ip (IP address) and req.get('user-agent') (device and browser information).
  4. We called this.clickService.recordClick() to save the click record. Note, we did not use await here, to ensure the click recording operation does not block the redirection.

5. View Statistics

After the data is recorded, we also need an endpoint to view it. Let's add a statistics endpoint in the ShortLinkController.

// src/short-link/short-link.controller.ts
// ... (imports and constructor)

@Controller()
export class ShortLinkController {
  // ... (constructor and other methods)

  // New statistics endpoint
  @Get('stats/:shortCode')
  async getStats(@Param('shortCode') shortCode: string) {
    const linkWithClicks = await this.shortLinkService.findOneByCodeWithClicks(shortCode);

    if (!linkWithClicks) {
      throw new NotFoundException('Short link not found.');
    }

    return {
      shortCode: linkWithClicks.shortCode,
      longUrl: linkWithClicks.longUrl,
      totalClicks: linkWithClicks.clicks.length,
      clicks: linkWithClicks.clicks.map((click) => ({
        clickedAt: click.clickedAt,
        ipAddress: click.ipAddress,
        userAgent: click.userAgent,
      })),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

For this endpoint to work, we also need to add the findOneByCodeWithClicks method in ShortLinkService.

Open src/short-link/short-link.service.ts:

// src/short-link/short-link.service.ts
// ...

@Injectable()
export class ShortLinkService {
  constructor(
    @InjectRepository(ShortLink)
    private readonly shortLinkRepository: Repository<ShortLink>
  ) {}

  // ... (findOneByCode and create methods)

  // New method that uses relations to load the associated clicks
  async findOneByCodeWithClicks(shortCode: string): Promise<ShortLink | null> {
    return this.shortLinkRepository.findOne({
      where: { shortCode },
      relations: ['clicks'], // Key: Tells TypeORM to load the related clicks entity
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, restart your service. After you visit a short link, you can then make a GET request to http://localhost:3000/stats/your-short-code and you will see a JSON response similar to the one below, which includes detailed click records:

{
  "shortCode": "some-hash",
  "longUrl": "https://www.google.com/",
  "totalClicks": 1,
  "clicks": [
    {
      "clickedAt": "2025-09-17T23:00:00.123Z",
      "ipAddress": "::1",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

At this point, your short link service now has click tracking and statistical analysis capabilities!

Update to Production

Since your service is already running online, the next step after debugging locally is to deploy the updates to the production server.

For applications deployed on Leapcell, this step is very simple: you just need to push your code to GitHub, and Leapcell will automatically pull the latest code and update the application.

Leapcell

Furthermore, Leapcell itself has a powerful built-in Traffic Analysis feature. Even without implementing click tracking yourself, the data presented in the Leapcell Dashboard can provide you with deep user insights.

ImageP1


Follow us on X: @LeapcellHQ


Read on our blog

Related Posts:

Top comments (0)