URL Shortener with Spring Boot and Redis

Build a URL shortener REST API using Base62 encoding and Redis.

Introduction

30 Second Summary

Every time you paste a long URL into a link shortener and get back a tiny link, there is a backend system generating unique codes, storing mappings, and redirecting visitors in milliseconds. It is one of the most common system design interview questions because it touches on so many core engineering decisions.

In this project, you will build a fully functional URL shortener REST API using Spring Boot and Redis. You will generate short codes, redirect visitors to original URLs, and track click analytics.

What You'll Build

A local URL shortener service that uses Redis atomic counters and Base62 encoding to generate collision-free short codes, stores URL metadata in Redis hashes with TTL expiration, and serves HTTP redirects with click tracking.

By the end of this project, you'll have:

  • A Spring Boot REST API with endpoints for shortening URLs, redirecting visitors, and viewing analytics.
  • A Base62 encoder that converts numeric IDs into short, URL-safe strings using the same strategy as production shorteners.
  • A Redis data model with atomic counters for ID generation, hashes for URL metadata, TTL-based expiration, and rate limiting.
  • Secret Mission: a custom alias feature that lets users pick their own short code instead of using an auto-generated one.

Are there any prerequisites?

You should be familiar with Java and have a basic understanding of REST APIs and HTTP. You will also need IntelliJ IDEA, JDK 25, and Docker installed on your machine.

Set Up the Project and Redis

Every URL shortener needs two things: an API framework to handle HTTP requests, and a fast data store to map short codes to original URLs.

In this step, you will scaffold a Spring Boot project and spin up a Redis container using Docker. By the end, your application will be running and connected to Redis.

In this step, get ready to:
  • Generate a Spring Boot project with the required dependencies.
  • Start a Redis container with Docker and verify the connection.
  • Configure your application to connect to Redis and confirm it starts.
Generate your Spring Boot project

Spring Initializr is a web tool that generates a pre-configured Spring Boot project for you. It bundles the dependencies you select into a ready-to-import Maven project.

  • Open start.spring.io in your browser.
  • Select Maven as the project type.
  • Select Java as the language.
  • Set the Spring Boot version to 4.0.6.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to url-shortener.
  • Set Java version to 25.
  • Click ADD DEPENDENCIES and search for Spring Web. Select it.
  • Click ADD DEPENDENCIES again and search for Spring Data Redis. Select it.
  • Click GENERATE to download the project as a zip file.

Why these two dependencies?

Spring Web gives you the REST controller layer for handling HTTP requests and returning JSON responses.

Spring Data Redis provides StringRedisTemplate, a pre-configured client for reading and writing data to Redis without manual connection setup.

  • Unzip the downloaded file to a location you can easily find (e.g. your Desktop).
  • Open IntelliJ IDEA.
  • Click Open on the Welcome screen.
  • Navigate to the unzipped url-shortener folder and click OK.

IntelliJ will automatically detect the Maven project and begin importing dependencies. Wait for the progress bar in the bottom status bar to finish.

What should I see?

In the Project sidebar on the left, you should see the folder structure including src/main/java/com/example/urlshortener with an UrlShortenerApplication.java file already generated for you.

IntelliJ not resolving dependencies?

If you see red underlines in the code, right-click the pom.xml file in the Project sidebar and select Maven > Reload Project. Make sure you have an internet connection so Maven can download packages.

help me troubleshoot Maven dependency resolution in IntelliJ

Start a Redis container with Docker

Redis is an in-memory data store that your URL shortener will use to store mappings between short codes and original URLs. Running Redis in a Docker container means you do not need to install it directly on your machine.

  • Open a terminal window.
  • Start a Redis container by running this command:
docker run -d --name redis -p 6379:6379 redis:latest

What does this command do?

  • docker run creates and starts a new container.
  • -d runs it in the background (detached mode).
  • --name redis gives the container a memorable name so you can reference it later.
  • -p 6379:6379 maps port 6379 on your machine to port 6379 inside the container. This is the default Redis port.
  • redis:latest pulls the official Redis 8.x image from Docker Hub.
  • Verify Redis is running by connecting with the Redis CLI:
docker exec -it redis redis-cli
  • Type PING and press Enter.

You should see PONG in the response. This confirms Redis is running and accepting connections.

  • Type exit and press Enter to leave the Redis CLI.

Docker command not found or container won't start?

Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray (Windows) or menu bar (macOS).

If you see an error about the name "redis" already being in use, a previous container with that name exists. Remove it by running docker rm -f redis and then retry the run command.

help me get my Redis Docker container running

Configure your application to connect to Redis

Spring Boot auto-configures a Redis connection if you tell it where Redis is running. You do this through the application.properties file.

  • In IntelliJ, expand src/main/resources in the Project sidebar.
  • Open application.properties.
  • Replace any existing content with the following configuration:
# Application name
spring.application.name=url-shortener

# Redis connection settings
spring.data.redis.host=localhost
spring.data.redis.port=6379

# Custom app settings
app.base-url=http://localhost:8080
app.default-ttl-hours=24
app.rate-limit-per-minute=10

What does this configuration do?

  • spring.data.redis.host and spring.data.redis.port tell Spring Boot where to find the Redis container you just started.
  • app.base-url is the root URL your short links will use. Shortened URLs will look like http://localhost:8080/abc123.
  • app.default-ttl-hours controls how long a shortened URL stays active before Redis automatically expires it.
  • app.rate-limit-per-minute sets the maximum number of shorten requests a single IP can make per minute.
  • Save application.properties by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Run your application by clicking the green play button next to the main method in UrlShortenerApplication.java.

In the Run panel at the bottom of IntelliJ, you should see output including Started UrlShortenerApplication. This confirms Spring Boot started and connected to Redis successfully.

Seeing a connection refused error?

If the application fails with a Redis connection error, confirm your Redis container is still running by executing docker ps in your terminal. You should see a container named redis in the list.

If it is not listed, restart it with docker start redis and re-run the application.

help me fix the Redis connection error in my Spring Boot app

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the full application.properties file:

spring.application.name=url-shortener
spring.data.redis.host=localhost
spring.data.redis.port=6379
app.base-url=http://localhost:8080
app.default-ttl-hours=24
app.rate-limit-per-minute=10

And the pom.xml (generated by Spring Initializr):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.6</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>url-shortener</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>url-shortener</name>
    <description>URL Shortener with Spring Boot and Redis</description>
    <properties>
        <java.version>25</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

And the main application class (also generated by Spring Initializr):

package com.example.urlshortener;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class UrlShortenerApplication {

    public static void main(String[] args) {
        SpringApplication.run(UrlShortenerApplication.class, args);
    }
}

Your project scaffold is in place and Redis is ready. Next up, you will build the Base62 encoder that turns numeric IDs into short, URL-safe codes.

Build the Base62 Encoder

Your Spring Boot app is running and connected to Redis. The next piece you need is a way to turn numeric IDs into short, readable codes that work in URLs.

URL shorteners like bit.ly use Base62 encoding for this. It converts a number into a compact string using 62 URL-safe characters: lowercase letters, uppercase letters, and digits.

In this step, get ready to:
  • Build a Base62 encode method that converts numbers to short strings.
  • Build a Base62 decode method that converts short strings back to numbers.
  • Verify that encoding and decoding round-trip correctly.
Create the encoder class and encode method

Every time your URL shortener creates a new short link, it needs to generate a unique ID and convert it into a compact string. The encode method handles that conversion.

  • In IntelliJ IDEA, right-click the com.example.urlshortener package in the Project panel on the left.
  • Select New, then Java Class.
  • Name it Base62Encoder and press Enter.
  • Replace the generated file contents with the following code:
package com.example.urlshortener;

import org.springframework.stereotype.Component;

@Component
public class Base62Encoder {

    // 62 URL-safe characters: a-z, A-Z, 0-9
    private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private static final int BASE = ALPHABET.length();

    // Converts a numeric ID to a short Base62 string
    public String encode(long number) {
        if (number == 0) {
            return String.valueOf(ALPHABET.charAt(0));
        }
        StringBuilder sb = new StringBuilder();
        while (number > 0) {
            sb.append(ALPHABET.charAt((int) (number % BASE)));
            number /= BASE;
        }
        return sb.reverse().toString();
    }
}

What does this code do?

The ALPHABET string contains all 62 characters that are safe to use in URLs. The BASE constant is 62, the total number of available characters.

The encode method works like converting a decimal number to a different number system. It repeatedly divides the number by 62, using each remainder as an index into the alphabet. The result is a short string like dnh instead of a long number like 12345.

Why Base62 instead of hashing?

Hashing algorithms like MD5 or SHA produce fixed-length outputs that need truncation to be short enough for URLs. Truncation introduces collision risk, meaning two different URLs could get the same short code.

Base62 encoding of a sequential ID is deterministic and collision-free. Each ID maps to exactly one string, and each string maps back to exactly one ID. This is the same approach used by production URL shorteners.

  • Save Base62Encoder.java.
  • Check the Problems tab at the bottom of IntelliJ IDEA. You should see no compilation errors for this file.

Seeing a compilation error?

  • Make sure the package declaration at the top matches your project structure: package com.example.urlshortener;.
  • Verify the import for org.springframework.stereotype.Component is present. This annotation comes from the Spring Web dependency you added in Step 1.

Still stuck? Help me fix a compilation error in my Base62Encoder class.

Add the decode method

The decode method reverses the encoding process. You will need this later to look up a URL from its short code.

  • In Base62Encoder.java, add the following method below the closing brace of encode but inside the class:
    // Converts a Base62 string back to its numeric ID
    public long decode(String encoded) {
        long result = 0;
        for (char c : encoded.toCharArray()) {
            result = result * BASE + ALPHABET.indexOf(c);
        }
        return result;
    }

What does the decode method do?

It walks through each character of the encoded string, looks up that character's position in the alphabet, and reconstructs the original number. This is the same process as converting a string of digits back to a number, just using base 62 instead of base 10.

  • Save Base62Encoder.java.
  • Confirm no errors appear in the Problems tab.

Red squiggly lines under decode?

  • Make sure the decode method is inside the class braces but not inside the encode method. Each method needs its own opening and closing brace.
  • Check that you haven't accidentally deleted the closing brace of the class. The file should end with a single } on its own line.

Need a hand? Help me fix an error in my decode method.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.urlshortener;

import org.springframework.stereotype.Component;

@Component
public class Base62Encoder {

    private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private static final int BASE = ALPHABET.length();

    public String encode(long number) {
        if (number == 0) {
            return String.valueOf(ALPHABET.charAt(0));
        }
        StringBuilder sb = new StringBuilder();
        while (number > 0) {
            sb.append(ALPHABET.charAt((int) (number % BASE)));
            number /= BASE;
        }
        return sb.reverse().toString();
    }

    public long decode(String encoded) {
        long result = 0;
        for (char c : encoded.toCharArray()) {
            result = result * BASE + ALPHABET.indexOf(c);
        }
        return result;
    }
}
Verify the encoder with a round-trip test

Before moving on, you should confirm that encoding a number and then decoding the result gives back the original number. This is called a round-trip test.

  • Restart your Spring Boot application by clicking the green Run button in IntelliJ IDEA's toolbar (or press Ctrl+R on macOS, Shift+F10 on Windows).

If the app starts without errors, your Base62Encoder class compiles and Spring has registered it as a component.

What should I see?

The console should show Started UrlShortenerApplication with no errors. This confirms Spring found and registered your @Component class.

Now trace through the logic manually to confirm it works correctly.

Tracing through encode and decode

Consider what happens when you encode the number 12345:

  • 12345 % 62 = 7, which maps to h in the alphabet. Remaining: 199.
  • 199 % 62 = 13, which maps to n. Remaining: 3.
  • 3 % 62 = 3, which maps to d. Remaining: 0.
  • Reversed: dnh.

Decoding dnh reverses this: (3 * 62 + 13) * 62 + 7 = 12345. The round-trip is complete.

Extra for Experts: How short are these codes?

A 7-character Base62 string can represent 62^7 (approximately 3.5 trillion) unique values. That means even with billions of shortened URLs, your codes stay compact and readable.

App won't start?

  • If you see a Redis connection error, make sure your Docker container is still running. Run docker ps in your terminal to check.
  • If you see a compilation error mentioning Base62Encoder, open the file and check for mismatched braces or typos in the method signatures.

Still stuck? Help me debug my Spring Boot app that won't start after adding Base62Encoder.

Your encoder is ready to convert any numeric ID into a compact, URL-safe string. Next up, you'll wire it into a service that uses Redis to generate those IDs and store the URL mappings.

Implement the URL Shortening Service

You have a Base62 encoder that turns numbers into short strings. Now you need the core logic that ties everything together: generating a unique ID for each URL, encoding it, and storing the mapping in Redis.

This step builds the service layer and REST endpoint. By the end, you will be able to POST a long URL and receive a short code back.

In this step, get ready to:
  • Create a service class that uses Redis INCR as an atomic counter and encodes IDs with Base62.
  • Store URL metadata in Redis hashes with TTL expiration.
  • Create a POST /api/shorten endpoint that accepts a URL and returns a short code.
Create the service class with a Redis atomic counter

Production URL shorteners need a way to generate unique IDs without collisions, even under heavy traffic. Redis provides an INCR command that atomically increments a counter, guaranteeing every caller gets a different number.

  • In IntelliJ IDEA, right-click the com.example.urlshortener package in the Project sidebar.
  • Select New, then Java Class.
  • Name it UrlShorteningService and press Enter.
  • Replace the generated file content with the following code:
package com.example.urlshortener;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;

@Service
public class UrlShorteningService {

    // Redis key for the global ID counter
    private static final String ID_COUNTER_KEY = "url:id:counter";
    // Prefix for all URL hash keys
    private static final String URL_PREFIX = "url:";

    private final StringRedisTemplate redisTemplate;
    private final Base62Encoder base62Encoder;
    private final long defaultTtlHours;

    public UrlShorteningService(StringRedisTemplate redisTemplate,
                                Base62Encoder base62Encoder,
                                @Value("${app.default-ttl-hours}") long defaultTtlHours) {
        this.redisTemplate = redisTemplate;
        this.base62Encoder = base62Encoder;
        this.defaultTtlHours = defaultTtlHours;
    }

    // Generate a unique short code for the given URL
    public String shortenUrl(String originalUrl) {
        // Atomically increment the counter to get a unique ID
        Long id = redisTemplate.opsForValue().increment(ID_COUNTER_KEY);
        // Encode the numeric ID as a short Base62 string
        String shortCode = base62Encoder.encode(id);
        saveUrlMapping(shortCode, originalUrl);
        return shortCode;
    }
}

What does this code do?

The @Service annotation registers this class as a Spring bean that other components can inject.

The constructor injects three dependencies: StringRedisTemplate (auto-configured by Spring Boot for talking to Redis), your Base62Encoder, and the TTL value from application.properties.

The shortenUrl method calls Redis INCR on a global counter key. Every call gets a unique incrementing number. That number is fed into the Base62 encoder to produce a short code like b or cZ.

Why use StringRedisTemplate instead of RedisTemplate?

StringRedisTemplate serializes all keys and values as plain strings. This makes your data human-readable when you inspect it with redis-cli. The generic RedisTemplate uses Java serialization by default, which stores binary blobs you cannot read.

The file will show an error on saveUrlMapping because the method does not exist yet. You will add it in the next substep.

Store URL metadata in Redis hashes with TTL

Each shortened URL needs more than just the destination. You also want to track when it was created, how many times it has been clicked, and when it should expire. A Redis hash stores all these fields under a single key.

  • In UrlShorteningService.java, add the following three methods below the shortenUrl method, inside the class body:
    // Look up the original URL and track the click
    public String resolveUrl(String shortCode) {
        String key = URL_PREFIX + shortCode;
        String originalUrl = (String) redisTemplate.opsForHash().get(key, "originalUrl");
        if (originalUrl != null) {
            // Atomically increment the click counter
            redisTemplate.opsForHash().increment(key, "clicks", 1);
        }
        return originalUrl;
    }

    // Return all stored metadata for a short code
    public Map<Object, Object> getStats(String shortCode) {
        String key = URL_PREFIX + shortCode;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        if (entries.isEmpty()) {
            return null;
        }
        return entries;
    }

    // Persist the URL mapping as a Redis hash with TTL
    private void saveUrlMapping(String shortCode, String originalUrl) {
        String key = URL_PREFIX + shortCode;
        redisTemplate.opsForHash().put(key, "originalUrl", originalUrl);
        redisTemplate.opsForHash().put(key, "shortCode", shortCode);
        redisTemplate.opsForHash().put(key, "createdAt", Instant.now().toString());
        redisTemplate.opsForHash().put(key, "clicks", "0");
        // Auto-expire the key after the configured TTL
        redisTemplate.expire(key, Duration.ofHours(defaultTtlHours));
    }

What do these methods do?

  • resolveUrl fetches the original URL from the Redis hash. If found, it atomically increments the clicks field so you get analytics for free.
  • getStats returns the full hash (original URL, short code, creation time, click count) for display.
  • saveUrlMapping writes five fields into a Redis hash under the key url:<shortCode>, then sets a TTL so the key auto-deletes after 24 hours.
  • Save UrlShorteningService.java.
  • Confirm that IntelliJ shows no red underlines in the file. The saveUrlMapping error from earlier should now be resolved.

Seeing red underlines on imports?

  • Make sure your pom.xml includes spring-boot-starter-data-redis. Without it, StringRedisTemplate will not resolve.
  • Click the Maven reload icon (circular arrows) in IntelliJ's Maven tool window to re-sync dependencies.
  • Check that the methods are placed inside the class body (between the outer curly braces), not outside the class.

Why are my Redis imports not resolving in IntelliJ?

✔️ Awesome, I've got everything!

Great. Double check you have saved UrlShorteningService.java with no errors.

ⓧ I'd like to double check the full code

Here is the complete UrlShorteningService.java file for comparison:

package com.example.urlshortener;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;

@Service
public class UrlShorteningService {

    private static final String ID_COUNTER_KEY = "url:id:counter";
    private static final String URL_PREFIX = "url:";

    private final StringRedisTemplate redisTemplate;
    private final Base62Encoder base62Encoder;
    private final long defaultTtlHours;

    public UrlShorteningService(StringRedisTemplate redisTemplate,
                                Base62Encoder base62Encoder,
                                @Value("${app.default-ttl-hours}") long defaultTtlHours) {
        this.redisTemplate = redisTemplate;
        this.base62Encoder = base62Encoder;
        this.defaultTtlHours = defaultTtlHours;
    }

    public String shortenUrl(String originalUrl) {
        Long id = redisTemplate.opsForValue().increment(ID_COUNTER_KEY);
        String shortCode = base62Encoder.encode(id);
        saveUrlMapping(shortCode, originalUrl);
        return shortCode;
    }

    public String resolveUrl(String shortCode) {
        String key = URL_PREFIX + shortCode;
        String originalUrl = (String) redisTemplate.opsForHash().get(key, "originalUrl");
        if (originalUrl != null) {
            redisTemplate.opsForHash().increment(key, "clicks", 1);
        }
        return originalUrl;
    }

    public Map<Object, Object> getStats(String shortCode) {
        String key = URL_PREFIX + shortCode;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        if (entries.isEmpty()) {
            return null;
        }
        return entries;
    }

    private void saveUrlMapping(String shortCode, String originalUrl) {
        String key = URL_PREFIX + shortCode;
        redisTemplate.opsForHash().put(key, "originalUrl", originalUrl);
        redisTemplate.opsForHash().put(key, "shortCode", shortCode);
        redisTemplate.opsForHash().put(key, "createdAt", Instant.now().toString());
        redisTemplate.opsForHash().put(key, "clicks", "0");
        redisTemplate.expire(key, Duration.ofHours(defaultTtlHours));
    }
}
Create the POST /api/shorten endpoint

The service layer is complete, but there is no way for clients to reach it over HTTP. You need a REST controller that accepts a JSON request body and returns the short code.

First, create a simple record to represent the incoming request payload.

  • Right-click the com.example.urlshortener package.
  • Select New, then Java Class.
  • Change the kind dropdown to Record, name it ShortenRequest, and press Enter.
  • Replace the file content with the following:
package com.example.urlshortener;

// A record auto-generates the constructor, getter, equals, hashCode, and toString
public record ShortenRequest(String url) {
}

What is a Java record?

A record is a compact way to define an immutable data carrier. Spring Boot automatically deserializes incoming JSON into this record when it appears as a @RequestBody parameter. The single field url maps to a JSON property of the same name.

  • Save ShortenRequest.java.

Now create the controller that wires the service to an HTTP endpoint.

  • Right-click the com.example.urlshortener package.
  • Select New, then Java Class.
  • Name it UrlController and press Enter.
  • Replace the file content with the following:
package com.example.urlshortener;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
public class UrlController {

    private final UrlShorteningService urlShorteningService;
    private final String baseUrl;

    public UrlController(UrlShorteningService urlShorteningService,
                         @Value("${app.base-url}") String baseUrl) {
        this.urlShorteningService = urlShorteningService;
        this.baseUrl = baseUrl;
    }

    // Accept a URL in the request body and return the generated short code
    @PostMapping("/api/shorten")
    public ResponseEntity<?> shortenUrl(@RequestBody ShortenRequest request) {
        // Validate that the URL is not empty
        if (request.url() == null || request.url().isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "URL is required"));
        }

        String shortCode = urlShorteningService.shortenUrl(request.url());
        String shortUrl = baseUrl + "/" + shortCode;

        // Return 201 Created with the short code and full short URL
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("shortCode", shortCode, "shortUrl", shortUrl, "originalUrl", request.url()));
    }
}

What does this controller do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON responses directly (no template rendering).
  • @PostMapping("/api/shorten") maps POST requests to this method.
  • The method validates the input, calls the service to generate a short code, and returns a 201 Created response with the short URL.
  • baseUrl is injected from app.base-url in application.properties so the response includes the full clickable link.
  • Save UrlController.java.
  • Make sure your Redis container is still running. Confirm by running this in your terminal:
docker ps
  • You should see your redis container listed with port 6379 mapped.
  • Run the application by clicking the green play button next to the main method in UrlShortenerApplication.java, or press Shift+F10 (Windows) / Ctrl+R (macOS).
  • Wait for the console to show Started UrlShortenerApplication.
  • Test the endpoint by running this curl command in a new terminal window:
curl -X POST http://localhost:8080/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.example.com/very/long/path/to/a/page"}'

You should see a JSON response like this (your short code will be b since it is the first ID):

{"shortCode":"b","shortUrl":"http://localhost:8080/b","originalUrl":"https://www.example.com/very/long/path/to/a/page"}

Application won't start or curl returns an error?

  • If you see Connection refused in the Spring logs, your Redis container is not running. Start it with docker start redis.
  • If curl returns Connection refused, make sure the application started successfully and is listening on port 8080.
  • If you get a 415 error, make sure you included the -H "Content-Type: application/json" header in your curl command.

My Spring Boot app won't connect to Redis or my curl request is failing.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved: UrlShorteningService.java, ShortenRequest.java, and UrlController.java.

ⓧ I'd like to double check the full code

Here is the complete UrlController.java file:

package com.example.urlshortener;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
public class UrlController {

    private final UrlShorteningService urlShorteningService;
    private final String baseUrl;

    public UrlController(UrlShorteningService urlShorteningService,
                         @Value("${app.base-url}") String baseUrl) {
        this.urlShorteningService = urlShorteningService;
        this.baseUrl = baseUrl;
    }

    @PostMapping("/api/shorten")
    public ResponseEntity<?> shortenUrl(@RequestBody ShortenRequest request) {
        if (request.url() == null || request.url().isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "URL is required"));
        }

        String shortCode = urlShorteningService.shortenUrl(request.url());
        String shortUrl = baseUrl + "/" + shortCode;

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("shortCode", shortCode, "shortUrl", shortUrl, "originalUrl", request.url()));
    }
}

And here is ShortenRequest.java:

package com.example.urlshortener;

public record ShortenRequest(String url) {
}

Your URL shortener can now accept long URLs and return short codes. Next up, you will add the redirect endpoint so that visiting the short URL actually sends users to the original destination.

Implement Redirect and Analytics

Your shortening endpoint can generate short codes, but visiting one of those codes does nothing yet. A URL shortener needs to close the loop: when someone visits the short link, the server must look up the original URL and send the browser there.

In this step, you will add the redirect endpoint and a stats endpoint. The redirect uses HTTP 302 to send browsers to the original destination while incrementing a click counter. The stats endpoint exposes that counter so you can track how many times a link has been visited.

In this step, get ready to:
  • Add a redirect endpoint that resolves short codes and returns HTTP 302.
  • Add an analytics endpoint that returns click count and metadata.
  • Test the full end-to-end flow with curl.
Add the redirect endpoint

When a browser visits a short URL like http://localhost:8080/b, the server needs to look up what b maps to and redirect the browser there. Your UrlShorteningService already has a resolveUrl method that handles the lookup and click counting. You just need a controller endpoint to call it.

  • In UrlController.java, find the import section at the top of the file.
  • Add this import below the existing ones:
import java.net.URI;
  • Now scroll down to the closing brace of the shortenUrl method.
  • Add the redirect endpoint below it by copying this code:
    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        // Look up the original URL and increment click count
        String originalUrl = urlShorteningService.resolveUrl(shortCode);
        if (originalUrl == null) {
            return ResponseEntity.notFound().build();
        }
        // Return HTTP 302 Found with the Location header
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create(originalUrl))
                .build();
    }

What does this code do?

The @GetMapping("/{shortCode}") annotation captures any path segment after the root as a variable. When someone visits /b, Spring passes "b" into the method.

The resolveUrl call fetches the original URL from Redis and atomically increments the click counter. If the short code does not exist, the method returns a 404.

The response uses HTTP 302 (Found) instead of 301 (Moved Permanently). A 301 tells browsers to cache the redirect forever, which would bypass your server on future visits and break click tracking.

Why 302 instead of 301?

Production URL shorteners that need analytics always use 302. With a 301, the browser remembers the redirect and never contacts your server again. Every subsequent click goes straight to the destination without being counted.

If you did not need analytics (e.g. a pure redirect service), 301 would be more efficient since it reduces server load.

  • Save UrlController.java.

Seeing a compile error on URI?

Make sure you added import java.net.URI; at the top of the file. Without it, Java cannot resolve the URI.create() call.

If IntelliJ underlines URI in red, place your cursor on it and press Alt+Enter to auto-import.

help me fix my redirect endpoint import error

Add the stats endpoint

The stats endpoint lets you query metadata about any shortened URL. It returns the original URL, creation timestamp, and the current click count from the Redis hash.

  • In UrlController.java, add the stats endpoint below the redirect method you just added:
    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<?> getStats(@PathVariable String shortCode) {
        // Fetch all fields from the Redis hash for this short code
        Map<Object, Object> stats = urlShorteningService.getStats(shortCode);
        if (stats == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(stats);
    }

What does this code do?

The getStats method calls the service layer which retrieves all fields from the Redis hash stored under the key url:<shortCode>. This includes originalUrl, shortCode, createdAt, and clicks.

If the short code does not exist in Redis (or has expired due to TTL), the service returns null and the controller responds with 404.

  • Save UrlController.java.

IntelliJ showing unresolved method errors?

If urlShorteningService.getStats() shows as unresolved, make sure your UrlShorteningService.java file is saved and contains the getStats method from the previous step.

Try clicking Build > Rebuild Project in the top menu to refresh IntelliJ's index.

help me fix unresolved method errors in my controller

✔️ Awesome, I've got everything!

Great. Double check you have saved UrlController.java before moving on.

ⓧ I'd like to double check the full code

package com.example.urlshortener;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Map;

@RestController
public class UrlController {

    private final UrlShorteningService urlShorteningService;
    private final String baseUrl;

    public UrlController(UrlShorteningService urlShorteningService,
                         @Value("${app.base-url}") String baseUrl) {
        this.urlShorteningService = urlShorteningService;
        this.baseUrl = baseUrl;
    }

    @PostMapping("/api/shorten")
    public ResponseEntity<?> shortenUrl(@RequestBody ShortenRequest request) {
        if (request.url() == null || request.url().isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "URL is required"));
        }

        String shortCode = urlShorteningService.shortenUrl(request.url());
        String shortUrl = baseUrl + "/" + shortCode;

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("shortCode", shortCode, "shortUrl", shortUrl, "originalUrl", request.url()));
    }

    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        String originalUrl = urlShorteningService.resolveUrl(shortCode);
        if (originalUrl == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create(originalUrl))
                .build();
    }

    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<?> getStats(@PathVariable String shortCode) {
        Map<Object, Object> stats = urlShorteningService.getStats(shortCode);
        if (stats == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(stats);
    }
}
Test the full flow end-to-end

Now you can test the entire lifecycle: shorten a URL, visit the short link, and check the analytics. You will use curl for all three requests.

  • Make sure your Spring Boot application is running. If it is not, start it from IntelliJ by clicking the green play button next to your main method in UrlShortenerApplication.java.
  • Shorten a URL by running this command in your terminal:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url": "https://spring.io/projects/spring-boot"}'

You should see a JSON response with a shortCode, shortUrl, and originalUrl field. Note the shortCode value (e.g. b or c) for the next commands.

  • Test the redirect by visiting the short URL with curl. Replace b with the short code from the previous response:
curl -v http://localhost:8080/[[SHORTCODE="your short code from the previous response, e.g. b"]]

You should see a 302 Found response with a Location header pointing to https://spring.io/projects/spring-boot.

Why use the -v flag?

The -v flag enables verbose output in curl, showing the full HTTP headers. Without it, curl would silently follow the redirect and you would not see the 302 status or Location header.

  • Check the analytics for your shortened URL by running:
curl http://localhost:8080/api/stats/[[SHORTCODE="your short code from earlier, e.g. b"]]

You should see a JSON object containing originalUrl, shortCode, createdAt, and clicks showing a value of 1 (from the redirect you just triggered).

Seeing 404 on the redirect or stats?

Make sure the short code you are using matches exactly what the shorten response returned. Short codes are case-sensitive.

If you restarted your Redis container between the shorten call and the redirect call, the data was lost. Run the POST shorten command again to generate a fresh short code.

help me debug my 404 response on the redirect endpoint

Your URL shortener now handles the full lifecycle: shortening, redirecting, and tracking clicks. Next up, you will add rate limiting to protect the service from abuse and configure TTL expiration so old links clean themselves up.

Add Rate Limiting and Expiration

Your URL shortener works, but there is no protection against abuse. A malicious user could flood the /api/shorten endpoint with thousands of requests, filling Redis with junk data and consuming resources.

In this step, you'll build a rate limiter that caps requests per IP address using Redis atomic counters. You'll also verify how TTL expiration automatically cleans up old shortened URLs.

In this step, get ready to:
  • Create a rate limiter service that tracks request counts per IP address.
  • Update the controller to reject requests that exceed the limit.
  • Test rate limiting and verify URL expiration behavior.
Create the rate limiter service

Rate limiting prevents any single client from overloading your API. The pattern is simple: for each request, increment a counter keyed to the client's IP address. If the counter exceeds a threshold within a time window, reject the request.

Why Redis for rate limiting?

Redis INCR is atomic and fast, making it ideal for counting requests. Combined with TTL, the counter auto-resets after the time window expires. This is the fixed-window rate limiting pattern used by many production APIs.

  • In IntelliJ IDEA, right-click the com.example.urlshortener package in the Project sidebar.
  • Select New, then Java Class.
  • Name it RateLimiterService and press Enter.
  • Replace the generated content with the following code:
package com.example.urlshortener;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
public class RateLimiterService {

    private static final String RATE_LIMIT_PREFIX = "ratelimit:";

    private final StringRedisTemplate redisTemplate;
    private final int maxRequestsPerMinute;

    public RateLimiterService(StringRedisTemplate redisTemplate,
                              @Value("${app.rate-limit-per-minute}") int maxRequestsPerMinute) {
        this.redisTemplate = redisTemplate;
        this.maxRequestsPerMinute = maxRequestsPerMinute;
    }

    // Check if a client IP is within the rate limit
    public boolean isAllowed(String clientIp) {
        String key = RATE_LIMIT_PREFIX + clientIp;
        // Atomically increment the request count for this IP
        Long currentCount = redisTemplate.opsForValue().increment(key);
        // Set a 1-minute expiry on the first request in the window
        if (currentCount == 1) {
            redisTemplate.expire(key, Duration.ofMinutes(1));
        }
        return currentCount <= maxRequestsPerMinute;
    }
}

What does this code do?

  • RATE_LIMIT_PREFIX + clientIp creates a unique Redis key for each client, like ratelimit:127.0.0.1.
  • increment(key) atomically increases the counter by 1 and returns the new value. This maps directly to the Redis INCR command.
  • The TTL is set only when currentCount == 1 (the first request in the window). After 1 minute, Redis deletes the key and the counter resets.
  • The method returns true if the client has not exceeded maxRequestsPerMinute (configured as 10 in application.properties).
  • Save RateLimiterService.java.

Seeing a compile error?

  • Make sure the file is in the same package as your other classes: com.example.urlshortener.
  • Verify that app.rate-limit-per-minute=10 exists in your application.properties file.

Help me fix a compile error in my RateLimiterService

Wire rate limiting into the controller

The rate limiter service is ready, but it is not connected to any endpoint yet. You need to inject it into the controller and check the limit before processing each shorten request.

  • Open UrlController.java.
  • Replace the entire file content with the updated version below:
package com.example.urlshortener;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Map;

@RestController
public class UrlController {

    private final UrlShorteningService urlShorteningService;
    // Inject the rate limiter to guard the shorten endpoint
    private final RateLimiterService rateLimiterService;
    private final String baseUrl;

    public UrlController(UrlShorteningService urlShorteningService,
                         RateLimiterService rateLimiterService,
                         @Value("${app.base-url}") String baseUrl) {
        this.urlShorteningService = urlShorteningService;
        this.rateLimiterService = rateLimiterService;
        this.baseUrl = baseUrl;
    }

    @PostMapping("/api/shorten")
    public ResponseEntity<?> shortenUrl(@RequestBody ShortenRequest request,
                                        HttpServletRequest httpRequest) {
        // Check rate limit before processing the request
        if (!rateLimiterService.isAllowed(httpRequest.getRemoteAddr())) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                    .body(Map.of("error", "Rate limit exceeded. Try again later."));
        }

        if (request.url() == null || request.url().isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "URL is required"));
        }

        String shortCode = urlShorteningService.shortenUrl(request.url());
        String shortUrl = baseUrl + "/" + shortCode;

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("shortCode", shortCode, "shortUrl", shortUrl, "originalUrl", request.url()));
    }

    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        String originalUrl = urlShorteningService.resolveUrl(shortCode);
        if (originalUrl == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create(originalUrl))
                .build();
    }

    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<?> getStats(@PathVariable String shortCode) {
        Map<Object, Object> stats = urlShorteningService.getStats(shortCode);
        if (stats == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(stats);
    }
}

What changed in the controller?

  • RateLimiterService is injected as a constructor parameter, just like the other dependencies.
  • HttpServletRequest httpRequest is added to the shortenUrl method signature so Spring injects the current request object.
  • httpRequest.getRemoteAddr() extracts the client's IP address for rate limit tracking.
  • If the rate limit is exceeded, the endpoint returns HTTP 429 (Too Many Requests) immediately, before any URL processing happens.
  • Save UrlController.java.
  • Restart your Spring Boot application by clicking the green Run button in IntelliJ IDEA.
  • You should see Started UrlShortenerApplication in the console output with no errors.

Application won't start?

  • If you see a No qualifying bean error, make sure RateLimiterService.java has the @Service annotation.
  • Confirm that the constructor in UrlController now accepts three parameters: UrlShorteningService, RateLimiterService, and String baseUrl.

Help me fix my Spring Boot startup error

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete RateLimiterService.java:

package com.example.urlshortener;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
public class RateLimiterService {

    private static final String RATE_LIMIT_PREFIX = "ratelimit:";

    private final StringRedisTemplate redisTemplate;
    private final int maxRequestsPerMinute;

    public RateLimiterService(StringRedisTemplate redisTemplate,
                              @Value("${app.rate-limit-per-minute}") int maxRequestsPerMinute) {
        this.redisTemplate = redisTemplate;
        this.maxRequestsPerMinute = maxRequestsPerMinute;
    }

    public boolean isAllowed(String clientIp) {
        String key = RATE_LIMIT_PREFIX + clientIp;
        Long currentCount = redisTemplate.opsForValue().increment(key);
        if (currentCount == 1) {
            redisTemplate.expire(key, Duration.ofMinutes(1));
        }
        return currentCount <= maxRequestsPerMinute;
    }
}

Here is the complete UrlController.java:

package com.example.urlshortener;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Map;

@RestController
public class UrlController {

    private final UrlShorteningService urlShorteningService;
    private final RateLimiterService rateLimiterService;
    private final String baseUrl;

    public UrlController(UrlShorteningService urlShorteningService,
                         RateLimiterService rateLimiterService,
                         @Value("${app.base-url}") String baseUrl) {
        this.urlShorteningService = urlShorteningService;
        this.rateLimiterService = rateLimiterService;
        this.baseUrl = baseUrl;
    }

    @PostMapping("/api/shorten")
    public ResponseEntity<?> shortenUrl(@RequestBody ShortenRequest request,
                                        HttpServletRequest httpRequest) {
        if (!rateLimiterService.isAllowed(httpRequest.getRemoteAddr())) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                    .body(Map.of("error", "Rate limit exceeded. Try again later."));
        }

        if (request.url() == null || request.url().isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "URL is required"));
        }

        String shortCode = urlShorteningService.shortenUrl(request.url());
        String shortUrl = baseUrl + "/" + shortCode;

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("shortCode", shortCode, "shortUrl", shortUrl, "originalUrl", request.url()));
    }

    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        String originalUrl = urlShorteningService.resolveUrl(shortCode);
        if (originalUrl == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create(originalUrl))
                .build();
    }

    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<?> getStats(@PathVariable String shortCode) {
        Map<Object, Object> stats = urlShorteningService.getStats(shortCode);
        if (stats == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(stats);
    }
}
Test rate limiting and verify URL expiration

The rate limiter is configured to allow 10 requests per minute per IP address. To confirm it works, you'll send 11 requests in quick succession and observe the 429 response on the 11th.

  • Open your terminal and send 11 requests to the shorten endpoint by running this command:
for i in $(seq 1 11); do curl -s -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com"}'; echo; done

What should I see?

The first 10 responses will return HTTP 201 with a short code and URL. The 11th response will return:

{"error":"Rate limit exceeded. Try again later."}

This is an HTTP 429 (Too Many Requests) response. After 1 minute, the counter resets and you can send requests again.

Now verify that your shortened URLs have a TTL set. The saveUrlMapping method in UrlShorteningService already calls redisTemplate.expire(key, Duration.ofHours(defaultTtlHours)) which sets each URL to expire after 24 hours.

  • Check the TTL on one of your shortened URL keys by running this command:
docker exec -it redis redis-cli TTL url:b
  • You should see a number around 86400 (seconds in 24 hours), confirming the key will auto-expire.

What is TTL expiration?

TTL (Time To Live) tells Redis to automatically delete a key after a set duration. When the TTL reaches 0, the key disappears as if it never existed.

This keeps your Redis instance clean without needing a background cleanup job. Any request to an expired short code will return a 404, just as if it was never created.

  • Verify the rate limit counter has a TTL too by running:
docker exec -it redis redis-cli TTL ratelimit:127.0.0.1
  • You should see a number between 1 and 60 (seconds remaining in the rate limit window). After it hits 0, the key is deleted and requests are allowed again.

Seeing -2 from the TTL command?

A TTL of -2 means the key does not exist. If you're checking the rate limit key, it may have already expired. Run the loop command again and immediately check the TTL.

For URL keys, make sure you're using the correct key name. Run docker exec -it redis redis-cli KEYS "url:*" to see all URL keys currently stored.

Help me debug my Redis TTL check

Your URL shortener now protects itself against abuse and automatically cleans up expired links. Next up, you'll tackle the Secret Mission and add custom alias support.

Secret mission

Add a Custom Alias Feature

Your URL shortener generates random-looking codes like 'b' or 'c'. But what if users want memorable, branded short links? Add a custom alias feature that lets users pick their own short code, with conflict detection and input validation.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Spring Boot project files (in your url-shortener folder on your Desktop).
  • Redis Docker container (named redis).
  • Docker image (redis:latest).
  • Redis data (URL mappings, counter, rate limit keys stored inside the container).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Redis container continues running in the background.
  • Your Spring Boot app stops when you close the terminal, but the project files remain on your Desktop.
  • Redis data persists as long as the container is running.

Pause - I'll come back to this later

Stop the Redis container to free up memory. Your data and project files stay intact for next time.

  • Stop the Redis container by running:
docker stop redis
  • When you are ready to resume, start it again by running:
docker start redis

Your project files in the url-shortener folder remain untouched.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the Redis container, its data, and optionally your project files.

  • Stop and remove the Redis container by running:
docker stop redis
docker rm redis
  • Remove the Redis Docker image by running:
docker rmi redis:latest
  • Delete your project folder by running:
rm -rf ~/Desktop/url-shortener

Everything is now removed from your machine.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional URL shortener service that implements the same core system design patterns used by production-scale shorteners like bit.ly.

You've learned how to:

  • Built a Base62 encoder with Redis atomic counters for collision-free ID generation.
  • Modeled URL data in Redis hashes with TTL expiration and click analytics tracking.
  • Implemented HTTP 302 redirects and rate limiting using Redis counters with TTL windows.
  • Extended the service with custom alias support and conflict handling.

Ready to quiz yourself?