Getting Started with Spring Boot

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page builds the skeleton of the Bookshelf Billing service used by the rest of the section: a Spring Boot 4 application on Java 21 with iText and JasperReports on the classpath, one "Hello PDF" in each library, and the HTTP plumbing that returns the file to a browser or an API client. Versions are stated as of 2026-10-09: iText Core 9.8.0, JasperReports 7.0.8 and Spring Boot 4.1.1. The Hello PDF examples below were compiled and run against exactly those versions on a Java 21 language level (--release 21 through the Spring Boot parent), and both endpoints returned a valid one-page PDF.

The Spring Boot basics (project layout, starters, configuration) are not repeated here, see Spring Boot getting started; for controllers and response handling in general see REST APIs with Spring Boot.

The request-to-PDF flow

Whatever library builds the document, a PDF endpoint has the same shape: the controller validates the request, a service loads the data and produces the bytes, and the controller sends them with the right headers. The only decision that changes the code is whether the bytes are first collected in memory or written straight to the response.

sequenceDiagram participant C as Client participant K as Controller participant S as PDF service participant D as Data source participant L as iText or JasperReports C->>K: GET /invoices/42/pdf K->>S: render(42) S->>D: load invoice and lines D-->>S: rows S->>L: build document L-->>S: bytes or OutputStream S-->>K: byte array or writer callback K-->>C: 200, Content-Type application/pdf, Content-Disposition

The sections below follow this diagram from left to right: dependencies first, then the two libraries, then the controller.

Maven setup for iText 9.8

Since iText 7 the library is distributed as modules, and since iText 8.0 the aggregator is called itext-core (it was itext7-core before). It is a POM aggregator: depending on it with <type>pom</type> pulls in the kernel, layout, forms, signatures, barcodes, SVG and the other core modules at once. Signing and encryption additionally need a Bouncy Castle provider, and iText wants exactly one of two adapters on the classpath: bouncy-castle-adapter or bouncy-castle-fips-adapter. Use the plain adapter unless a FIPS 140 validated provider is a requirement. Both come from Maven Central under com.itextpdf.

iText is licensed under the AGPLv3 or under a commercial licence. With AGPL, a service that lets users interact with it over a network must offer its complete source code to those users, so for a closed-source product a commercial licence is needed. The licensing consequences are discussed in the Introduction & Approaches page of this section.

<properties>
    <java.version>21</java.version>
    <itext.version>9.8.0</itext.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>

    <!-- iText 9: the POM aggregator plus exactly one Bouncy Castle adapter -->
    <dependency>
        <groupId>com.itextpdf</groupId>
        <artifactId>itext-core</artifactId>
        <version>${itext.version}</version>
        <type>pom</type>
    </dependency>
    <dependency>
        <groupId>com.itextpdf</groupId>
        <artifactId>bouncy-castle-adapter</artifactId>
        <version>${itext.version}</version>
    </dependency>
</dependencies>

Notes on the setup:

  • spring-boot-starter-webmvc is the Spring Boot 4 name of the servlet web starter. The parent POM is spring-boot-starter-parent 4.1.1 as of 2026-10-09.

  • Commercial add-ons such as pdfCalligraph or pdfOptimizer are not on Maven Central; they are served from https://repo.itextsupport.com/ and need a licence. pdfHTML (html2pdf) is on Central under AGPL or commercial terms.

  • If dependency convergence or a BOM is preferred, itext-core can be imported once in dependencyManagement, but a single property for the version, as above, is enough for a service.

Hello PDF with iText

A PDF is written by a PdfWriter into a PdfDocument, and the high-level Document adds layout elements on top of it. All three are AutoCloseable, so a try-with-resources block closes them in the right order, which also writes the cross-reference table and trailer. The service below can write to any OutputStream, which is what both the in-memory and the streaming endpoint need. setCloseStream(false) keeps iText from closing a stream it does not own.

package com.example.billing;

import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Paragraph;
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import org.springframework.stereotype.Service;

@Service
public class ItextHelloService {

    /** Writes a one-page PDF to any stream; the caller owns the stream. */
    public void write(OutputStream out, String customer) {
        PdfWriter writer = new PdfWriter(out);
        writer.setCloseStream(false);
        try (PdfDocument pdf = new PdfDocument(writer);
             Document document = new Document(pdf)) {
            document.add(new Paragraph("Hello PDF from Bookshelf Billing"));
            document.add(new Paragraph("Customer: " + customer));
        }
    }

    public byte[] render(String customer) {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        write(buffer, customer);
        return buffer.toByteArray();
    }
}

Closing the Document closes the underlying PdfDocument too, so the explicit second resource is harmless and keeps the lifecycle visible. The default font is Helvetica, a standard font that is not embedded; embedding fonts and Unicode text are covered in the iText pages later in this section.

Official documentation: iText jump-start tutorial, chapter 1, which still describes the current PdfWriter, PdfDocument and Document classes.

Maven setup for JasperReports 7.0.8

JasperReports 7 is split into modules, and the core artifact no longer contains the PDF exporter, the fonts or a Java compiler. A service that fills reports and exports PDF needs four artifacts, all in the net.sf.jasperreports group and all at the same version:

  • jasperreports is the engine: parsing JRXML, compiling, filling and the exporter API.

  • jasperreports-pdf is the PDF exporter. Its PDF writer is OpenPDF (com.github.librepdf:openpdf in a Jaspersoft build, 1.3.43.jaspersoft.2 for 7.0.8). The PDF producer is pluggable through the net.sf.jasperreports.export.pdf.producer.factory property, but the default is what these pages use.

  • jasperreports-fonts bundles the DejaVu Sans, Serif and Mono families, which are the default report fonts.

  • jasperreports-jdt is the Eclipse JDT compiler used to compile report expressions at run time, so the service does not need a full JDK or javac at run time.

JasperReports is licensed under the LGPL-3.0, so using it from a closed-source service is allowed. The core targets Java 8, but the Jakarta, Spring and Spring Boot modules need Java 17 or later, which Spring Boot 4 satisfies.

<properties>
    <jasperreports.version>7.0.8</jasperreports.version>
</properties>

<dependencies>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports-pdf</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports-fonts</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports-jdt</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
</dependencies>

Charts, barcodes, Excel export and the other optional features are separate artifacts (for example jasperreports-charts) and are added by the pages that use them. In the Community Edition of 7.0.8, tagged PDF (PDF/UA and the "a" levels of PDF/A) is not available: it needs the commercial JasperReports Library Professional. In the Community Edition the default tagger raises an error when tagging is requested, and a silent tagger can be configured to skip the tags.

Official documentation: the JasperReports Maven sample and the Spring Boot sample.

Hello PDF with JasperReports

A report is a JRXML template. JasperReports 7 uses the new Jackson-based JRXML format, where every element is an <element kind="..."> rather than the <staticText> and <textField> tags of version 6 and earlier; a v6 file has to be converted with Jaspersoft Studio 7. The template below has one parameter and a title band. Save it as src/main/resources/reports/hello.jrxml.

<?xml version="1.0" encoding="UTF-8"?>
<jasperReport name="hello" language="java" pageWidth="595" pageHeight="842"
              columnWidth="555" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20">
    <parameter name="customer" class="java.lang.String"/>
    <title height="60">
        <element kind="staticText" x="0" y="0" width="555" height="30" fontSize="18.0">
            <text><![CDATA[Hello PDF from Bookshelf Billing]]></text>
        </element>
        <element kind="textField" x="0" y="30" width="555" height="20">
            <expression><![CDATA["Customer: " + $P{customer}]]></expression>
        </element>
    </title>
</jasperReport>

The service compiles the template once, when the bean is created, and fills it for every request. Compiling is the expensive step, while the compiled JasperReport object is thread-safe and reusable. Filling produces a JasperPrint, a format-independent document, and JasperExportManager turns it into PDF bytes.

package com.example.billing;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import net.sf.jasperreports.engine.JREmptyDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

@Service
public class JasperHelloService {

    private final JasperReport report;

    public JasperHelloService() throws IOException, JRException {
        // Compile once at start-up; the compiled report is thread-safe and reusable.
        try (InputStream template = new ClassPathResource("reports/hello.jrxml").getInputStream()) {
            this.report = JasperCompileManager.compileReport(template);
        }
    }

    public void write(OutputStream out, String customer) throws JRException {
        // JasperReports adds its own entries to this map, so it must be mutable (not Map.of).
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("customer", customer);
        // The template has no detail rows: an empty data source with one record still produces the page.
        JasperPrint print = JasperFillManager.fillReport(report, parameters, new JREmptyDataSource());
        JasperExportManager.exportReportToPdfStream(print, out);
    }
}

The parameters map must be mutable. Map.of(...) is tempting in Java 21 code, but fillReport stores its own parameters, such as the format factory, in the same map, and an immutable map makes the fill fail with an UnsupportedOperationException. Use a HashMap, or copy an immutable one into a HashMap first.

The third argument matters too. A report filled without a data source or a connection has no records, and with the default whenNoDataType="NoPages" the result is a document with no pages. new JREmptyDataSource() supplies one empty record, so the title band is printed. Setting whenNoDataType="AllSectionsNoDetail" on the <jasperReport> element is the template-side alternative.

For production services, compile .jrxml at build time into .jasper files (the JasperReports Maven plugin or an Ant task) and load those, which removes the compiler from the start-up path. Run-time compilation, as above, is the simplest start and is why the JDT module is on the classpath.

Official documentation: the JasperReports Spring Boot sample and Maven sample.

Returning the PDF from a controller

This replaces the servlet and JSP era approach of writing to HttpServletResponse by hand, setting the content type and working around old Internet Explorer behaviour with extra headers. Those workarounds are obsolete: in Spring Boot 4 the controller returns a ResponseEntity<byte[]> for a document built in memory, or a ResponseEntity<StreamingResponseBody> for one that is written directly to the response. Spring sets the status, the content type and the length, and the same code works with any modern browser. See the Spring ResponseEntity reference and the asynchronous request and streaming reference.

The two return types make different trade-offs:

ResponseEntity<byte[]>

The whole PDF is built in memory first. The Content-Length header is known, a failure while building the document becomes a clean HTTP 500 before any byte is sent, and the code is easy to test. Use it for invoices, receipts and anything up to a few megabytes.

StreamingResponseBody

The callback writes directly to the response OutputStream on a separate task executor thread. Memory use stays low for large reports, and the first bytes reach the client early. The cost is that the response is already committed when the callback fails, so the client sees a truncated file instead of an error status, and the response is chunked without a Content-Length. Configure the MVC async task executor and timeout for long reports.

package com.example.billing;

import java.io.IOException;
import net.sf.jasperreports.engine.JRException;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

@RestController
public class PdfController {

    private final ItextHelloService itext;
    private final JasperHelloService jasper;

    public PdfController(ItextHelloService itext, JasperHelloService jasper) {
        this.itext = itext;
        this.jasper = jasper;
    }

    /** Small document: build it in memory, so the length is known and errors become a clean 500. */
    @GetMapping(value = "/hello/itext", produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<byte[]> itextInline(@RequestParam(defaultValue = "Ada") String customer) {
        byte[] pdf = itext.render(customer);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.inline().filename("hello.pdf").build().toString())
                .contentType(MediaType.APPLICATION_PDF)
                .contentLength(pdf.length)
                .body(pdf);
    }

    /** Larger document: write straight to the response; attachment makes browsers download it. */
    @GetMapping(value = "/hello/jasper/{customer}", produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<StreamingResponseBody> jasperDownload(@PathVariable String customer) {
        StreamingResponseBody body = out -> {
            try {
                jasper.write(out, customer);
            } catch (JRException e) {
                throw new IOException("Report generation failed", e);
            }
        };
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment().filename("hello-report.pdf").build().toString())
                .contentType(MediaType.APPLICATION_PDF)
                .body(body);
    }
}

Run the application with mvn spring-boot:run and request curl -OJ http://localhost:8080/hello/jasper/Ada to save the second file under the name given by the server.

Content-Disposition: inline versus attachment

The Content-Disposition header tells the browser what to do with the body. inline asks it to display the PDF in the viewer and attachment asks it to download the file; both can carry a filename that is used as the suggested name. Build the header with ContentDisposition instead of concatenating strings: the builder quotes the name and, when the name contains non-ASCII characters, adds the RFC 5987 filename* form with UTF-8 encoding. Never put a user-supplied value in the file name without sanitising it, because a header value is a header injection and a path traversal vector. A download link for a document that the user has to save should use attachment, while a preview in a new tab should use inline. The PDF media type is application/pdf, available as MediaType.APPLICATION_PDF.

Official documentation: the MDN Content-Disposition reference and RFC 6266, and the Javadoc for StreamingResponseBody and ResponseEntity.

Headless AWT and fonts in containers

JasperReports measures text with the Java 2D font machinery (AWT), and iText can use it for some image and font operations. A server has no display, so Java must run in headless mode, otherwise some code paths fail on a machine without X11. Headless is the default on Linux servers without a display, but set it explicitly so a developer workstation behaves like the container. Either set the system property before the first AWT class is used, as the example application does in main, or pass -Djava.awt.headless=true to the JVM.

public static void main(String[] args) {
    System.setProperty("java.awt.headless", "true");
    SpringApplication.run(BillingApplication.class, args);
}

The second container issue is fonts. A slim base image such as a JRE-only image often has no fontconfig library and no system fonts, and Java then fails when it looks up a font, with errors that mention fontconfig or libfreetype, or reports silently fall back to different glyphs. There are two complementary fixes:

  • In JasperReports, keep jasperreports-fonts on the classpath: it registers the bundled DejaVu fonts as report fonts, which are embedded in the PDF, so the output is identical on a laptop and in a container.

  • In the image, install fontconfig and a font package, so any other Java 2D use (and any report that names a system font) works. On a Debian based JRE image that is fontconfig and fonts-dejavu-core.

FROM eclipse-temurin:21-jre

RUN apt-get update \
    && apt-get install -y --no-install-recommends fontconfig fonts-dejavu-core \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY target/bookshelf-billing-0.0.1-SNAPSHOT.jar app.jar

ENV JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

For iText, fonts that are not embedded are not a container problem as long as the standard 14 fonts are used, because they are not read from the operating system; fonts for non-Latin text have to be supplied with the application and embedded. That is covered in the iText fonts page later in this section. This Dockerfile was not built as part of this page, so check it against the base image your organisation uses.

Official documentation: the JasperReports fonts sample and the Java SE GraphicsEnvironment Javadoc, which describes headless mode.

What the older books say

The servlet chapter of the iText book consulted for this section (B1, written for iText 1.4 and JDK 1.4) sends a PDF from a servlet or a JSP, with workarounds for the content-type handling of Microsoft Internet Explorer of that time. Those workarounds no longer apply. The current approach is the controller shown above: a ResponseEntity<byte[]> or a StreamingResponseBody with a Content-Disposition header built by ContentDisposition. The code in that book is also written for Java 1.3 to 5, without generics. Every example on this page uses Java 21 idioms instead: try-with-resources for iText documents and streams, constructor injection, typed collections and, in the pages that follow, records for the data passed to templates and text blocks for embedded markup.