How to Convert HTML to PDF in Java Spring Boot
Build a Spring Boot HTML-to-PDF pipeline with Thymeleaf, OpenHTMLtoPDF, browser-backed options, troubleshooting, and production guidance.
How do I convert HTML to PDF in Spring Boot? Treat it as two separate stages: first render a complete document with a Spring-supported template engine such as Thymeleaf; then pass that HTML to a PDF renderer selected for your CSS, JavaScript, font and pagination requirements.
For controlled, well-formed invoice or report templates, OpenHTMLtoPDF is a practical Java option. It supports a documented subset of XHTML/HTML5 and CSS, but it does not execute JavaScript and does not reproduce every modern browser layout. If your source depends on client-side rendering, flexbox/grid-heavy CSS or browser-specific behavior, evaluate a browser-backed renderer such as Flying Saucer’s Chrome-backed artifact instead of assuming a pure-Java renderer will match Chrome.
1. Choose the rendering path
| Requirement | Best starting point | Reason |
|---|---|---|
| Server-generated invoices, statements or letters with controlled markup | Thymeleaf + OpenHTMLtoPDF | Simple Spring integration and predictable templates when you stay within the renderer’s supported HTML/CSS subset. |
| JavaScript-generated content or modern browser CSS | Browser-backed renderer | A real browser engine is more likely to execute scripts and match current HTML5/CSS3 behavior. |
| Existing PDF composition rather than HTML layout | Apache PDFBox or another PDF API | Use PDF primitives directly when HTML is not the source of truth. |
OpenHTMLtoPDF documents a reasonable subset of well-formed XML/XHTML, CSS 2.1 and selected later features. Its README explicitly cautions that it is not a browser and lists no JavaScript execution, limited modern CSS support (including no flex and grid), limited RTL support and no OpenType font support. Flying Saucer documents Java rendering artifacts and a Chrome-backed PDF route. Compare real representative documents before committing to an engine. Review the exact artifact versions, Java runtime requirements and licenses: OpenHTMLtoPDF and Flying Saucer identify LGPL 2.1-or-later licensing; PDFBox identifies Apache 2.0. Sources: OpenHTMLtoPDF README, Flying Saucer README, Apache PDFBox.
2. Create a Spring Boot project
Spring Boot supports Thymeleaf, FreeMarker, Groovy and Mustache. With the conventional setup, templates live under src/main/resources/templates. See the Spring Boot template-engine documentation and Thymeleaf documentation.
Add the Spring MVC and Thymeleaf starters, plus an OpenHTMLtoPDF PDFBox integration. Keep the renderer version in one property and select a release compatible with your Java runtime after reviewing its release notes.
<properties>
<java.version>17</java.version>
<openhtmltopdf.version>REVIEWED_VERSION</openhtmltopdf.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>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>${openhtmltopdf.version}</version>
</dependency>
</dependencies>
Use the same coordinates and reviewed version in Gradle if that is your build system. Do not copy arbitrary markup supplied by an end user into a renderer without sanitizing it and controlling resource loading.
3. Build a complete HTML template
Create src/main/resources/templates/invoice.html. Keep document CSS close to the template while you establish layout, then move shared styles into a controlled stylesheet. Use absolute or resolvable relative URLs for images and stylesheets.
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 18mm 15mm 20mm; }
body { font-family: DejaVu Sans, sans-serif; color: #222; font-size: 10pt; }
h1 { font-size: 20pt; margin: 0 0 8mm; }
.meta { margin-bottom: 8mm; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 0.2mm solid #ccc; padding: 2mm; text-align: left; }
.total { text-align: right; font-weight: bold; margin-top: 6mm; }
.page-break { page-break-before: always; }
</style>
</head>
<body>
<h1 th:text="${invoice.title}">Invoice</h1>
<div class="meta">
<div>Customer: <span th:text="${invoice.customerName}">Example customer</span></div>
<div>Issued: <span th:text="${invoice.issuedDate}">2026-01-01</span></div>
</div>
<table>
<thead><tr><th>Description</th><th>Quantity</th><th>Amount</th></tr></thead>
<tbody>
<tr th:each="line : ${invoice.lines}">
<td th:text="${line.description}">Consulting</td>
<td th:text="${line.quantity}">1</td>
<td th:text="${line.amount}">100.00</td>
</tr>
</tbody>
</table>
<div class="total">Total: <span th:text="${invoice.total}">100.00</span></div>
</body>
</html>
4. Render the template to PDF
The service below resolves Thymeleaf into a string, supplies a base URI for relative resources, and writes PDF bytes. The exact builder methods can differ between OpenHTMLtoPDF releases, so verify them against the version selected in your build.
package com.example.pdf;
import java.io.ByteArrayOutputStream;
import java.nio.file.Paths;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
@Service
public class InvoicePdfService {
private final TemplateEngine templates;
public InvoicePdfService(TemplateEngine templates) {
this.templates = templates;
}
public byte[] render(Invoice invoice) {
Context context = new Context();
context.setVariable("invoice", invoice);
String html = templates.process("invoice", context);
String baseUri = Paths.get("src/main/resources/").toAbsolutePath().toUri().toString();
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
new PdfRendererBuilder()
.withHtmlContent(html, baseUri)
.toStream(output)
.run();
return output.toByteArray();
} catch (Exception e) {
throw new PdfRenderingException("Could not render invoice PDF", e);
}
}
}
In packaged applications, src/main/resources is not a reliable filesystem base. Prefer a stable HTTP resource URL, a custom resource resolver, or embed the required images and fonts and register them explicitly. The base URI must let the renderer resolve every relative URL used by the final HTML.
5. Expose a download endpoint
@RestController
@RequestMapping("/invoices")
public class InvoiceController {
private final InvoicePdfService pdfs;
public InvoiceController(InvoicePdfService pdfs) {
this.pdfs = pdfs;
}
@GetMapping(value = "/{id}.pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> download(@PathVariable long id) {
Invoice invoice = loadInvoice(id); // load and authorize in your application
byte[] pdf = pdfs.render(invoice);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=invoice-" + id + ".pdf")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
}
Call the endpoint with any HTTP client:
curl -fL http://localhost:8080/invoices/42.pdf -o invoice-42.pdf
import requests
r = requests.get("http://localhost:8080/invoices/42.pdf", timeout=90)
r.raise_for_status()
open("invoice-42.pdf", "wb").write(r.content)
const res = await fetch('http://localhost:8080/invoices/42.pdf');
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const fs = require('node:fs');
fs.writeFileSync('invoice-42.pdf', Buffer.from(await res.arrayBuffer()));
6. Make layout predictable
- Use a complete, well-formed document and close every element.
- Define
@pagesize and margins explicitly. - Prefer tables and block layout for invoices; test every page break.
- Register fonts deliberately and verify Unicode, symbols and non-Latin scripts.
- Use absolute dimensions for logos and critical diagrams.
- Embed or allowlist images and stylesheets; avoid network dependencies that can disappear during rendering.
- Render long documents, empty collections, unusually long names, huge tables and missing images before release.
7. When OpenHTMLtoPDF is the wrong fit
If your page requires JavaScript, client-side chart rendering, CSS grid/flex layouts, advanced web fonts or exact Chrome output, compare a browser-backed implementation. Flying Saucer documents a Chrome-backed PDF artifact for modern HTML5/CSS3 alongside Java renderers. A browser process adds deployment and startup cost, but it may reduce CSS compatibility work. Compare output, pagination, fonts, accessibility/PDF-A requirements, sandboxing, runtime compatibility and license obligations using your actual documents.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| PDF is blank | Template failed, body is empty or rendering threw an exception that was swallowed. | Log the resolved HTML in a safe non-production diagnostic path, fail the request on exceptions and add a minimal template test. |
| Images or CSS are missing | Relative URLs cannot be resolved from the base URI, or remote resources are blocked. | Pass a correct base URI, use absolute allowlisted URLs, or register a resource resolver. |
| CSS looks different from Chrome | The Java renderer supports a narrower CSS/HTML subset. | Simplify markup/CSS or move to a browser-backed renderer. |
| Fonts show boxes or wrong glyphs | Font is unavailable, not embedded, or the format/script is unsupported. | Install/register a compatible font, embed it, and test Unicode and RTL samples. |
| Pages split in bad places | Automatic pagination conflicts with table or block sizes. | Use page-break-before/after/inside, repeat table headers where supported, and test long rows. |
| Works locally, fails in a container | Different Java version, missing fonts, filesystem paths or network access. | Pin the runtime, package fonts/resources, use container-safe URIs and record dependency versions. |
| Slow or memory-heavy requests | Large images, very long documents or concurrent renderer instances. | Resize images, stream or queue large jobs, cap input sizes and measure concurrency under production-like load. |
9. Reliability, performance and cost
PDF generation is CPU- and memory-bound. Reuse the Spring template engine, avoid loading multi-megapixel images when a smaller version is sufficient, and set request limits for HTML, image bytes and page counts. For long reports, queue work and return a job identifier instead of holding an HTTP request open. Capture structured metrics for render duration, output size, failures and resource-load errors. Keep renderer versions pinned and rerun visual regression samples after upgrades.
There is no universal throughput number: output varies with fonts, images, page count, CSS and concurrency. Benchmark your own representative documents. Review the license of the selected renderer and every transitive dependency before distributing your application.
10. Or skip the browser setup
If your goal is a clean screenshot or PDF of a web URL rather than a server-side invoice template, ScreenshotNeo provides a single request. See the ScreenshotNeo API docs for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status. It also provides an MCP server with take_screenshot, get_page_info and capture_pdf for AI agents. The free plan includes 1,000 shots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Can Thymeleaf itself create a PDF?
No. Thymeleaf produces HTML. A separate renderer converts that HTML into PDF bytes.
Should I pass arbitrary user HTML to OpenHTMLtoPDF?
Use controlled templates or sanitize and constrain input. Resource loading and untrusted content need explicit security controls.
Why does my browser page not match the PDF?
A Java renderer is not automatically a browser. JavaScript and modern CSS may be unsupported; use a browser-backed renderer when fidelity is a requirement.
Which Java version should I use?
Check the exact artifact documentation. Flying Saucer documents Java 11+ from 9.5.0, Java 17+ from 9.6.0 and Java 21+ from 10.0.0; OpenHTMLtoPDF documents Java 8 and reports testing on OpenJDK 8, 11 and 17 early access.
How do I handle PDFs larger than a normal HTTP response?
Generate asynchronously, store the result, and provide an authorized download URL with retention and size limits.


