HTML to PDF in Spring Boot

A GET /invoices/{id}.pdf endpoint that fills a Thymeleaf template and returns a downloadable PDF, in two versions: pure Java with OpenHTMLtoPDF, and Chrome-rendered through an API with RestClient. Both were run end to end on Spring Boot 4.1.1 and JDK 25, and both return a 1-page A4 invoice with "Page 1 of 1" in the corner.

Quick answer

OptionPick it when
OpenHTMLtoPDF
Pure Java, in your JVM, LGPL
Your templates are simple (blocks, tables, floats) and you want no external process or service.
An API (Chromium)
One HTTP call from RestClient
Your HTML uses flexbox, grid or web fonts and must look like it does in Chrome, without running a browser next to your app.

Running Chromium yourself with Playwright for Java is the third option; its code and setup are in HTML to PDF in Java. In a web app it means a browser process per instance, so pool it (one Browser, a new Page per request) and keep it off small containers.

The project

A standard Spring Boot project (Initializr with Spring Web and Thymeleaf), plus OpenHTMLtoPDF and jsoup. pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
  </parent>
  <groupId>com.example</groupId>
  <artifactId>invoice</artifactId>
  <version>1.0</version>
  <properties>
    <java.version>17</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-thymeleaf</artifactId>
    </dependency>
    <dependency>
      <groupId>io.github.openhtmltopdf</groupId>
      <artifactId>openhtmltopdf-pdfbox</artifactId>
      <version>1.1.97</version>
    </dependency>
    <dependency>
      <groupId>org.jsoup</groupId>
      <artifactId>jsoup</artifactId>
      <version>1.23.2</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

The API version needs nothing beyond spring-boot-starter-web, so drop the last two dependencies if that is the only one you use. Gradle: the same coordinates as implementation lines.

The template

src/main/resources/templates/invoice.html. The @page rule sets the paper size, margins and page numbers; both renderers honor it:

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><style>
  @page { size: A4; margin: 20mm 15mm;
    @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9px; } }
  body { font-family: sans-serif; }
  .top { background: #eef2ff; padding: 12px; }
  table { width: 100%; border-collapse: collapse; }
  td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
  tr { page-break-inside: avoid; }
</style></head><body>
  <div class="top"><h1 th:text="'Invoice #' + ${number}">Invoice</h1><p th:text="'Due: ' + ${due}">Due</p></div>
  <table><tr><th>Item</th><th>Price</th></tr>
    <tr th:each="line : ${lines}"><td th:text="${line.item}">Item</td><td th:text="${line.price}">0</td></tr></table>
</body></html>

The data, as a Thymeleaf Context both controllers share (InvoiceData.java):

package com.example.invoice;

import java.util.List;
import java.util.Map;

import org.thymeleaf.context.Context;

public final class InvoiceData {
    public record Line(String item, String price) {}

    // In a real app this comes from your database
    public static Context context(long id) {
        Context ctx = new Context();
        ctx.setVariables(Map.of(
            "number", id,
            "due", "2026-11-01",
            "lines", List.of(new Line("Consulting (3h)", "€450.00"), new Line("Hosting, Oct.", "€19.00"))));
        return ctx;
    }
}

We call TemplateEngine.process() instead of returning a view name: the controller needs the HTML as a String, not a rendered response. Spring Boot auto-configures the engine with the templates/ folder, so just inject it.

1. OpenHTMLtoPDF controller (pure Java)

PdfController.java:

package com.example.invoice;

import java.io.ByteArrayOutputStream;

import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.jsoup.Jsoup;
import org.jsoup.helper.W3CDom;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.TemplateEngine;

@RestController
public class PdfController {
    private final TemplateEngine templates;

    public PdfController(TemplateEngine templates) {
        this.templates = templates;
    }

    @GetMapping("/invoices/{id}.pdf")
    public ResponseEntity<byte[]> invoice(@PathVariable long id) throws Exception {
        String html = templates.process("invoice", InvoiceData.context(id));
        // jsoup turns HTML5 into the well-formed DOM OpenHTMLtoPDF needs
        var doc = new W3CDom().fromJsoup(Jsoup.parse(html));
        var out = new ByteArrayOutputStream();
        PdfRendererBuilder builder = new PdfRendererBuilder();
        builder.useFastMode();
        builder.withW3cDocument(doc, "classpath:/static/"); // base for relative images/CSS
        builder.toStream(out);
        builder.run();
        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_PDF)
            .header(HttpHeaders.CONTENT_DISPOSITION,
                ContentDisposition.attachment().filename("invoice-" + id + ".pdf").build().toString())
            .body(out.toByteArray());
    }
}

Run mvn spring-boot:run and open http://localhost:8080/invoices/1042.pdf: the browser downloads invoice-1042.pdf with the blue header, selectable text, the euro amounts and the page number. Notes:

2. Chrome-quality PDFs via the API (RestClient)

ApiPdfController.java. Same template, but the HTML is sent to Shotmint's /v1/pdf, which renders it in headless Chromium and returns the PDF bytes:

package com.example.invoice;

import java.util.Map;

import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.thymeleaf.TemplateEngine;

@RestController
public class ApiPdfController {
    private final TemplateEngine templates;
    private final RestClient shotmint = RestClient.builder()
        .baseUrl("https://shotmint.creasework.com")
        .defaultHeader("X-API-Key", System.getenv("SHOTMINT_KEY"))
        .build();

    public ApiPdfController(TemplateEngine templates) {
        this.templates = templates;
    }

    @GetMapping("/invoices/{id}/chrome.pdf")
    public ResponseEntity<byte[]> invoice(@PathVariable long id) {
        String html = templates.process("invoice", InvoiceData.context(id));
        byte[] pdf = shotmint.post()
            .uri("/v1/pdf")
            .contentType(MediaType.APPLICATION_JSON)
            .body(Map.of("html", html, "format", "A4", "margin", "20mm"))
            .retrieve() // a 4xx/5xx throws RestClientResponseException with the JSON error
            .body(byte[].class);
        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_PDF)
            .header(HttpHeaders.CONTENT_DISPOSITION,
                ContentDisposition.attachment().filename("invoice-" + id + ".pdf").build().toString())
            .body(pdf);
    }
}

Set the key in the environment (export SHOTMINT_KEY=sm_...; a free key takes one click and needs no signup) and open /invoices/1042/chrome.pdf. Result: the same 1-page A4 invoice, page number included, rendered by Chromium, so flexbox, grid and web fonts work too. Notes:

Common errors

SymptomFix
SAXParseException: element type "meta" must be terminatedYou passed HTML5 straight to withHtmlContent(). Parse it with jsoup + W3CDom as above.
Euro sign or accents garbledMissing <meta charset="utf-8"> in the template, or a non-UTF-8 source encoding in the build.
Side-by-side boxes stacked (OpenHTMLtoPDF)Flexbox/grid aren't supported. Use a table or floats, or the API.
Logo missingRelative URL with the wrong base. OpenHTMLtoPDF: check the base URI. API: use an absolute public URL.
PDF opens as text or downloads as "1042"Set MediaType.APPLICATION_PDF and a Content-Disposition filename.
Thymeleaf TemplateInputExceptionThe file isn't at src/main/resources/templates/invoice.html, or the name passed to process() has a typo.

Which one?

Simple invoices and reports you design yourself: OpenHTMLtoPDF keeps everything in the JVM. If the template needs modern CSS, must match the browser, or comes from designers who use flexbox, use the API rather than shipping Chromium with every instance. To check a template first, paste the rendered HTML into the free HTML to PDF converter; no key needed. Plain Java, Playwright and the dependency-free HttpClient version: HTML to PDF in Java.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons