·7 min read

Let's build our own URL Shortener with NestJS and Redis

Oguzhan OlguncuOguzhan OlguncuSoftware Engineer @Upstash

Welcome! Today, we'll create our very own URL shortener using NestJS and Redis. If you've ever surprised at the simplicity of popular services like TinyURL and wondered how they transform long URLs into compact links, you're in the right place.

The concept is quite straightforward: users provide a lengthy URL, and our system assigns a unique identifier, in this case, a simple UUID, to represent it. This unique pairing is then stored as a key-value duo in our Redis database. But why bother with URL shorteners?

URL shorteners aren't just about brevity; they significantly enhance the sharing experience. Whether you're sharing links on social media or simplifying URLs for ease of use, this project has practical applications.

Example key-value

Understanding the Structure

Let's break down the key-value structure that combines a (UUID) with user information that we are going to store in our redis db:

[UUID]:[userId|userName] -> longUrl
7deb2726:hezarfen -> https://upstash.com/
[UUID]:[userId|userName] -> longUrl
7deb2726:hezarfen -> https://upstash.com/
  • [UUID]: A unique identifier for each entry.
  • [userId|userName]: Information about the user associated with the shortened URL.
  • longUrl: The original URL that has been shortened.

First things first, let's set up a Redis. Head over to Upstash Redis Console to get your keys, which will look something like this:

UPSTASH_REDIS_REST_URL="https://us1-XXX-38101.upstash.io"
UPSTASH_REDIS_REST_TOKEN="AZTVACQXXXVhOTktYzI0Mi0XXXM4ZmQzMjI3NDY0NTZmNDXXXYjQ0NGY4MGYwNDI="
UPSTASH_REDIS_REST_URL="https://us1-XXX-38101.upstash.io"
UPSTASH_REDIS_REST_TOKEN="AZTVACQXXXVhOTktYzI0Mi0XXXM4ZmQzMjI3NDY0NTZmNDXXXYjQ0NGY4MGYwNDI="

Env key setup

UPSTASH_REDIS_REST_URL="https://us1-XXX-38101.upstash.io"
UPSTASH_REDIS_REST_TOKEN="AZTVACQXXXVhOTktYzI0Mi0XXXM4ZmQzMjI3NDY0NTZmNDXXXYjQ0NGY4MGYwNDI="
UPSTASH_REDIS_REST_URL="https://us1-XXX-38101.upstash.io"
UPSTASH_REDIS_REST_TOKEN="AZTVACQXXXVhOTktYzI0Mi0XXXM4ZmQzMjI3NDY0NTZmNDXXXYjQ0NGY4MGYwNDI="

With the grunt work behind us, give yourself a well-deserved pat on the back. Now, let's dive into the juicy part.

Setting up our NestJS

Let's first clone the starter repo:

git clone https://github.com/nestjs/typescript-starter.git project
cd project
git clone https://github.com/nestjs/typescript-starter.git project
cd project

And, install @upstash/redis and some depedencies:

npm i @upstash/redis @nestjs/config nanoid
npm i @upstash/redis @nestjs/config nanoid

We need @upstash/redis to store our shortened urls and @nestjs/config to load env files into our project.

Here is our file structure:

📦src
  📂lib
   📜redis-client.ts
  📜app.controller.ts
  📜app.module.ts
  📜app.service.ts
  📜main.ts
 📜.env
📦src
  📂lib
   📜redis-client.ts
  📜app.controller.ts
  📜app.module.ts
  📜app.service.ts
  📜main.ts
 📜.env

Before move on let's update our .env as follows:

UPSTASH_REDIS_REST_URL="https://XXXX-39281.upstash.io"
UPSTASH_REDIS_REST_TOKEN="AZXXXgNzYzMDhjYTItODQ4XXX4NTktOThmZWRiZjgzODNlMjM4XXXNmU4NDc0Njk0YWExOXXXFlZGU="
UPSTASH_REDIS_REST_URL="https://XXXX-39281.upstash.io"
UPSTASH_REDIS_REST_TOKEN="AZXXXgNzYzMDhjYTItODQ4XXX4NTktOThmZWRiZjgzODNlMjM4XXXNmU4NDc0Njk0YWExOXXXFlZGU="

And, now it's time to make our redis-client.ts this is our entry file to our redis instance.

import { Redis } from "@upstash/redis";
 
export const createRedisClient = (restUrl: string, restToken: string) => {
  const redis = new Redis({
    url: restUrl,
    token: restToken,
  });
  return redis;
};
import { Redis } from "@upstash/redis";
 
export const createRedisClient = (restUrl: string, restToken: string) => {
  const redis = new Redis({
    url: restUrl,
    token: restToken,
  });
  return redis;
};

In NestJS since we can't access env keys directly within our function we have to pass it from the top when we load the env keys with @nestjs/config. To be able to use @nestjs/config we have to add it into our import in app.module.ts.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
 
@Module({
  imports: [ConfigModule.forRoot()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
 
@Module({
  imports: [ConfigModule.forRoot()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

NestJS has opiniated way of doing things. .module.ts files are similar to barel import files. They control what you import from or export to a specific module. As you can see we are also passing our app.controller.ts and app.service.ts which we'll get to it in a second.

Now that we've successfully integrated the modules, let's delve into the functionality of service files and explore the process of injecting environment keys into app.service.ts.

import { Injectable } from '@nestjs/common';
 
@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}
import { Injectable } from '@nestjs/common';
 
@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

NestJS uses decorators extensively for abstraction. When we apply the @Injectable decorator, it signals that other packages can now inject this module into their runtime. With this foundation, let's proceed to inject our environment keys.

@Injectable()
export class AppService {
   constructor(private configService: ConfigService) {}
 
  getRedis(): Redis {
    const restUrl = this.configService.get<string>('UPSTASH_REDIS_REST_URL');
    const restToken = this.configService.get<string>(
      'UPSTASH_REDIS_REST_TOKEN',
    );
    return createRedisClient(restUrl, restToken);
  }
 
  getHello(): string {
    return 'Hello World!';
  }
}
@Injectable()
export class AppService {
   constructor(private configService: ConfigService) {}
 
  getRedis(): Redis {
    const restUrl = this.configService.get<string>('UPSTASH_REDIS_REST_URL');
    const restToken = this.configService.get<string>(
      'UPSTASH_REDIS_REST_TOKEN',
    );
    return createRedisClient(restUrl, restToken);
  }
 
  getHello(): string {
    return 'Hello World!';
  }
}

NestJS adopts a developer-friendly approach by favoring Dependency Injection over explicit imports/exports. This streamlined method is how we easily imported environment keys into our Redis client.

Now, let's bring our first actual function setShortUrlToCache.

export const UUID_LENGTH = 5;
const EIGHT_HOUR_IN_SEC = 3600 * 8;
export const EIGHT_HOUR_IN_MS = EIGHT_HOUR_IN_SEC * 1000;
 
export type ShortLinkCacheValues = {
  createadAt: number; //Date.now()
  expiredAt: number; //Date.now()
  actualLink: string;
  k: string;
};
 
async setShortUrlToCache(
    linkToShorten: string,
    expirationTime = EIGHT_HOUR_IN_SEC,
    userId: string,
  ) {
    const pathKey = `${nanoid(UUID_LENGTH)}:${userId}`;
 
    const payload: ShortLinkCacheValues = {
      createadAt: Date.now(),
      expiredAt: Date.now() + EIGHT_HOUR_IN_MS,
      actualLink: linkToShorten,
      k: pathKey,
    };
    const res = await this.getRedis().set<ShortLinkCacheValues>(
      pathKey,
      payload,
      {
        ex: expirationTime,
      },
    );
    if (res) {
      return pathKey;
    }
  }
export const UUID_LENGTH = 5;
const EIGHT_HOUR_IN_SEC = 3600 * 8;
export const EIGHT_HOUR_IN_MS = EIGHT_HOUR_IN_SEC * 1000;
 
export type ShortLinkCacheValues = {
  createadAt: number; //Date.now()
  expiredAt: number; //Date.now()
  actualLink: string;
  k: string;
};
 
async setShortUrlToCache(
    linkToShorten: string,
    expirationTime = EIGHT_HOUR_IN_SEC,
    userId: string,
  ) {
    const pathKey = `${nanoid(UUID_LENGTH)}:${userId}`;
 
    const payload: ShortLinkCacheValues = {
      createadAt: Date.now(),
      expiredAt: Date.now() + EIGHT_HOUR_IN_MS,
      actualLink: linkToShorten,
      k: pathKey,
    };
    const res = await this.getRedis().set<ShortLinkCacheValues>(
      pathKey,
      payload,
      {
        ex: expirationTime,
      },
    );
    if (res) {
      return pathKey;
    }
  }

Since we are essentially in a class, we can use this to access other internal methods like this.getRedis(). This function requests a link to shorten, an optional expiration time, and a userId to easily identify shortened URLs. Then, We create a UUID using nanoid and the userId: ${nanoid(UUID_LENGTH)}:${userId}, and then call redis.set with the given pathKey, payload, and ex. If the set operation is successful, we return pathKey to the user so they can later retrieve their full URL.

Now, we need two more method to get single shortened urls and all the shortened urls with associated user.

async getShortUrlFromCache(pathKey: string) {
    const res = await this.getRedis().get<ShortLinkCacheValues>(pathKey);
    return res;
  }
 
  async getAllShortUrlsFromCacheForUser(userId: string) {
    const keys = await this.getRedis().keys(`*${userId}*`);
    if (Boolean(keys.length)) {
      const listOfUrls = await this.getRedis().mget<ShortLinkCacheValues[]>(
        ...keys,
      );
      return listOfUrls;
    }
  }
async getShortUrlFromCache(pathKey: string) {
    const res = await this.getRedis().get<ShortLinkCacheValues>(pathKey);
    return res;
  }
 
  async getAllShortUrlsFromCacheForUser(userId: string) {
    const keys = await this.getRedis().keys(`*${userId}*`);
    if (Boolean(keys.length)) {
      const listOfUrls = await this.getRedis().mget<ShortLinkCacheValues[]>(
        ...keys,
      );
      return listOfUrls;
    }
  }

The first method is straightforward: we pass the pathKey and retrieve the full URL along with additional metadata. The second method takes a userId, attempts to retrieve all keys containing that userId using *userId*, and then calls redis.mget to fetch all of them simultaneously.

Now, we need a controller file to expose those services to endpoints

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
 
  @Post('/shorten')
  shorterUrl(@Req() req: Request) {
    const queries = req.query;
    const { linkToShorten, userId } = queries as unknown as {
      linkToShorten: string;
      expire: number;
      userId: string;
    };
    return this.appService.setShortUrlToCache(linkToShorten, null, userId);
  }
 
  @Get('/get-shortened-url/:id')
  getShortenedUrl(@Req() req: Request) {
    return this.appService.getShortUrlFromCache(req.params['id']);
  }
 
  @Get('/get-all-shortened-url/:id')
  getAllShortenedUrl(@Req() req: Request) {
    return this.appService.getAllShortUrlsFromCacheForUser(req.params['id']);
  }
}
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
 
  @Post('/shorten')
  shorterUrl(@Req() req: Request) {
    const queries = req.query;
    const { linkToShorten, userId } = queries as unknown as {
      linkToShorten: string;
      expire: number;
      userId: string;
    };
    return this.appService.setShortUrlToCache(linkToShorten, null, userId);
  }
 
  @Get('/get-shortened-url/:id')
  getShortenedUrl(@Req() req: Request) {
    return this.appService.getShortUrlFromCache(req.params['id']);
  }
 
  @Get('/get-all-shortened-url/:id')
  getAllShortenedUrl(@Req() req: Request) {
    return this.appService.getAllShortUrlsFromCacheForUser(req.params['id']);
  }
}

As mentioned earlier, NestJS heavily relies on decorators, such as @Post and @Get. If you've worked with Spring or .Net, the controller structure might seem familiar.

In our case, we use @Post() to define an endpoint, in this instance, /shorter. We then use @Req() req: Request to access query parameters. After that, the process is straightforward – we call this.appService.setShortUrlToCache, which we injected earlier through constructor injection, and return the pathKey to the user.

If you wanna test /shorten. Use this example cURL,

curl -X POST "http://localhost:3000/shorten?linktoShorten=https://upstash.com&userId=hezarfen"
curl -X POST "http://localhost:3000/shorten?linktoShorten=https://upstash.com&userId=hezarfen"

In return you will get something like this

Pn7d0:hezarfen
Pn7d0:hezarfen

To get back our full url we simply do this

curl http://localhost:3000/get-shortened-url/Pn7d0:hezarfen
curl http://localhost:3000/get-shortened-url/Pn7d0:hezarfen

In return you will get something like this

{"createadAt":1700743946597,"expiredAt":1700772746597,"k":"Pn7d0:Oz"}
{"createadAt":1700743946597,"expiredAt":1700772746597,"k":"Pn7d0:Oz"}

To get every associated long url:

curl http://localhost:3000/get-all-shortened-url/hezarfen
curl http://localhost:3000/get-all-shortened-url/hezarfen

In return you will get something like this

[
  { createadAt: 1700744195441, expiredAt: 1700772995441, k: "7KgZF:hezarfen" },
  { createadAt: 1700744179512, expiredAt: 1700772979512, k: "e0b0S:hezarfen" },
  { createadAt: 1700744207376, expiredAt: 1700773007376, k: "hMlNs:hezarfen" },
];
[
  { createadAt: 1700744195441, expiredAt: 1700772995441, k: "7KgZF:hezarfen" },
  { createadAt: 1700744179512, expiredAt: 1700772979512, k: "e0b0S:hezarfen" },
  { createadAt: 1700744207376, expiredAt: 1700773007376, k: "hMlNs:hezarfen" },
];

Wrap up

To sum it up, our journey to build a URL shortener using NestJS and Redis has come to a end. Throughout this process, we've seen how the combination of NestJS and Redis, particularly Upstash Redis, can streamline the development of efficient and practical applications. The project not only showcases the technical capabilities of these technologies but also emphasizes their real-world applicability in creating useful tools like URL shorteners. As you continue exploring and building with these tools, remember the valuable insights gained from this project, and consider how they can be applied to future endeavors. Happy coding!