API reference
Publish Spring Boot API documentation with springdoc-openapi
A Spring Boot API whose Maven build exports and checks its OpenAPI spec, and a static docs site built from that file in CI, with no docs endpoint in production.
By Hayden Bleasel11 min read

To publish Spring Boot API documentation outside the running application, let springdoc-openapi generate the OpenAPI document from your controllers, export it to a file while Maven builds the app, and hand that file to a separate docs build. springdoc's Maven plugin starts your app on the build machine, downloads the spec from /v3/api-docs, and stops it again, so the service you deploy never has to serve its own docs.
By the end of this guide, you have a small Spring Boot API with annotated controllers and DTOs, a Maven build that exports openapi.json and checks its auth metadata, and a static Blume site with a page per operation, built from that file in GitHub Actions and deployed to GitHub Pages. Blume reads the spec, never your Java code. Every page comes from what springdoc exports, so the annotations are where your docs get better.
If an in-app Swagger UI for your own team is all you need, springdoc's springdoc-openapi-starter-webmvc-ui starter serves one at /swagger-ui.html, with no docs site to build. A separate site is worth it when the docs are public, need guides beside the reference, or shouldn't go down when the API does. The example uses Maven and Spring MVC, with notes for Gradle and WebFlux.
Pin a compatible springdoc release
Each springdoc-openapi line is built against one Spring Boot line, so choose the two together:
| Spring Boot | springdoc-openapi | Checked against |
|---|---|---|
| 4.1.x | 3.1.x | 3.1.1 is built on Spring Boot 4.1.0 |
| 4.0.x | 3.0.x | 3.0.3 is built on Spring Boot 4.0.5 |
| 3.5.x | 2.8.x or 2.9.x | 2.9.1 is built on Spring Boot 3.5.16 |
This guide uses Spring Boot 4.1.1 with springdoc-openapi 3.1.1, and pins both in the pom.xml. A mismatch can go unnoticed: this guide's small API exported the same spec with springdoc 2.8.17 on Spring Boot 4.1.1, a pair springdoc doesn't build against. Working isn't the same as supported, so check the table in springdoc's FAQ whenever you upgrade Spring Boot. On Spring Boot 3.5, the Spring MVC starter is spring-boot-starter-web rather than spring-boot-starter-webmvc.
Set up the two projects
The example keeps the API and its docs in one repository, as two folders:
acme/
acme-api/ the Spring Boot API (Java, Maven)
docs-site/ the Blume docs (Node.js)Create the folders and scaffold the docs project:
mkdir acme && cd acme
mkdir -p acme-api/src/main/java/com/acme/messages acme-api/src/main/resources
npx blume init docs-site --template docs --yesblume init writes docs-site/ with a docs/ content folder, a blume.config.ts, and a package.json whose dev and build scripts run Blume, then installs its dependencies. The API needs JDK 21 and Maven. If you generated your project on start.spring.io, it has the Maven wrapper, and ./mvnw works wherever this guide runs mvn.
Build the API
Here's the whole pom.xml. Beside the web and validation starters, it adds springdoc's API starter, which serves the spec without Swagger UI. The build section is the export, which the next section walks through.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.acme</groupId>
<artifactId>acme-api</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<springdoc.version>3.1.1</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>pre-integration-test</id>
<goals>
<goal>start</goal>
</goals>
<configuration>
<profiles>
<profile>openapi</profile>
</profiles>
</configuration>
</execution>
<execution>
<id>post-integration-test</id>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.5</version>
<executions>
<execution>
<id>integration-test</id>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
<outputFileName>openapi.json</outputFileName>
<failOnError>true</failOnError>
</configuration>
</plugin>
</plugins>
</build>
</project>For a WebFlux app, use springdoc-openapi-starter-webflux-api instead. The rest of the guide is the same.
Declare the API's metadata once, on a configuration class. The title, public server URL, and security scheme apply to the whole document:
package com.acme.messages;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server;
import org.springframework.context.annotation.Configuration;
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "Acme Messages API",
version = "1.0.0",
description = "Send transactional email and SMS."),
servers = @Server(url = "https://api.acme.example/v1"),
security = @SecurityRequirement(name = "bearerAuth"))
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
description = "An API key, sent as a bearer token.")
public class OpenApiConfig {}The DTOs are records. springdoc reads Jakarta Bean Validation annotations, so @NotBlank marks a field as required in the spec, and @Schema adds descriptions, examples, and allowed values:
package com.acme.messages;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
public record SendMessageRequest(
@NotBlank
@Schema(
description = "How to deliver the message.",
allowableValues = {"email", "sms"},
example = "email")
String channel,
@NotBlank
@Schema(
description = "An email address, or a phone number in E.164 format.",
example = "ada@example.com")
String to,
@NotBlank
@Schema(description = "The ID of the template to send.", example = "welcome")
String template,
@Schema(description = "Values for the template's variables.")
Map<String, String> variables) {}package com.acme.messages;
import io.swagger.v3.oas.annotations.media.Schema;
public record Message(
@Schema(example = "msg_8f2k")
String id,
@Schema(allowableValues = {"queued", "sent", "delivered", "failed"})
String status,
@Schema(allowableValues = {"email", "sms"})
String channel) {}The controller keeps messages in memory, which is enough to run the API and see what the spec describes:
package com.acme.messages;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/messages")
@Tag(name = "Messages", description = "Send a message and check whether it arrived.")
public class MessageController {
private final Map<String, Message> messages = new ConcurrentHashMap<>();
@PostMapping
@ResponseStatus(HttpStatus.ACCEPTED)
@Operation(
summary = "Send a message",
description = "Queues an email or SMS for delivery and returns its ID right away.")
@ApiResponse(responseCode = "202", description = "The message is queued.")
@ApiResponse(
responseCode = "400",
description = "The request body is invalid.",
content = @Content)
public Message sendMessage(@Valid @RequestBody SendMessageRequest request) {
String id = "msg_" + UUID.randomUUID().toString().substring(0, 8);
Message message = new Message(id, "queued", request.channel());
messages.put(id, message);
return message;
}
@GetMapping("/{id}")
@Operation(
summary = "Get a message",
description = "Returns a message and its delivery status.")
@ApiResponse(responseCode = "200", description = "The message.")
@ApiResponse(responseCode = "404", description = "No message has that ID.", content = @Content)
public Message getMessage(
@Parameter(
description = "The message ID, from the response to Send a message.",
example = "msg_8f2k")
@PathVariable
String id) {
Message message = messages.get(id);
if (message == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return message;
}
}package com.acme.messages;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MessagesApplication {
public static void main(String[] args) {
SpringApplication.run(MessagesApplication.class, args);
}
}Where each annotation shows up
| In Java | In the spec | On the docs site |
|---|---|---|
@Tag(name = "Messages") | The operation's tag | The sidebar group, and the URL segment /api/messages/ |
The method name, sendMessage | operationId | The page URL, /api/messages/send-message |
@Operation | summary and description | The page title, sidebar label, and introduction |
@NotBlank, @Schema | required, descriptions, examples, enum | The schema tables, and the values Try it starts with |
@Server | servers | Where the Try it panel sends requests |
@SecurityScheme and the root requirement | components.securitySchemes and security | An Authorization section, and a bearer placeholder in every code sample |
Two defaults are worth overriding before you publish. Without @Tag, springdoc tags operations after the controller class, so the URL segment becomes message-controller. Without @Server, the spec's only server is http://localhost:8080, labeled "Generated server url", because that's where the export reached the app. To mark one operation as public, annotate its method with @SecurityRequirements and no value: springdoc writes security: [] for it, and Blume leaves out its Authorization section.
Export the spec during the build
Add two properties files:
springdoc.api-docs.enabled=false
springdoc.default-produces-media-type=application/jsonspringdoc.api-docs.enabled=trueThe first file turns /v3/api-docs off in the app you deploy, so a production request to it gets a 404. The openapi profile turns it back on, and only the export runs with that profile. The media type line makes responses application/json in the spec; without it, springdoc writes */* for any method that doesn't declare what it produces.
Now the build section of the pom.xml. When you run mvn verify, Maven runs its goals in phase order:
packagebuilds the jar.pre-integration-test: Spring Boot'sstartgoal launches the jar's app in a separate process, with theopenapiprofile, and waits until it's ready.integration-test: springdoc'sgenerategoal downloadsapiDocsUrland writes it totarget/openapi.json.post-integration-test:stopshuts the app down.
Don't leave out failOnError. It defaults to false, and then a failed download logs an error while the build still passes, with no spec written. Run the build:
cd acme-api
mvn verifyspringdoc 3.x writes OpenAPI 3.1 by default. To see the parts Blume turns into the Authorization section and the Try it server, print them with jq:
jq '{openapi, servers, security, securitySchemes: .components.securitySchemes}' target/openapi.json{
"openapi": "3.1.0",
"servers": [
{
"url": "https://api.acme.example/v1"
}
],
"security": [
{
"bearerAuth": []
}
],
"securitySchemes": {
"bearerAuth": {
"type": "http",
"description": "An API key, sent as a bearer token.",
"scheme": "bearer"
}
}
}On Gradle, the org.springdoc.openapi-gradle-plugin plugin (1.9.0) does the same job: ./gradlew generateOpenApiDocs starts the app, downloads the spec, and writes build/openapi.json. Pass the profile through its customBootRun arguments.
Check the auth metadata without secrets
Nothing in that export needed a credential. The bearer scheme comes from annotations, so springdoc describes it without ever seeing a token, and the export request sends none. Keep it that way: documenting the API should never need a production key, database password, or signing secret. If your app won't start without one, give the openapi profile a local stand-in, not the real value.
What an export can do is lose metadata quietly. Drop an annotation in a refactor and the build still succeeds, with less in the spec. Check the file after every export:
jq -e '
.components.securitySchemes.bearerAuth.scheme == "bearer"
and .security == [{"bearerAuth": []}]
and .servers[0].url == "https://api.acme.example/v1"
' target/openapi.jsonIt prints true. With -e, jq exits with an error when the result is false, so the check fails the build if the scheme, the root requirement, or the public server URL goes missing. jq is preinstalled on GitHub's Ubuntu runners.
Build the docs site
Replace the scaffolded config:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
title: "Acme Docs",
deployment: { site: "https://docs.acme.example" },
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "API reference", path: "/api" },
],
},
reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});The reference doesn't add a header tab on its own, so the tab pointing at /api is what makes it reachable. deployment.site gives the sitemap and canonical links their origin, since GitHub Pages doesn't tell the build its URL.
The spec is a build output, so keep it out of Git in the docs project, then copy in the one you exported and start the dev server:
cd ../docs-site
echo "openapi.json" >> .gitignore
cp ../acme-api/target/openapi.json openapi.json
npm run devOpen http://localhost:4321/api. The overview lists the Messages tag, and each operation has its own page:
| Operation | Page |
|---|---|
POST /messages | /api/messages/send-message |
GET /messages/{id} | /api/messages/get-message |
Each page has an Authorization section for the bearer token, schema tables with the required fields marked, and a Try it panel aimed at https://api.acme.example/v1. That host doesn't exist, so Send can't succeed here; with your own API, the panel calls the server you declared in @Server. The dev server doesn't watch the spec file, so after a new export, copy the file again and restart it.
For more on the reference itself, like code sample languages and a hand-written authentication page beside the operations, see Generate API docs from an OpenAPI spec.
Hand the spec over in CI
In CI, the handoff is a workflow artifact. One job builds the API and uploads openapi.json, the next downloads it into docs-site and builds the site, and a third deploys it. The docs job never sets up Java, and the API job never sets up Node.js.
name: API docs
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
cache: maven
cache-dependency-path: acme-api/pom.xml
- name: Build the API and export the spec
run: mvn -B verify
working-directory: acme-api
- name: Check the auth metadata
run: |
jq -e '
.components.securitySchemes.bearerAuth.scheme == "bearer"
and .security == [{"bearerAuth": []}]
and .servers[0].url == "https://api.acme.example/v1"
' acme-api/target/openapi.json
- uses: actions/upload-artifact@v7
with:
name: openapi
path: acme-api/target/openapi.json
if-no-files-found: error
docs:
needs: spec
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs-site
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: openapi
path: docs-site
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx blume validate
- run: npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: docs-site/dist
include-hidden-files: true
deploy:
needs: docs
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5- spec installs Temurin JDK 21 with a Maven cache keyed on the API's
pom.xml, runs the export and the jq check, and uploads the file.if-no-files-found: errorstops the run here if the spec is missing, instead of in the docs build. - docs downloads the artifact into
docs-site/, wherespecpoints, then checks every internal link withblume validateand builds the site intodist/.include-hidden-fileskeeps the.well-known/folder Blume writes for agent discovery. - deploy publishes the build to GitHub Pages. The
pagesandid-tokenpermissions are what a Pages deployment needs, and the workflow references no secrets.
Before the first run, set Source to GitHub Actions in the repository's Pages settings, and add docs.acme.example under Custom domain. Deploy Markdown docs to GitHub Pages covers both settings and the DNS record. To deploy somewhere else, replace the last job with your host's deploy step for docs-site/dist; the deployment docs cover each host.
Troubleshooting
mvn verify passes, but there's no openapi.json
failOnError is missing or false. The log shows [ERROR] An error has occurred, response code: 404 and then BUILD SUCCESS. Set failOnError to true so the next failure stops the build, then fix the cause below.
The export fails with "response code: 404"
The app started, but the spec isn't at apiDocsUrl. Check that the start execution lists the openapi profile. If you set springdoc.api-docs.path or server.servlet.context-path, include them in apiDocsUrl.
The export fails with "response code: 401" or 403
Spring Security is guarding the spec. Permit it in your security filter chain with .requestMatchers("/v3/api-docs/**").permitAll(). The endpoint is only on under the openapi profile, so this exposes nothing in production. Don't work around it by sending a real token through the plugin's headers setting: the build would then need a production credential to document the API.
The next export can't start because port 8080 is in use
When generate fails, Maven stops before post-integration-test, so the app it started keeps running on port 8080. Stop that Java process, then run the build again. CI runners start clean each time, so this only bites locally.
The app doesn't start in time
The start goal checks 60 times, half a second apart, so it gives up after 30 seconds. For a slow app, raise maxAttempts in the start execution's configuration. If it needs a database or a broker to boot, point the openapi profile at a local stand-in, never at production.
Send fails in Try it, but curl works
That's CORS: the panel calls your API from the docs site's origin, and the browser blocks the response unless the API allows that origin. In Spring MVC:
package com.acme.messages;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("https://docs.acme.example")
.allowedMethods("GET", "POST")
.allowedHeaders("Authorization", "Content-Type");
}
}Fix CORS errors in an API documentation playground covers reading the preflight, and Blume's proxy for APIs you can't change.
The docs build fails with BLUME_OPENAPI_UNAVAILABLE
Blume couldn't read the spec where spec points. In CI, check that the download step's path is docs-site, so the file lands at docs-site/openapi.json. Locally, copy the export in again. In blume dev this is only a warning and the reference is skipped, so a working dev server can hide it.
Next step
Catch API changes in review
Check each pull request's spec for freshness and breaking changes, so a change that breaks clients or moves a page shows up in review.
Read the CI sync guideA step here not working for you? Report a broken step.