Dynamic SVG Badges with Quarkus: A Practical Guide for Java Developers
Learn how to combine QuickJS4j, Batik, and Qute to build a customizable badge generator that runs smoothly in JVM and native mode.
Badges have always felt like the developer equivalent of stickers on a laptop: Small, expressive, and a little bit personal. They show pride, identity, or just a sense of fun in a codebase. In this tutorial, we take that idea and turn it into something you control entirely: a Dynamic Badge Generator built with Quarkus. It generates crisp SVG badges using JavaScript running inside QuickJS, embeds the official Quarkus icon, supports themes and colors, accepts custom text, and can output either SVG or PNG. Best of all, it runs the same whether you’re in the JVM or a native image.
And I’ll be honest here: writing this tutorial cost me a ton of willpower. I am not a JavaScript fan. I never have been. Every time I open a .js file, I feel like …. But QuickJS inside Quarkus is different. It keeps the JavaScript confined, predictable, and actually pleasant to work with. Almost like a tiny, well-behaved guest in your otherwise tidy Java home. So yes, I survived. And you will too.
We’ll follow the quickjs4j pattern: defining a Java interface, binding it to a JavaScript module, and letting Quarkus stitch everything together with CDI. It’s clean, type-safe, and feels natural for modern Quarkus development.
Most badge platforms, like shields.io, require external APIs or templating services. With Quarkus, you get to host your own. You can brand it, theme it, lock it down, or embed it directly into internal dashboards and GitOps pipelines. And thanks to native image startup times, the badges render instantly. This project also gives you a practical glimpse into polyglot rendering. The moment Java and JavaScript collaborate inside Quarkus in a way that just feels… developer-friendly.
Prerequisites
Java 21+
Quarkus CLI (
quarkus)Maven 3.9+
Basic knowledge of REST endpoints
If you just want to look at the code, feel free to grab it from my Github repository.
Create the Project
quarkus create app org.acme.badge:quarkus-badge \
--extension=resteasy-reactive \
--extension=quarkus-quickjs4j \
--extension=quarkus-batik \
--extension=quarkus-rest-qute \
--no-code
cd quarkus-badgeFor the PNG transformation we need to add a Batic codec dependency in the pom.xml
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
<version>1.19</version>
</dependency>We also need to make sure to add the annotation processor while we are here. Adjust the maven-compiler section. I have added a property for the quickjs4j version too.
<quarkus-quickjs4j.version>0.0.3</quarkus-quickjs4j.version>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>io.quarkiverse.quickjs4j</groupId>
<artifactId>quarkus-quickjs4j</artifactId>
<version>${quarkus-quickjs4j.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>Add the Official Quarkus Icon
Download the official icon from the Quarkus branding repo:
https://quarkus.io/assets/images/brand/quarkus_icon_default.svg
Save it as:
src/main/resources/quarkus-icon.svgWe will embed this into the badge as a Base64-encoded <image> element.
Define a Java Interface (QuickJS Binding)
In quickjs4j, JavaScript modules are bound to Java interfaces by name.
Create:
src/main/java/org/acme/BadgeGenerator.java
package org.acme.badge;
import io.quarkiverse.quickjs4j.annotations.ScriptImplementation;
import io.roastedroot.quickjs4j.annotations.ScriptInterface;
@ScriptInterface
@ScriptImplementation(location = “badge-generator.js”)
public interface BadgeGenerator {
String createBadge(String label, String value, String theme, String quarkusIconBase64);
}This is the contract.
JavaScript Module Executed by QuickJS
Place your JS module under:
src/main/resources/badge-generator.jsQuarkus will detect it and bind it to BadgeGenerator.
// badge-generator.js
/**
* Escapes XML/HTML special characters to prevent injection and parsing errors
*/
function escapeXml(text) {
if (!text) return ‘’;
return String(text)
.replace(/&/g, ‘&’)
.replace(/</g, ‘<’)
.replace(/>/g, ‘>’)
.replace(/”/g, ‘"’)
.replace(/’/g, ‘'’);
}
/**
* Estimates text width more accurately by considering character widths
*/
function estimateTextWidth(text) {
if (!text) return 0;
let width = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
// Approximate character widths (Verdana 12px)
if (char === ‘i’ || char === ‘l’ || char === ‘I’ || char === ‘|’ || char === ‘ ‘) {
width += 3;
} else if (char === ‘m’ || char === ‘w’ || char === ‘M’ || char === ‘W’) {
width += 9;
} else if (char >= ‘A’ && char <= ‘Z’) {
width += 7;
} else {
width += 6; // default for lowercase and other chars
}
}
return width;
}
export function createBadge(label, value, theme, iconBase64) {
// Validate and sanitize inputs
label = String(label || ‘’).trim();
value = String(value || ‘’).trim();
theme = String(theme || ‘default’).toLowerCase();
const themes = {
default: { bg: “#4695EB”, text: “#ffffff” },
native: { bg: “#2EBAAE”, text: “#ffffff” },
dark: { bg: “#0D1C2C”, text: “#e0e0e0” },
ai: { bg: “#9b51e0”, text: “#fff” }
};
const t = themes[theme] || themes.default;
// Constants
const iconSize = 16;
const iconPadding = 4;
const textPadding = 8;
const textY = 16; // Vertical center for 12px font
const fontSize = 12;
const height = 24;
// Calculate widths
const labelTextWidth = estimateTextWidth(label);
const valueTextWidth = estimateTextWidth(value);
const labelWidth = labelTextWidth + (textPadding * 2);
const valueWidth = valueTextWidth + (textPadding * 2);
// Prepare embedded Quarkus icon
const iconHref = `data:image/svg+xml;base64,${iconBase64}`;
// Escape text content for XML safety
const escapedLabel = escapeXml(label);
const escapedValue = escapeXml(value);
const escapedTitle = escapeXml(`${label}: ${value}`);
// Calculate text positions
const labelX = iconSize + iconPadding + textPadding;
const valueX = labelX + labelWidth;
// Calculate total width: from start to end of value text + its right padding
const totalWidth = valueX + valueWidth;
// Build SVG with proper XML structure
return `<svg xmlns=”http://www.w3.org/2000/svg” xmlns:xlink=”http://www.w3.org/1999/xlink” width=”${totalWidth}” height=”${height}” role=”img”>
<title>${escapedTitle}</title>
<rect width=”${totalWidth}” height=”${height}” rx=”4” fill=”${t.bg}”/>
<image x=”${iconPadding}” y=”${iconPadding}” width=”${iconSize}” height=”${iconSize}” xlink:href=”${iconHref}”/>
<text x=”${labelX}” y=”${textY}” fill=”${t.text}” font-family=”Verdana,DejaVu Sans,sans-serif” font-size=”${fontSize}”>${escapedLabel}</text>
<text x=”${valueX}” y=”${textY}” fill=”${t.text}” font-family=”Verdana,DejaVu Sans,sans-serif” font-size=”${fontSize}”>${escapedValue}</text>
</svg>`;
}This is pure JavaScript, but executed entirely inside Quarkus.
Inject the JS as a CDI Bean
QuickJS4j generates an implementation of your interface at build time.
BadgeGenerator is injectable via CDI:
@Inject
BadgeGenerator generator;Create the Badge Resource (SVG + PNG)
src/main/java/org/acme/BadgeResource.java
package org.acme.badge;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.StringReader;
import java.util.Base64;
import org.apache.batik.transcoder.TranscoderInput;
import org.apache.batik.transcoder.TranscoderOutput;
import org.apache.batik.transcoder.image.PNGTranscoder;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
@Path(”/badge”)
@ApplicationScoped
public class BadgeResource {
@Inject
BadgeGenerator generator;
private String quarkusIconBase64;
public BadgeResource() {
try (InputStream is = getClass().getResourceAsStream(”/quarkus-icon.svg”)) {
quarkusIconBase64 = Base64.getEncoder().encodeToString(is.readAllBytes());
} catch (Exception e) {
throw new RuntimeException(”Failed to load Quarkus icon”, e);
}
}
@GET
@Path(”/dynamic.svg”)
@Produces(”image/svg+xml”)
public String svg(
@QueryParam(”label”) @DefaultValue(”Built with”) String label,
@QueryParam(”value”) @DefaultValue(”Quarkus”) String value,
@QueryParam(”theme”) @DefaultValue(”default”) String theme) {
return generator.createBadge(label, value, theme, quarkusIconBase64);
}
@GET
@Path(”/dynamic.png”)
@Produces(”image/png”)
public Response png(
@QueryParam(”label”) @DefaultValue(”Powered by”) String label,
@QueryParam(”value”) @DefaultValue(”Quarkus 3.x”) String value,
@QueryParam(”theme”) @DefaultValue(”default”) String theme) throws Exception {
String svgContent = svg(label, value, theme);
ByteArrayOutputStream out = new ByteArrayOutputStream();
PNGTranscoder pngTranscoder = new PNGTranscoder();
pngTranscoder.transcode(
new TranscoderInput(new StringReader(svgContent)),
new TranscoderOutput(out));
out.flush();
return Response.ok(out.toByteArray())
.type(”image/png”)
.build();
}
}Everything here is now:
Correct quickjs4j interface invocation
Correct module loading
Correct SVG→PNG transcoding
Official Quarkus icon embedded
Run and Test
Start dev mode:
quarkus devTry a few badges:
SVG:
http://localhost:8080/badge/dynamic.svg?label=Proudly+Built&value=with+Quarkus&theme=defaultPNG:
http://localhost:8080/badge/dynamic.png?label=Runs&value=Native&theme=nativeFun themes:
http://localhost:8080/badge/dynamic.svg?label=AI&value=Ready&theme=ai
Custom text:
http://localhost:8080/badge/dynamic.svg?label=Made+with&value=Quarkus+3.15.1
Add a Qute “Badge Playground” UI
This Qute playground turns the badge generator from a backend API into a front-end experience, making the tutorial engaging and visually gratifying.
Create the following file:src/main/resources/templates/badge_playground.html
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8”/>
<title>Quarkus Badge Playground</title>
<style>
<!-- ommited -->
</style>
<script>
// Auto-update preview
function autoUpdate() {
const label = document.getElementById(’label’).value;
const value = document.getElementById(’value’).value;
const theme = document.getElementById(’theme’).value;
const newUrl = ‘/badge/dynamic.svg?label=’ + encodeURIComponent(label)
+ ‘&value=’ + encodeURIComponent(value)
+ ‘&theme=’ + encodeURIComponent(theme)
+ ‘&ts=’ + Date.now();
document.getElementById(’badge-image’).src = newUrl;
document.getElementById(’badge-url’).value = newUrl;
}
// Copy badge URL to clipboard
function copyUrl() {
const input = document.getElementById(’badge-url’);
navigator.clipboard.writeText(input.value).then(() => {
alert(”Badge URL copied!”);
});
}
// Force-download SVG
function downloadSvg() {
const url = document.getElementById(’badge-url’).value;
const a = document.createElement(’a’);
a.href = url;
a.download = “badge.svg”;
a.click();
}
// Force-download PNG
function downloadPng() {
const url = document.getElementById(’badge-url’).value.replace(”dynamic.svg”, “dynamic.png”);
const a = document.createElement(’a’);
a.href = url;
a.download = “badge.png”;
a.click();
}
// Apply presets
function applyPreset(label, value, theme) {
document.getElementById(’label’).value = label;
document.getElementById(’value’).value = value;
document.getElementById(’theme’).value = theme;
autoUpdate();
}
</script>
</head>
<body>
<h1>Quarkus Badge Playground</h1>
<p>Create, preview, copy, and download your custom Quarkus badges.</p>
<form oninput=”autoUpdate()”>
<div class=”row”>
<label for=”label”>Label</label>
<input id=”label” name=”label” value=”{label}”>
</div>
<div class=”row”>
<label for=”value”>Value</label>
<input id=”value” name=”value” value=”{value}”>
</div>
<div class=”row”>
<label for=”theme”>Theme</label>
<select id=”theme” name=”theme”>
<option value=”default” {#if theme == ‘default’}selected{/if}>Default</option>
<option value=”native” {#if theme == ‘native’}selected{/if}>Native</option>
<option value=”ai” {#if theme == ‘ai’}selected{/if}>AI</option>
<option value=”dark” {#if theme == ‘dark’}selected{/if}>Dark</option>
</select>
</div>
</form>
<h2>Presets</h2>
<div class=”preset”>
<div class=”preset-badge” onclick=”applyPreset(’Proudly built with’, ‘Quarkus’, ‘default’)”>
Proudly built with Quarkus
</div>
<div class=”preset-badge” onclick=”applyPreset(’Runs’, ‘Native’, ‘native’)”>
Runs Native
</div>
<div class=”preset-badge” onclick=”applyPreset(’AI’, ‘Ready’, ‘ai’)”>
AI Ready
</div>
</div>
<h2>Live Badge Preview</h2>
<div id=”preview”>
<img id=”badge-image” alt=”Badge Preview” src=”{badgeUrl}”>
</div>
<h2>Actions</h2>
<div class=”row”>
<label>Badge URL</label>
<input id=”badge-url” value=”{badgeUrl}”>
<br/><br/>
<button type=”button” onclick=”copyUrl()”>Copy URL</button>
<button type=”button” onclick=”downloadSvg()”>Download SVG</button>
<button type=”button” onclick=”downloadPng()”>Download PNG</button>
</div>
</body>
</html>Now we need a REST endpoint to serve the template:
Create the following file:src/main/java/org/acme/badge/BadgePlaygroundResource.java
package org.acme.badge;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path(”/playground”)
@ApplicationScoped
public class BadgePlaygroundResource {
@Inject
Template badge_playground; // matches badge_playground.html
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance playground(
@QueryParam(”label”) @DefaultValue(”Built with”) String label,
@QueryParam(”value”) @DefaultValue(”Quarkus”) String value,
@QueryParam(”theme”) @DefaultValue(”default”) String theme) {
String badgeUrl = “/badge/dynamic.svg?label=”
+ URLEncoder.encode(label, StandardCharsets.UTF_8)
+ “&value=” + URLEncoder.encode(value, StandardCharsets.UTF_8)
+ “&theme=” + URLEncoder.encode(theme, StandardCharsets.UTF_8);
return badge_playground
.data(”label”, label)
.data(”value”, value)
.data(”theme”, theme)
.data(”badgeUrl”, badgeUrl);
}
}This produces:
a text input for
labela text input for
valuea theme selector
a live preview area that redraws the badge as you type
No libraries.
No JavaScript frameworks.
Just Quarkus, Qute, and a tiny JS function.
Give it a try: http://localhost:8080/playground
Production Notes
QuickJS4j is sandboxed: no filesystem, no network by default.
Native image works out of the box (all JS is known at build time).
You can pre-cache popular badges.
You can add ETag or
Cache-Control: max-ageheaders easily for CDN optimization.This approach allows you to embed corporate branding or product icons safely.
Fun Extensions
You can extend this project in several creative ways. For example, you can add subtle animations to your badges using SVG’s <animate> element, giving them a pulse or glide effect for extra visual appeal. The existing theme system already enables dark-mode variants, but you can expand it further with richer color schemes and gradients. You could also bring the entire experience into the Quarkus Dev UI by adding a custom Dev UI card that lets developers preview badges interactively without leaving the development console. Finally, the badge engine can support multiple logos, allowing you to offer branded badges for Java, Kubernetes, LangChain4j, or any other ecosystem you want to highlight.
You now have a fully dynamic badge generator running on Quarkus, powered by JavaScript, brand-accurate, native-ready, and fun to extend.





The QuickJS sandboxing is a smart move for production. No filesystem or network access by defalt means you can run untrusted JavaScript safely. The character width calculation in estimateTextWidth is clever, I wouldn't have thought to weight 'm' and 'w' differently. Are you palnning to cache popular badge combinations or would the native startup be fast enough to just generate on demand?