Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 11 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 Bootspringdoc-openapiChecked against
4.1.x3.1.x3.1.1 is built on Spring Boot 4.1.0
4.0.x3.0.x3.0.3 is built on Spring Boot 4.0.5
3.5.x2.8.x or 2.9.x2.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 --yes

blume 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 JavaIn the specOn the docs site
@Tag(name = "Messages")The operation's tagThe sidebar group, and the URL segment /api/messages/
The method name, sendMessageoperationIdThe page URL, /api/messages/send-message
@Operationsummary and descriptionThe page title, sidebar label, and introduction
@NotBlank, @Schemarequired, descriptions, examples, enumThe schema tables, and the values Try it starts with
@ServerserversWhere the Try it panel sends requests
@SecurityScheme and the root requirementcomponents.securitySchemes and securityAn 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/json
springdoc.api-docs.enabled=true

The 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:

  1. package builds the jar.
  2. pre-integration-test: Spring Boot's start goal launches the jar's app in a separate process, with the openapi profile, and waits until it's ready.
  3. integration-test: springdoc's generate goal downloads apiDocsUrl and writes it to target/openapi.json.
  4. post-integration-test: stop shuts 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 verify

springdoc 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.json

It 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 dev

Open http://localhost:4321/api. The overview lists the Messages tag, and each operation has its own page:

OperationPage
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: error stops the run here if the spec is missing, instead of in the docs build.
  • docs downloads the artifact into docs-site/, where spec points, then checks every internal link with blume validate and builds the site into dist/. include-hidden-files keeps the .well-known/ folder Blume writes for agent discovery.
  • deploy publishes the build to GitHub Pages. The pages and id-token permissions 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 guide

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init