Shrink That Link: Craft a Java URL Shortener with Quarkus
Build a lean, cache-powered service that trims URLs, serves a Qute UI, and spits out QR codes in 10 easy steps.
Long URLs do not spark joy. That is true. And I was inspired by Sven’s blog post about an URL shortener service in plain Java, so I thought it’s time for a quick Quarkus version.
Unreadable links overflow chats, clutter dashboards, and break when a line wrap sneaks in. A tiny redirect service fixes the mess, and Quarkus lets you build one in 10 easy steps, complete with a browser UI, REST endpoint, and auto-generated QR codes. Fire up your terminal, roll up your sleeves, and follow along.
1 Bootstrap the project
First, create a new Quarkus project with all the required extensions. Open your terminal and run the following commands:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=url-shortener \
-DclassName="com.example.ShortenerResource" \
-Dpath="/learn" \
-Dextensions=rest,hibernate-orm-panache,cache,jdbc-postgresql,qute,quarkus-qrcodegen
cd url-shortener
This scaffolds a fresh Quarkus application wired with REST, Panache ORM, Caffeine cache, Qute templates, PostgreSQL support, and Quarkus QR code generation. And, as usual, you can grab the running project from my Github repository.
2 How a shortener works
A redirect service does three things:
Stores a mapping from a random base-62 key to the original URL.
Caches hot keys in memory for millisecond lookups.
Avoids key collisions by checking the database before persisting.
Base-62 gives you 62⁶ combinations, about 56 billion possibilities with six characters. A cryptographically secure random number generator makes the keys unguessable.
3 Model: the ShortLink
entity
Next, create a Panache entity to represent the shortened links. This class will be mapped to a database table.
The Quarkus maven plugin already scaffolded a “MyEntity”. Rename it to src/main/java/com/example/ShortLink.java
and copy the following content:
package com.example;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@Entity
public class ShortLink extends PanacheEntity {
@Column(unique = true, nullable = false)
public String key;
@Column(nullable = false, length = 2048)
public String originalUrl;
public static ShortLink findByKey(String key) {
return find("key", key).firstResult();
}
}
This ShortLink
entity has two fields: key
, which is the short identifier for the link, and originalUrl
, which is the URL it redirects to. The findByKey
method is a custom query that allows you to easily find a ShortLink
by its key.
4 Service layer: ShortenerService
To separate the business logic from the REST endpoint, we'll create a service class.
Create a new file src/main/java/com/example/ShortenerService.java
:
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.security.SecureRandom;
import java.util.Optional;
@ApplicationScoped
public class ShortenerService {
private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int KEY_LENGTH = 6;
private static final SecureRandom RANDOM = new SecureRandom();
@Transactional
public ShortLink createShortLink(String originalUrl) {
String key;
do {
key = generateKey();
} while (ShortLink.findByKey(key) != null);
ShortLink link = new ShortLink();
link.originalUrl = originalUrl;
link.key = key;
link.persist();
return link;
}
@CacheResult(cacheName = "urls")
public Optional<String> getOriginalUrl(String key) {
return Optional.ofNullable(ShortLink.findByKey(key))
.map(link -> link.originalUrl);
}
private String generateKey() {
StringBuilder sb = new StringBuilder(KEY_LENGTH);
for (int i = 0; i < KEY_LENGTH; i++) {
sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length())));
}
return sb.toString();
}
}
This service contains the logic for:
createShortLink
: Generates a unique 6-character key, creates a newShortLink
entity, and persists it to the database. The@Transactional
annotation ensures that this method runs within a database transaction.getOriginalUrl
: Retrieves the original URL for a given key.generateKey
: Generates a random string to be used as the short key.
To improve performance, we can cache the mapping from the short key to the original URL. This way, we don't have to hit the database every time someone uses a short link.
5 Programmatic access: /api/shorten
Now, let's create the REST endpoint that will handle the creation of new short links and the redirection.
Drop below into the file src/main/java/com/example/ShortenerResource.java
:
package com.example;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import java.net.URI;
@Path("/api")
public class ShortenerResource {
@Inject
ShortenerService service;
@POST
@Path("/shorten")
public Response shorten(String url) {
ShortLink link = service.createShortLink(url);
return Response.ok(link.key).build();
}
@GET
@Path("/{key}")
public Response redirect(@PathParam("key") String key) {
return service.getOriginalUrl(key)
.map(url -> Response.status(Response.Status.FOUND).location(URI.create(url)).build())
.orElse(Response.status(Response.Status.NOT_FOUND).build());
}
}
This resource defines two endpoints:
POST /shorten
: Takes a URL in the request body and returns a short key.GET /{key}
: Redirects to the original URL associated with the given key.
Curl lovers rejoice.
6 Generating QR codes
Let’s reuse the QR code generation from an older blog post here.
Drop below into the file src/main/java/com/example/QRCodeService.java
:
package com.example;
import java.util.Objects;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import io.nayuki.qrcodegen.QrCode;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class QRCodeService {
public QrCode generateQrCode(String data) {
QrCode qr = QrCode.encodeText(data, QrCode.Ecc.LOW); // Make the QR Code symbol
return qr;
}
public static String toSvgString(QrCode qr, int border, String lightColor, String darkColor, boolean download) {
Objects.requireNonNull(qr);
Objects.requireNonNull(lightColor);
Objects.requireNonNull(darkColor);
if (border < 0)
throw new IllegalArgumentException("Border must be non-negative");
long brd = border;
StringBuilder sb = new StringBuilder();
if (download) {
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
sb.append(
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n");
}
sb.append(String.format(
"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 %1$d %1$d\" stroke=\"none\">\n",
qr.size + brd * 2));
sb.append("\t<rect width=\"100%\" height=\"100%\" fill=\"" + lightColor + "\"/>\n");
sb.append("\t<path d=\"");
for (int y = 0; y < qr.size; y++) {
for (int x = 0; x < qr.size; x++) {
if (qr.getModule(x, y)) {
if (x != 0 || y != 0)
sb.append(" ");
sb.append(String.format("M%d,%dh1v1h-1z", x + brd, y + brd));
}
}
}
return sb
.append("\" fill=\"" + darkColor + "\"/>\n")
.append("</svg>\n")
.toString();
}
public static String toBase64Image(QrCode qr, int scale, int border, int lightColor, int darkColor) {
Objects.requireNonNull(qr);
if (scale <= 0 || border < 0)
throw new IllegalArgumentException("Value out of range");
if (border > Integer.MAX_VALUE / 2 || qr.size + border * 2L > Integer.MAX_VALUE / scale)
throw new IllegalArgumentException("Scale or border too large");
BufferedImage image = new BufferedImage((qr.size + border * 2) * scale, (qr.size + border * 2) * scale,
BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
boolean color = qr.getModule(x / scale - border, y / scale - border);
image.setRGB(x, y, color ? darkColor : lightColor);
}
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
javax.imageio.ImageIO.write(image, "PNG", baos);
return java.util.Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (IOException e) {
throw new RuntimeException("Failed to encode image to base64", e);
}
}
}
This does little more than just using the library to return a qr code object. We also need to render this into something more useful to be displayed on the web.
7 Browser interactions: UiResource
Everything the browser needs lives in one place: form handling, redirect, and image serving. Create the new file src/main/java/com/example/UiResource.java
:
package com.example;
import java.net.URI;
import io.quarkus.qute.Template;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
@Path("/")
public class UiResource {
@Inject
Template index;
@Inject
ShortenerService shortener;
@Inject
QRCodeService qrs;
@GET
@Produces(MediaType.TEXT_HTML)
public String home() {
return index.data("result", null).render();
}
@POST
@Path("/shorten")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_HTML)
public String create(@FormParam("originalUrl") String url,
@Context UriInfo uriInfo) {
ShortLink link = shortener.createShortLink(url);
String shortUrl = uriInfo.getBaseUriBuilder().path(link.key).build().toString();
var result = new Result(link.originalUrl, shortUrl, "/qr/" + link.key);
return index.data("result", result).render();
}
@GET
@Path("/{key}")
public Response redirect(@PathParam("key") String key) {
return shortener.getOriginalUrl(key)
.map(u -> Response.status(Response.Status.FOUND).location(URI.create(u)).build())
.orElse(Response.status(Response.Status.NOT_FOUND).build());
}
@GET
@Path("/qr/{key}")
@Produces("image/svg+xml")
public Response qr(@PathParam("key") String key, @Context UriInfo uriInfo) {
if (key == null || key.isBlank()) {
return Response
.status(Status.BAD_REQUEST)
.entity("QR Code data cannot be null or empty")
.type(MediaType.TEXT_PLAIN).build();
}
String shortUrl = uriInfo.getBaseUriBuilder().path(key).build().toString();
String svg = QRCodeService.toSvgString(qrs.generateQrCode(shortUrl), 4, "#FFFFFF", "#000000", true);
return Response.ok(svg)
.build();
}
public record Result(String originalUrl, String shortUrl, String qrCodeUrl) {
}
}
8 Qute template
Now let’s use Qute to return an HTML page with the QR code embedded.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Quarkus URL Shortener</title>
<style>
<!-- skipped for brevity -->
</style>
</head>
<body>
<h1>URL Shortener</h1>
<p>Paste a long link, get something snack-sized.</p>
<form action="/shorten" method="post">
<input type="url" name="originalUrl" placeholder="https://example.com/very/long/..." required>
<input type="submit" value="Shorten">
</form>
{#if result}
<div id="result">
<p><strong>Original:</strong> {result.originalUrl}</p>
<p><strong>Short:</strong>
<a href="{result.shortUrl}" target="_blank">{result.shortUrl}</a>
</p>
<img src="{result.qrCodeUrl}" alt="QR Code"/>
</div>
{/if}
</body>
</html>
9 Configuration
And finally the configuration. This time at the end. Nonetheless important.
Add below to the src/main/resources/application.properties
# PostgreSQL
quarkus.datasource.db-kind=postgresql
# Schema strategy
quarkus.hibernate-orm.database.generation=drop-and-create
# Cache tuning
quarkus.cache.caffeine."urls".initial-capacity=100
quarkus.cache.caffeine."urls".maximum-size=1000
10 Run it
./mvnw quarkus:dev
Visit http://localhost:8080/ for the web UI:
Or hit POST /api/shorten
for the raw API. Paste a long link, click Shorten, and watch Quarkus crunch it into six tidy characters and a scannable QR image.
Happy shortening.