Build a URL Shortener with Spring Boot

Build a REST API that shortens URLs and redirects visitors using Spring Boot.

Introduction

30 Second Summary

Every time you click a bit.ly link, a server looks up where that short code points and sends you there in milliseconds. Behind that simple redirect is a REST API connected to a database.

In this project, you will build your own URL shortener API using Spring Boot and a live PostgreSQL database hosted on Supabase.

What You'll Build

You'll fire off a request with a long URL, get back a tidy 6-character code, then paste that code into a browser and watch it redirect to the original page while the API counts the click.

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

  • Shorten any valid URL and receive a compact short code back from your running API.
  • Redirect visitors who hit the short code to the original destination, with every click counted automatically.
  • Reject invalid URLs with clear error messages and return proper 404 responses for codes that don't exist.
  • Secret Mission: Add URL expiry with a configurable TTL so links expire after a set time, then build a scheduled cleanup task to purge them.

Are there any prerequisites?

This project assumes intermediate Java knowledge and a working JDK 17+ installation with Maven on your machine. You'll also need a free Supabase account, which you'll create during setup.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Generate the Project and Connect to Supabase

Every Spring Boot API needs two foundations: a configured project skeleton and a live database connection. Without these, there is nothing to build on top of.

In this step you will generate a Spring Boot project using Spring Initializr, create a Supabase PostgreSQL database, and wire the two together so the app starts cleanly.

In this step, get ready to:
  • Generate a Spring Boot 4.1.0 Maven project with the correct dependencies.
  • Create a Supabase project and copy its Session Pooler connection string.
  • Configure your app to connect to the database and confirm it starts.
Generate the Spring Boot project

Spring Initializr is a web tool that generates a ready-to-run project with all the boilerplate (build file, main class, folder structure) already in place.

  • Open https://start.spring.io in your browser.
  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot to 4.1.0.
  • Under Project Metadata, enter com.example for Group.
  • Enter url-shortener for Artifact.
  • Set Packaging to Jar.
  • Set Java to 17.
  • Click ADD DEPENDENCIES and add each of these one at a time:
  • Spring Web.
  • Spring Data JPA.
  • PostgreSQL Driver.
  • Validation.
  • Click GENERATE to download the zip file.
  • Extract the zip to your Desktop.
  • Open the extracted url-shortener folder in your IDE.

What did Spring Initializr create?

The zip contains a full Maven project: a pom.xml with your four dependencies, a main class (UrlShortenerApplication.java), and a blank application.properties file.

It also includes Maven wrapper scripts (mvnw for macOS/Linux, mvnw.cmd for Windows). The wrapper lets you run Maven commands without a global Maven install.

  • Confirm the project structure exists by checking that src/main/java/com/example/urlshortener/UrlShortenerApplication.java is present in the IDE file tree.

Don't see the file?

Make sure you opened the inner url-shortener folder (the one containing pom.xml), not the outer extraction folder.

help me find UrlShortenerApplication.java in my project

Create a Supabase project and get the connection string

Your API needs a real database to store shortened URLs. Supabase gives you a hosted PostgreSQL instance on a generous free tier (500 MB storage, 200 pooler connections).

  • Open https://database.new in your browser to create a new Supabase project.
  • Choose a name and region for your project.
  • Set a strong database password and save it somewhere safe (you will need it in a moment).
  • Click Create new project and wait for provisioning to complete.

Once your project is ready, you need the Session Pooler connection string.

  • Click the Connect button at the top of your project dashboard.
  • Find the Session pooler section (port 5432).
  • Copy the connection string. It looks like this:
postgresql://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres

Why the Session Pooler?

The Session Pooler provides a stable IPv4 address for your connection. It is the recommended option for a persistent backend running on your local machine.

Save your password now

Supabase only shows your database password at creation time. If you lose it, you can reset it from Database Settings in the dashboard.

Configure application.properties

Spring Boot reads database connection details from application.properties at startup. You need to translate your Supabase connection string into the fields Spring expects.

  • In your IDE, open src/main/resources/application.properties.
  • Replace the empty file contents with the following configuration:
# Database connection via Supabase Session Pooler
spring.datasource.url=jdbc:postgresql://aws-0-[[SUPABASE_REGION="your-region"]].pooler.supabase.com:5432/postgres
spring.datasource.username=postgres.[[PROJECT_REF="your-project-ref"]]
spring.datasource.password=your-database-password-here
spring.datasource.driver-class-name=org.postgresql.Driver

# JPA / Hibernate settings
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

What does this configuration do?

  • spring.datasource.url tells Spring where to find the database. The jdbc:postgresql:// prefix is how Java's JDBC driver addresses PostgreSQL.
  • spring.datasource.username is the pooler username. Supabase formats it as postgres. followed by your project reference.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to create or alter tables automatically to match your Java entities. Perfect for development.
  • spring.jpa.properties.hibernate.dialect ensures Hibernate generates PostgreSQL-compatible SQL.
  • Replace your-database-password-here with the actual password you saved when creating your Supabase project.
  • Save application.properties.

Before you run the app, what do you expect to see if the database credentials are correct?

  • Start the application by running this command from the url-shortener folder in your terminal:

macOS

./mvnw spring-boot:run

Windows

.\mvnw.cmd spring-boot:run

You should see a line like Started UrlShortenerApplication near the bottom of the console output with no database connection errors.

Seeing a connection error?

  • Double-check that your region and project reference in the URL match what the Supabase dashboard shows (e.g. aws-0-us-east-1).
  • Confirm your password has no trailing spaces. Copy it fresh from wherever you saved it.
  • Make sure your Supabase project has finished provisioning. The dashboard shows a green status when it is ready.

My Spring Boot app won't connect to Supabase

✔️ Awesome, I've got everything!

Great. Make sure you have saved application.properties and the app is running without errors.

ⓧ I'd like to double check the full code

Here is what application.properties should look like (with your real values substituted):

spring.datasource.url=jdbc:postgresql://aws-0-your-region.pooler.supabase.com:5432/postgres
spring.datasource.username=postgres.your-project-ref
spring.datasource.password=your-database-password-here
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

And your pom.xml (generated by Spring Initializr) should contain these dependencies:

<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-jpa</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-validation</artifactId>
	</dependency>
	<dependency>
		<groupId>org.postgresql</groupId>
		<artifactId>postgresql</artifactId>
		<scope>runtime</scope>
	</dependency>
</dependencies>

Your Spring Boot app is alive and talking to a real PostgreSQL database. Next up, you will build the shorten endpoint so the API can actually do something useful.

Build the Shorten Endpoint

Your Spring Boot app is running and connected to Supabase. Now it is time to build the feature that makes a URL shortener a URL shortener: the ability to accept a long URL and hand back a short code.

This step builds the full vertical slice from database to HTTP endpoint. You will create a JPA entity, a repository, a service that generates random short codes, and a controller that returns them over HTTP.

In this step, get ready to:
  • Create the JPA entity and repository that map to a database table.
  • Create the service layer with Base62 code generation.
  • Create the controller with a POST endpoint that returns a short code.
Create the JPA entity

Every URL mapping needs a home in the database. A JPA entity is a Java class that maps directly to a database table. Each field becomes a column.

  • Create a new package called model inside src/main/java/com/example/urlshortener/.
  • Create a new file called UrlMapping.java inside that package with the following code:
package com.example.urlshortener.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

// This entity maps to the "url_mappings" table in your database
@Entity
@Table(name = "url_mappings")
public class UrlMapping {

    // Auto-generated primary key
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // The 6-character code visitors will use
    @Column(nullable = false, unique = true, length = 10)
    private String shortCode;

    // The full destination URL
    @Column(nullable = false, length = 2048)
    private String originalUrl;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    // Tracks how many times this link has been visited
    @Column(nullable = false)
    private long clickCount = 0;

What does this code do?

The @Entity annotation tells Hibernate this class represents a database table. @Table(name = "url_mappings") sets the table name explicitly.

Each @Column annotation maps a field to a table column. The unique = true constraint on shortCode ensures no two URLs share the same code.

  • Below the fields (still inside the class), add the constructors and getter/setter methods:
    // Required by JPA
    public UrlMapping() {}

    // Convenience constructor for creating new mappings
    public UrlMapping(String shortCode, String originalUrl) {
        this.shortCode = shortCode;
        this.originalUrl = originalUrl;
        this.createdAt = LocalDateTime.now();
        this.clickCount = 0;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getShortCode() { return shortCode; }
    public void setShortCode(String shortCode) { this.shortCode = shortCode; }
    public String getOriginalUrl() { return originalUrl; }
    public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public long getClickCount() { return clickCount; }
    public void setClickCount(long clickCount) { this.clickCount = clickCount; }
}

Why two constructors?

JPA requires a no-argument constructor to instantiate entities when loading from the database. The second constructor is a convenience for your own code, so you can create a new mapping with one line.

  • Save UrlMapping.java.
  • Restart the app by running:
.\mvnw.cmd spring-boot:run

You should see "Started UrlShortenerApplication" in the console with no errors. Because ddl-auto=update is set, Hibernate automatically creates the url_mappings table in your Supabase database.

Seeing a database connection error?

  • Double-check your application.properties credentials match your Supabase project. The username format is postgres.your-project-ref (with a dot, not a slash).
  • Make sure your Supabase project is not paused. Free-tier projects pause after 1 week of inactivity.

Having trouble? Help me debug my Spring Boot database connection to Supabase

Create the repository interface

Spring Data JPA lets you access the database without writing SQL. You define an interface, and Spring generates the implementation at runtime.

  • Stop the running app with Ctrl+C in the terminal.
  • Create a new package called repository inside src/main/java/com/example/urlshortener/.
  • Create UrlMappingRepository.java inside that package with the following code:
package com.example.urlshortener.repository;

import com.example.urlshortener.model.UrlMapping;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

// Spring generates the implementation of this interface automatically
public interface UrlMappingRepository extends JpaRepository<UrlMapping, Long> {
    Optional<UrlMapping> findByShortCode(String shortCode);
    boolean existsByShortCode(String shortCode);
}

What does this code do?

By extending JpaRepository, you get built-in methods like save(), findById(), and deleteAll() for free.

The two custom methods use Spring Data's query derivation. findByShortCode generates a SELECT WHERE short_code = ? query automatically from the method name.

  • Save UrlMappingRepository.java.
Create the service with Base62 code generation

The service layer holds your business logic. It generates random short codes, checks for collisions, and persists new mappings.

  • Create a new package called exception inside src/main/java/com/example/urlshortener/.
  • Create ShortCodeNotFoundException.java inside that package. This exception will be thrown when someone looks up a code that does not exist:
package com.example.urlshortener.exception;

// Thrown when a short code has no matching URL in the database
public class ShortCodeNotFoundException extends RuntimeException {
    public ShortCodeNotFoundException(String code) {
        super("No URL found for short code: " + code);
    }
}
  • Save ShortCodeNotFoundException.java.
  • Now create a new package called service inside src/main/java/com/example/urlshortener/.
  • Create UrlService.java inside that package. Start with the class declaration, constants, and the shorten method:
package com.example.urlshortener.service;

import com.example.urlshortener.exception.ShortCodeNotFoundException;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.repository.UrlMappingRepository;
import org.springframework.stereotype.Service;

import java.security.SecureRandom;

@Service
public class UrlService {

    // All possible characters for a short code (a-z, A-Z, 0-9)
    private static final String CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private static final int CODE_LENGTH = 6;
    private static final SecureRandom random = new SecureRandom();

    private final UrlMappingRepository repository;

    public UrlService(UrlMappingRepository repository) {
        this.repository = repository;
    }

    // Creates a new short URL mapping and saves it to the database
    public UrlMapping shorten(String originalUrl) {
        String code = generateUniqueCode();
        UrlMapping mapping = new UrlMapping(code, originalUrl);
        return repository.save(mapping);
    }

What does this code do?

The CHARACTERS string contains 62 alphanumeric characters (Base62). A 6-character code from this set gives over 56 billion possible combinations.

SecureRandom produces cryptographically strong random values, making short codes unpredictable. The shorten method generates a unique code and persists the mapping in one call.

  • Below the shorten method (still inside the class), add the lookup methods and the private code-generation helpers:
    // Looks up a URL by short code and increments the click counter
    public UrlMapping getAndTrackClick(String shortCode) {
        UrlMapping mapping = repository.findByShortCode(shortCode)
                .orElseThrow(() -> new ShortCodeNotFoundException(shortCode));
        mapping.setClickCount(mapping.getClickCount() + 1);
        return repository.save(mapping);
    }

    // Looks up stats without incrementing clicks
    public UrlMapping getStats(String shortCode) {
        return repository.findByShortCode(shortCode)
                .orElseThrow(() -> new ShortCodeNotFoundException(shortCode));
    }

    // Generates codes until one is found that doesn't already exist
    private String generateUniqueCode() {
        String code;
        do {
            code = generateCode();
        } while (repository.existsByShortCode(code));
        return code;
    }

    // Builds a random 6-character string from the Base62 alphabet
    private String generateCode() {
        StringBuilder sb = new StringBuilder(CODE_LENGTH);
        for (int i = 0; i < CODE_LENGTH; i++) {
            sb.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
        }
        return sb.toString();
    }
}

Why check for collisions?

The generateUniqueCode method uses a do-while loop that keeps generating new codes until it finds one not already in the database. With 56 billion possibilities, collisions are extremely rare. But handling them makes the system correct even at scale.

  • Save UrlService.java.
  • Restart the app to confirm everything compiles:
.\mvnw.cmd spring-boot:run

You should see "Started UrlShortenerApplication" with no errors. The service bean is now registered in the Spring context.

Seeing a compilation error?

  • Check that ShortCodeNotFoundException.java is in the com.example.urlshortener.exception package. The import in UrlService must match exactly.
  • Make sure both code blocks were added to the same UrlService.java file. The second block goes inside the class, before the closing }.

Help me fix my UrlService compilation error

Create the controller with POST /api/shorten

The controller is the HTTP layer. It receives requests, delegates to the service, and returns JSON responses.

  • Stop the running app with Ctrl+C.
  • Create a new package called dto inside src/main/java/com/example/urlshortener/.
  • Create ShortenResponse.java inside that package. This record defines the JSON shape of the response:
package com.example.urlshortener.dto;

// A Java record auto-generates the constructor, getters, and JSON serialization
public record ShortenResponse(
    String shortCode,
    String shortUrl,
    String originalUrl
) {}

What is a Java record?

A record is a compact class that holds immutable data. Java automatically generates the constructor, getters, equals(), hashCode(), and toString() for you. Spring Boot serializes records to JSON automatically.

  • Save ShortenResponse.java.
  • Create a new package called controller inside src/main/java/com/example/urlshortener/.
  • Create UrlController.java inside that package with the following code:
package com.example.urlshortener.controller;

import com.example.urlshortener.dto.ShortenResponse;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.service.UrlService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class UrlController {

    private final UrlService urlService;

    public UrlController(UrlService urlService) {
        this.urlService = urlService;
    }

    // Accepts a JSON body with a "url" field and returns the short code
    @PostMapping("/api/shorten")
    public ResponseEntity<ShortenResponse> shorten(@RequestBody java.util.Map<String, String> body) {
        UrlMapping mapping = urlService.shorten(body.get("url"));
        ShortenResponse response = new ShortenResponse(
                mapping.getShortCode(),
                "http://localhost:8080/" + mapping.getShortCode(),
                mapping.getOriginalUrl()
        );
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

What does this code do?

The @RestController annotation tells Spring this class handles HTTP requests and returns JSON. @PostMapping("/api/shorten") maps POST requests to this method.

The method reads the url field from the JSON body, calls the service to create a mapping, and returns HTTP 201 (Created) with the short code in the response.

  • Save UrlController.java.
  • Restart the app:
.\mvnw.cmd spring-boot:run

Before you test, what do you think will happen when you send a POST request with a URL in the body? Will you get back a short code or an error?

  • Open a second terminal and send this request:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"https://www.example.com/very/long/path\"}"

You should receive a JSON response containing a 6-character short code, a short URL, and the original URL you submitted.

What should I see?

The response looks something like this (your short code will be different):

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

Getting a 404 or connection refused?

  • Make sure the first terminal still shows the app running. If it crashed, check for compilation errors and restart.
  • On Windows, if curl is not recognized, use PowerShell's Invoke-WebRequest or install curl separately.
  • Confirm the app started on port 8080 by checking the console output for "Tomcat started on port 8080".

Help me troubleshoot my /api/shorten endpoint

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

You created several files in this step. Scroll up and confirm each one is present and matches the code blocks above:

  • UrlMapping.java (entity fields + constructors + getters/setters).
  • UrlMappingRepository.java (interface with two query methods).
  • ShortCodeNotFoundException.java (custom runtime exception).
  • UrlService.java (class setup + shorten method + lookup methods + code generation helpers).
  • ShortenResponse.java (record with three fields).
  • UrlController.java (POST endpoint returning HTTP 201).

Your URL shortener can now accept any URL and hand back a unique 6-character code. Next up, you will make that code actually redirect visitors to the original URL.

Add the Redirect and Stats Endpoints

You can create short codes, but nobody can actually use them yet. A short code is useless until a visitor can hit it in a browser and land at the original URL.

This step brings your shortener to life with two new endpoints. The first redirects visitors via HTTP 302, and the second exposes click stats so you can see how many times a short code has been used.

In this step, get ready to:
  • Add a redirect endpoint that sends visitors to the original URL.
  • Create a stats endpoint that returns click count and metadata.
  • Test both endpoints against your live Supabase database.
Add GET /{shortCode} to the controller

Your service already has a getAndTrackClick method that looks up a short code and increments its click count. But it throws a ShortCodeNotFoundException when the code does not exist, and that class has not been created yet.

  • Create a new file at src/main/java/com/example/urlshortener/exception/ShortCodeNotFoundException.java and add this code:
package com.example.urlshortener.exception;

public class ShortCodeNotFoundException extends RuntimeException {
    public ShortCodeNotFoundException(String code) {
        super("No URL found for short code: " + code);
    }
}

What does this code do?

This is a custom runtime exception. When the service cannot find a mapping for a given short code, it throws this exception with a descriptive message.

Spring Boot translates uncaught exceptions into HTTP 500 errors by default. In a later step, you will add a global exception handler that converts this into a proper 404 response.

Now you can add the redirect endpoint to the controller. This method looks up the short code, increments the click count, and sends the visitor to the original URL using an HTTP 302 redirect.

  • Open UrlController.java and add these two imports at the top of the import block:
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;

Why do I need HttpHeaders?

An HTTP 302 redirect works by returning a Location header with the destination URL. The browser reads that header and automatically navigates to the original URL. HttpHeaders lets you set that header on your response.

  • In UrlController.java, find the closing brace of the shorten method, then paste the following method below it:
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
    UrlMapping mapping = urlService.getAndTrackClick(shortCode);
    HttpHeaders headers = new HttpHeaders();
    headers.add(HttpHeaders.LOCATION, mapping.getOriginalUrl());
    return new ResponseEntity<>(headers, HttpStatus.FOUND);
}

What does this code do?

  • @GetMapping("/{shortCode}") captures any path like /abc123 and passes abc123 into the method.
  • getAndTrackClick finds the mapping and increments the click counter before saving.
  • HttpStatus.FOUND sends a 302 status code, telling the browser to follow the Location header to the original URL.
  • Save UrlController.java.
  • Restart your app by pressing Ctrl+C in the terminal, then running:
.\mvnw.cmd spring-boot:run

Before you test, think about what you expect to happen when you visit your short code in a browser. Will you stay on localhost:8080 or end up somewhere else?

  • Open your browser and visit http://localhost:8080/ followed by the short code you created in the previous step (check your earlier curl response for the code).

Your browser should redirect to the original URL you shortened earlier. The address bar updates to show the full destination URL instead of your short code.

Not redirecting?

  • Make sure you copied the exact short code from your earlier curl response (it is case-sensitive).
  • Check that the app restarted without errors. Look for "Started UrlShortenerApplication" in the console.
  • If you see a 500 error, confirm you created the ShortCodeNotFoundException.java file in the correct package folder.

Still stuck? Why isn't my redirect endpoint working?

Add a stats endpoint

You can now shorten URLs and redirect visitors. The final piece is a stats endpoint that reveals how many times a short code has been clicked.

  • Create a new file at src/main/java/com/example/urlshortener/dto/StatsResponse.java and add this code:
package com.example.urlshortener.dto;

import java.time.LocalDateTime;

// Holds the data returned by the stats endpoint
public record StatsResponse(
    String shortCode,
    String originalUrl,
    long clickCount,
    LocalDateTime createdAt
) {}

What does this code do?

This is a Java record that acts as a read-only data container. It defines the exact JSON shape the stats endpoint returns: the short code, original URL, click count, and creation timestamp.

  • Save StatsResponse.java.
  • Return to UrlController.java and add this import alongside the existing ones:
import com.example.urlshortener.dto.StatsResponse;
  • Below the redirect method you just added, paste the stats method:
@GetMapping("/api/stats/{shortCode}")
public ResponseEntity<StatsResponse> stats(@PathVariable String shortCode) {
    UrlMapping mapping = urlService.getStats(shortCode);
    StatsResponse response = new StatsResponse(
            mapping.getShortCode(),
            mapping.getOriginalUrl(),
            mapping.getClickCount(),
            mapping.getCreatedAt()
    );
    return ResponseEntity.ok(response);
}

What does this code do?

  • @GetMapping("/api/stats/{shortCode}") maps GET requests like /api/stats/abc123 to this method.
  • getStats looks up the mapping without incrementing the click count. This is a read-only operation.
  • ResponseEntity.ok(response) returns the JSON with HTTP 200.
  • Save UrlController.java.
  • Restart your app by pressing Ctrl+C then running:
.\mvnw.cmd spring-boot:run

What do you expect to see when you check the stats for the short code you just visited in your browser? Think about whether the click count should be zero or higher.

  • Test the stats endpoint by running this curl command (replace the short code with yours):
curl http://localhost:8080/api/stats/[[SHORTCODE="your-short-code"]]

You should see a JSON response containing the short code, original URL, and a clickCount of at least 1 (from the redirect you tested earlier).

Seeing a 500 error on the stats endpoint?

  • Make sure the StatsResponse import is in UrlController.java.
  • Confirm you used the exact short code from your earlier curl response. Codes are case-sensitive.
  • If the app did not start, check the console for compilation errors and verify StatsResponse.java is in the dto package folder.

Need help? Why is my stats endpoint returning an error?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: ShortCodeNotFoundException.java, StatsResponse.java, and UrlController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these pieces is present in your project, in order:

  • ShortCodeNotFoundException.java in the exception package with the single constructor.
  • StatsResponse.java in the dto package with four fields: shortCode, originalUrl, clickCount, createdAt.
  • UrlController.java with all three methods: shorten, redirect, and stats.
  • Imports in the controller include HttpHeaders and StatsResponse.

Your shortener is fully functional. Anyone can shorten a URL, visit the short code to get redirected, and check the click stats. Next up, you will break it on purpose by sending invalid input, then build proper error handling to catch those failures gracefully.

Add Validation and Error Handling

Your URL shortener works end to end. You can shorten links, redirect visitors, and track clicks. But there is a critical gap lurking under the surface.

The API currently accepts any string as a URL. Send hello and it happily stores it. Request a short code that does not exist and you get an ugly default error instead of a structured response. Real APIs need to reject bad input and return consistent, predictable error messages.

In this step, get ready to:
  • Create a request DTO with Jakarta Validation annotations that reject invalid URLs.
  • Build a global exception handler that returns consistent JSON error responses.
  • Wire the validated DTO into the controller and confirm both error paths work.
Send invalid data to the current API

Before fixing anything, you need to see the problem firsthand. Your app should still be running from the previous step.

What do you think will happen if you try to shorten the string not-a-url? Will the API reject it, or store it?

  • Test with an invalid URL by running this command:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"not-a-url\"}"

You get back a 201 response with a short code. The API happily accepted not-a-url as if it were a real URL. That is the first problem.

Now try requesting a short code that does not exist. What response do you expect?

  • Send a GET request to a nonexistent code by running:
curl -i http://localhost:8080/doesnotexist

You get back a 500 Internal Server Error with Spring's default error format. The response does not tell the caller what went wrong in a predictable, structured way. That is the second problem.

What are the two problems?

First, there is no input validation. Any string is accepted as a URL, even complete nonsense.

Second, when something goes wrong (like a missing code), the API returns Spring's default error body instead of a consistent JSON shape your callers can parse. You will fix both of these now.

Add Jakarta Validation to the request DTO

Jakarta Validation lets you declare constraints directly on your data class with annotations. Spring automatically checks these before your controller method runs.

  • Create a new file at src/main/java/com/example/urlshortener/dto/ShortenRequest.java and add the following code:
package com.example.urlshortener.dto;

import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;

public record ShortenRequest(
    @NotBlank(message = "URL must not be blank")
    @URL(message = "Must be a valid URL")
    String url
) {}

What do these annotations do?

  • @NotBlank rejects null values and empty strings. The request must contain a url field with actual content.
  • @URL checks that the string is a well-formed URL (starts with a protocol like http:// or https://). Plain text like not-a-url fails this check.
  • The message attribute on each annotation defines the error text that appears in the response when validation fails.

Now you need to tell the controller to use this DTO and validate it.

  • Open src/main/java/com/example/urlshortener/controller/UrlController.java.
  • Find the imports at the top of the file. Replace them with the following:
package com.example.urlshortener.controller;

import com.example.urlshortener.dto.ShortenRequest;
import com.example.urlshortener.dto.ShortenResponse;
import com.example.urlshortener.dto.StatsResponse;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.service.UrlService;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
  • Find the shorten method. It currently looks like this:
    @PostMapping("/api/shorten")
    public ResponseEntity<ShortenResponse> shorten(@RequestBody java.util.Map<String, String> body) {
        UrlMapping mapping = urlService.shorten(body.get("url"));
        ShortenResponse response = new ShortenResponse(
                mapping.getShortCode(),
                "http://localhost:8080/" + mapping.getShortCode(),
                mapping.getOriginalUrl()
        );
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
  • Replace the entire shorten method with this updated version:
    @PostMapping("/api/shorten")
    public ResponseEntity<ShortenResponse> shorten(@Valid @RequestBody ShortenRequest request) {
        UrlMapping mapping = urlService.shorten(request.url());
        ShortenResponse response = new ShortenResponse(
                mapping.getShortCode(),
                "http://localhost:8080/" + mapping.getShortCode(),
                mapping.getOriginalUrl()
        );
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

What changed?

  • The parameter changed from @RequestBody java.util.Map<String, String> body to @Valid @RequestBody ShortenRequest request. Spring now deserializes the JSON into your typed record.
  • @Valid triggers Jakarta Validation on the ShortenRequest object. If validation fails, Spring throws a MethodArgumentNotValidException before your method body ever runs.
  • body.get("url") became request.url() because the record gives you a typed accessor.
  • Save UrlController.java.
  • Stop the running app with Ctrl+C in the terminal, then restart it by running:
.\mvnw.cmd spring-boot:run
  • Once it starts, test with the same invalid URL by running:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"not-a-url\"}"

This time you get a 400 Bad Request. Validation is working. But the error body is Spring's default format, not a shape you control. You will fix that next.

Still getting a 201 response?

  • Make sure you added @Valid before @RequestBody in the method signature. Without it, Spring skips validation entirely.
  • Confirm your ShortenRequest.java is in the dto package and has the @URL annotation imported from org.hibernate.validator.constraints.URL.
  • Check that you restarted the app after saving. help me debug why my @Valid annotation is not triggering validation
Create a global exception handler

Validation rejects bad input now, but the error response is Spring's default format. You want every error to follow the same shape so callers can parse errors consistently.

  • Create a new file at src/main/java/com/example/urlshortener/dto/ErrorResponse.java and add this code:
package com.example.urlshortener.dto;

public record ErrorResponse(
    int status,
    String error,
    String message
) {}

What is this record for?

Every error your API returns will use this same three-field shape: a numeric status, a short error label, and a human-readable message. Callers can rely on this consistent structure instead of guessing at different formats for different errors.

  • Create a new file at src/main/java/com/example/urlshortener/exception/GlobalExceptionHandler.java and add the following code:
package com.example.urlshortener.exception;

import com.example.urlshortener.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .findFirst()
                .orElse("Validation failed");
        ErrorResponse body = new ErrorResponse(400, "Bad Request", message);
        return ResponseEntity.badRequest().body(body);
    }
}

What does @RestControllerAdvice do?

@RestControllerAdvice registers this class as a global interceptor for exceptions thrown by any controller. When a MethodArgumentNotValidException is thrown (because @Valid failed), Spring routes it here instead of using its default error format.

The handler extracts the first field error message and wraps it in your ErrorResponse record. Now every validation failure returns the same predictable JSON shape.

  • Save both files.
  • Restart the app with Ctrl+C then:
.\mvnw.cmd spring-boot:run
  • Test the validation error by running:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"not-a-url\"}"

You should see a clean JSON response with status 400, error "Bad Request", and a message like url: Must be a valid URL. That is your custom format working.

Still seeing Spring's default error format?

  • Make sure GlobalExceptionHandler.java is in the exception package. Spring's component scan must find it.
  • Verify the class is annotated with @RestControllerAdvice (not just @ControllerAdvice).
  • help me debug why my GlobalExceptionHandler is not intercepting validation errors

Now add handlers for the other error types. The nonexistent-code case still returns a 500.

  • In GlobalExceptionHandler.java, add these two methods inside the class, before the closing brace:
    @ExceptionHandler(ShortCodeNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ShortCodeNotFoundException ex) {
        ErrorResponse body = new ErrorResponse(404, "Not Found", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        ErrorResponse body = new ErrorResponse(500, "Internal Server Error", "An unexpected error occurred");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
    }

What do these handlers cover?

  • handleNotFound catches the ShortCodeNotFoundException your service already throws when a short code does not exist. It returns a proper 404 instead of a 500.
  • handleGeneric is a safety net for any other unexpected exception. It returns a 500 with a generic message so stack traces never leak to the caller.
  • Save GlobalExceptionHandler.java.
  • Restart the app with Ctrl+C then:
.\mvnw.cmd spring-boot:run

Before you test, predict: what HTTP status and message do you expect when you request a code that does not exist?

  • Test the not-found error by running:
curl -i http://localhost:8080/doesnotexist

You should see a 404 response with your custom JSON: status 404, error "Not Found", and a message like No URL found for short code: doesnotexist.

Verify both the happy and error paths

Your API now validates input and returns structured errors. Run through the full set of scenarios to confirm everything works together.

  • Test a valid URL to confirm the happy path still works:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"https://www.example.com/very/long/path\"}"

You should receive a 201 response with a short code, short URL, and the original URL.

  • Test with an empty URL field:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"\"}"

You should see a 400 with the message url: URL must not be blank.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these files in place. Each one should match what you pasted above:

  • ShortenRequest.java in the dto package with @NotBlank and @URL annotations.
  • ErrorResponse.java in the dto package with three fields: status, error, message.
  • GlobalExceptionHandler.java in the exception package with three handler methods.
  • UrlController.java with the updated imports and @Valid @RequestBody ShortenRequest request parameter.

Your API now rejects garbage input and returns clean, parseable errors. Next up, you will extend this project with URL expiry and scheduled cleanup in the Secret Mission.

Secret mission

Add URL Expiry and Scheduled Cleanup

Your URL shortener works great, but production shorteners let users set a time-to-live on their links. Can you add TTL support that returns HTTP 410 Gone for expired URLs and automatically purges stale records from the database on a schedule?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on Supabase's free tier, so there are no ongoing costs. Free projects auto-pause after 1 week of inactivity.

Resources you used:

  • Supabase project (PostgreSQL database with the url_mappings table).
  • Local url-shortener project folder on your Desktop.

Keep everything running

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

  • Your Spring Boot app only runs when you start it manually, so it consumes no resources when stopped.
  • Your Supabase project stays on the free tier. It will auto-pause after 1 week of inactivity and wake when you reconnect.
  • Your local url-shortener folder remains on your Desktop for future work.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact for when you return.

  • If your Spring Boot app is still running in the terminal, press Ctrl+C to stop it.
  • Your Supabase project will auto-pause after 1 week of inactivity. No manual action is needed.
  • Leave your url-shortener folder in place so you can pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • If your Spring Boot app is still running, press Ctrl+C in the terminal to stop it.

Delete your Supabase project:

  • Go to your Supabase dashboard at https://supabase.com/dashboard.
  • Select your URL shortener project.
  • Click Project Settings in the left sidebar.
  • Scroll to the bottom of the General page.
  • Click Delete project and confirm the deletion.

Delete your local project folder:

  • On macOS/Linux, run:
rm -rf ~/Desktop/url-shortener
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\url-shortener

Nice Work!

Nice Work!

Nice work! You just built a production-style URL shortener REST API from scratch using Spring Boot and a live cloud database.

You've learned how to:

  • Design and build a layered Spring Boot REST API that shortens URLs, redirects visitors with HTTP 302, and tracks click counts in a live Supabase PostgreSQL database.
  • Persist data with Spring Data JPA entities and repositories, and generate collision-safe short codes using Base62 encoding with SecureRandom.
  • Reject invalid input with Jakarta Validation annotations and return consistent error responses through a @RestControllerAdvice global exception handler.
  • Secret Mission: Added TTL-based URL expiry with 410 Gone responses and a scheduled cleanup task that automatically purges expired records.

Ready to quiz yourself?