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;
}
Explanation:
-
@ManyToOne
: This decorator establishes the relationship betweenClick
andShortLink
. 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[];
}
Explanation:
-
@OneToMany
: Establishes a one-to-many relationship fromShortLink
toClick
. 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
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 {}
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);
}
}
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 {}
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);
}
}
Key Changes:
- We injected
ClickService
. - In the
redirect
method, we obtained theexpress
Request
object via the@Req()
decorator. - From the
req
object, we gotreq.ip
(IP address) andreq.get('user-agent')
(device and browser information). - We called
this.clickService.recordClick()
to save the click record. Note, we did not useawait
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,
})),
};
}
}
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
});
}
}
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) ..."
}
]
}
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.
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.
Follow us on X: @LeapcellHQ
Related Posts:
Top comments (0)