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

API reference

Build an Elysia API documentation website

Describe your Elysia routes and errors so the OpenAPI spec matches the running API, then publish it as a docs site with tutorials beside a page per endpoint.

By 12 min read

To publish an Elysia API with both reference docs and tutorials, add Elysia's OpenAPI plugin, @elysia/openapi, and give every route a schema for its body, parameters, and responses, errors included. Elysia then serves an OpenAPI 3.1 spec at /openapi/json. Export that spec to a file, point Blume's openapi() adapter at it, and write your tutorials as Markdown pages that share the reference's sidebar, search, and links.

By the end, you have a Bun API whose spec matches what it enforces at runtime, and a static docs site beside it with a quickstart, an errors page, and a page per endpoint with a Try it panel. The example is the fictional Acme Messages API from the OpenAPI guide, rebuilt in Elysia with two routes.

If the API is internal and one reference page is all your readers need, you may not need a docs site at all: the plugin already serves one. Here's where that page stops.

The plugin's page or a docs site

The plugin serves a reference at /openapi as soon as you add it. A docs site doesn't replace it; they do different jobs:

The plugin's /openapi pageA Blume docs site
Served byYour API, on every deployment of itA separate static host
ContentsOne page for the whole API, drawn in the browser by Scalar from /openapi/jsonA page per operation plus the tutorials you write, in one sidebar and one search
Changes whenThe API deploysYou export the spec and rebuild the site
For agents and crawlersOne URL, rendered by JavaScriptA URL per page, each with a Markdown copy, listed in llms.txt and the sitemap

You can keep both: the site for public docs, and the plugin's page for your team against a staging API. To drop the page but keep the raw spec, pass provider: null to openapi(): /openapi goes away and /openapi/json stays, so the export below keeps working.

Create the API

Scaffold an Elysia project with Bun, then add the plugin. These are the versions this guide was tested with:

bun create elysia acme-messages
cd acme-messages
bun add elysia@1.4.30 @elysia/openapi@1.4.16

Elysia's docs install the plugin as @elysia/openapi. Older tutorials use @elysiajs/openapi, or the earlier @elysiajs/swagger, whose page lived at /swagger. By the end of this guide, the repository looks like this:

acme-messages/
├── package.json
├── scripts/
│   └── export-openapi.ts
├── src/
│   ├── app.ts          the routes and the OpenAPI plugin
│   └── index.ts        starts the server
└── docs-site/          the Blume site
    ├── blume.config.ts
    ├── openapi.json    written by bun run openapi
    └── docs/

Describe routes and errors

Put the app in its own module, so the export script can import it without starting a server. Replace the scaffold's routes with this file:

import { openapi } from "@elysia/openapi";
import { Elysia, t } from "elysia";

// t.UnionEnum defaults to its first value, which would quietly turn a
// missing channel into "email". Clearing the default keeps it required.
const Channel = t.UnionEnum(["email", "sms"], {
  default: undefined,
  description: "How to deliver the message.",
});

const Message = t.Object(
  {
    channel: Channel,
    id: t.String({ examples: ["msg_8f2k"] }),
    status: t.UnionEnum(["queued", "sent", "delivered", "failed"], {
      default: undefined,
    }),
  },
  { description: "A message and its delivery status." }
);

const SendMessageRequest = t.Object({
  channel: Channel,
  template: t.String({
    description: "The ID of the template to send.",
    examples: ["welcome"],
  }),
  to: t.String({
    description: "An email address, or a phone number in E.164 format.",
    examples: ["ada@example.com"],
  }),
  variables: t.Optional(
    t.Record(t.String(), t.String(), {
      description: "Values for the template's variables.",
    })
  ),
});

// Every error has the same shape. Its description becomes the response's
// description in the spec.
const ApiError = (description: string) =>
  t.Object(
    {
      error: t.String({ description: "A code your client can branch on." }),
      message: t.String({ description: "What went wrong, for a person." }),
    },
    { description }
  );

// An in-memory store stands in for your database.
const messages = new Map<string, typeof Message.static>();

const v1 = new Elysia({ prefix: "/v1" })
  .model({ Message, SendMessageRequest })
  .onError(({ code, error, status }) => {
    if (code === "VALIDATION" && error.type !== "response") {
      return status(422, {
        error: "invalid_request",
        message: error.all[0]?.summary ?? "The request is invalid.",
      });
    }
  })
  .onBeforeHandle(({ headers, status }) => {
    const key = process.env.ACME_API_KEY;
    if (!key || headers.authorization !== `Bearer ${key}`) {
      return status(401, {
        error: "unauthorized",
        message: "Send your API key as a bearer token.",
      });
    }
  })
  .post(
    "/messages",
    ({ body, status }) => {
      const message = {
        channel: body.channel,
        id: `msg_${crypto.randomUUID().slice(0, 8)}`,
        status: "queued" as const,
      };
      messages.set(message.id, message);
      return status(202, message);
    },
    {
      body: "SendMessageRequest",
      detail: {
        description:
          "Queues an email or SMS for delivery and returns its ID right away.",
        operationId: "sendMessage",
        summary: "Send a message",
        tags: ["Messages"],
      },
      parse: "json",
      response: {
        202: "Message",
        401: ApiError("The API key is missing or wrong."),
        422: ApiError("The body doesn't match the schema."),
      },
    }
  )
  .get(
    "/messages/:id",
    ({ params, status }) =>
      messages.get(params.id) ??
      status(404, { error: "not_found", message: "No message has that ID." }),
    {
      detail: {
        description: "Returns a message and its delivery status.",
        operationId: "getMessage",
        summary: "Get a message",
        tags: ["Messages"],
      },
      params: t.Object({ id: t.String({ examples: ["msg_8f2k"] }) }),
      response: {
        200: "Message",
        401: ApiError("The API key is missing or wrong."),
        404: ApiError("No message has that ID."),
      },
    }
  );

export const app = new Elysia()
  .use(
    openapi({
      documentation: {
        components: {
          securitySchemes: {
            bearerAuth: {
              description: "An API key, sent as a bearer token.",
              scheme: "bearer",
              type: "http",
            },
          },
        },
        info: {
          description: "Send transactional email and SMS.",
          title: "Acme Messages API",
          version: "1.0.0",
        },
        security: [{ bearerAuth: [] }],
        servers: [{ url: "https://api.acme.example" }],
        tags: [
          {
            description: "Send a message and check whether it arrived.",
            name: "Messages",
          },
        ],
      },
    })
  )
  .use(v1);

Each part lands somewhere in the spec:

  • Models. .model() registers Message and SendMessageRequest by name. Routes that refer to them by name get a $ref, and the schemas land under components.schemas, so the reference shows them as named types.
  • detail. Becomes the operation. summary is the page title, the first tag is its sidebar group, and operationId sets its URL.
  • response. One schema per status code, errors included. Each ApiError description becomes that response's description; without one, Elysia writes "Response for status 404". A route with no response has no responses in the spec at all.
  • documentation. Copied to the top of the spec: the title, the server, the tag descriptions, and the bearer scheme that security applies to every operation.

The server is https://api.acme.example with no /v1, because the prefix is already part of every path in the spec. Put it in both places and the docs would send requests to /v1/v1/messages.

Make the runtime match the spec

The schemas that document each route also validate it, which is what keeps the reference honest. Three details in the file close the gaps where the two would drift apart:

  • Validation errors. By default, Elysia answers a bad body with a 422 in its own format, which the spec doesn't describe. The onError hook returns the same { error, message } shape as every other error, and the route lists its 422. The hook skips errors whose type is response: those mean a handler broke its own schema, which is a bug to fix, not a client error.
  • Body formats. Without parse: "json", Elysia also accepts form-encoded and multipart bodies, and the spec lists all three. With it, the route rejects anything but JSON with a 400, and the spec lists JSON alone.
  • Enum defaults. t.UnionEnum defaults to its first value. Keep that, and a request with no channel goes out as email, while the spec marks the field as required and also gives it a default. default: undefined makes it required in both.

Elysia also checks each response against the schema declared for its status code, so a handler that returns the wrong shape fails with a validation error instead of sending something the reference doesn't describe. Elysia can infer response types from your TypeScript with fromTypes(), but declaring them, as here, means the schema that documents a response is also the one that checks it.

Export the spec

Start the server from its own file:

import { app } from "./app";

app.listen(3000);
console.log(`Acme Messages API on ${app.server?.url}`);

Run it with ACME_API_KEY=sk_test_123 bun run dev. The plugin's page is at http://localhost:3000/openapi and the raw spec at /openapi/json.

The docs site builds from a file, not from a running server. Write the file with a script that asks the app for its spec in memory: app.handle runs a request through Elysia without opening a port.

import { app } from "../src/app";

const response = await app.handle(new Request("http://localhost/openapi/json"));
if (!response.ok) {
  throw new Error(`GET /openapi/json answered ${response.status}`);
}
const spec = await response.json();
await Bun.write("docs-site/openapi.json", `${JSON.stringify(spec, null, 2)}\n`);
console.log("Wrote docs-site/openapi.json");

Add a script for it beside the scaffold's dev script:

"scripts": {
  "dev": "bun run --watch src/index.ts",
  "openapi": "bun scripts/export-openapi.ts"
}

Run bun run openapi from the repository root. It creates docs-site/ if it doesn't exist yet and writes the spec there. The file is the same JSON the server returns at /openapi/json, and the same code always produces the same bytes, so its Git diff shows exactly what changed in the API.

Create the docs site

From the repository root, scaffold a Blume project around the spec:

bunx blume init docs-site --template docs --yes

It writes blume.config.ts, a docs/index.mdx home page, and a package.json with dev and build scripts, then installs Blume with Bun. The site has its own dependencies, separate from the API's. The blume command itself runs on Node.js, so you need Node.js 22.12 or later installed too. Replace the config:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  navigation: {
    tabs: [
      { label: "Docs", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});

The reference mounts at /api with an overview, and each operation gets a page at a URL built from its tag and operationId: /api/messages/send-message and /api/messages/get-message. The reference doesn't add a header tab of its own, so the API reference tab pointing at the same route is what makes it reachable. The OpenAPI guide covers the options this config leaves at their defaults, like code sample languages.

Write the tutorials

Tutorials are Markdown pages in docs-site/docs/. A page at the top level sits under the Docs tab, and a page in docs/api/ joins the API reference tab's sidebar, beside the generated pages. Start with the home page:

---
title: Acme Messages
description: Send transactional email and SMS from your app with one API call, and check whether each message arrived.
---

The Acme Messages API sends email and SMS for your app. Start with the
[quickstart](/quickstart), then look up any endpoint in the
[API reference](/api).

Then a quickstart that walks through both endpoints and links to their reference pages:

---
title: Quickstart
description: Send your first email with the Acme Messages API, then check whether it was delivered.
---

## Send a message

Every request needs your API key as a bearer token. Send a welcome email:

```bash
curl https://api.acme.example/v1/messages \
  -H "Authorization: Bearer $ACME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel":"email","to":"ada@example.com","template":"welcome"}'
```

The API answers `202 Accepted` with the new message:

```json
{ "channel": "email", "id": "msg_8f2k", "status": "queued" }
```

[Send a message](/api/messages/send-message) lists every field the body takes.

## Check delivery

Pass the ID to [Get a message](/api/messages/get-message):

```bash
curl https://api.acme.example/v1/messages/msg_8f2k \
  -H "Authorization: Bearer $ACME_API_KEY"
```

`status` moves from `queued` to `sent`, then `delivered` or `failed`.

Every endpoint shares one error shape, so describe it once, on a page under the reference:

---
title: Errors
description: Every error from the Acme Messages API has the same JSON shape, a code to branch on and a message for people.
---

Every error response has the same body:

```json
{ "error": "not_found", "message": "No message has that ID." }
```

Branch on `error`, and show or log `message`.

| Status | `error` | When |
| --- | --- | --- |
| 401 | `unauthorized` | The `Authorization` header is missing, or the key is wrong. |
| 404 | `not_found` | No message has that ID. |
| 422 | `invalid_request` | The body doesn't match the schema, like a `channel` other than `email` or `sms`. |

Each endpoint's page lists the errors it can return, starting with
[Send a message](/api/messages/send-message).

Links to operation pages are ordinary links. Run bunx blume validate in docs-site/ and it reports any that no longer resolve, like a link to an operation whose ID you changed.

Preview and build

Start the docs dev server:

cd docs-site
bun run dev

Open the URL it prints and go to the API reference tab. The Send a message page shows the SendMessageRequest fields, the 202, 401, and 422 responses with the descriptions you gave them, and an Authorization section from the bearer scheme. Its curl sample sends Authorization: Bearer YOUR_TOKEN to https://api.acme.example/v1/messages, and the Try it form starts with the examples values from your schemas.

Then build the site and serve the production build locally:

bun run build
bunx blume preview

The build writes static HTML, a search index, llms.txt, and a Markdown copy of every page to docs-site/dist/.

Deploy the site

The docs deploy separately from the API, to any static host. On Vercel, import the repository as a new project, then set these under Build and Deployment in the project settings:

SettingValue
Root Directorydocs-site
Build Commandbun run build
Output Directorydist
Node.js Version22.x or later

Vercel can't read files outside the Root Directory, which is why the spec lives in docs-site/ and is committed: the docs build never runs your API or installs its dependencies. Blume detects the site's URL on Vercel. For other hosts, and for setting deployment.site where a host doesn't expose its URL, see Deployment.

Let Try it reach the API

The Try it panel sends requests from the reader's browser to the server in your spec. Browsers allow that only when the API permits the docs origin with CORS. Add Elysia's CORS plugin with bun add @elysia/cors@1.4.2, and allow your docs domain:

import { cors } from "@elysia/cors";

export const app = new Elysia()
  .use(cors({ origin: "https://docs.acme.example" }))
  .use(
    openapi({
      // ...the documentation from before
    })
  )
  .use(v1);

The plugin answers the browser's preflight requests, and the exported spec doesn't change. The acme.example domains don't exist, so point both at your real ones. The other way around CORS is Blume's built-in proxy, covered in Fix CORS errors in an API documentation playground.

Keep the spec in sync

The docs change when the committed spec changes. After you edit a route, run bun run openapi and commit docs-site/openapi.json with the code, so the pull request shows what readers will see change. Because the export is deterministic, CI can catch a route change nobody exported:

name: API spec
on: pull_request

jobs:
  spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun run openapi
      - run: git diff --exit-code docs-site/openapi.json

You could instead point spec at your deployed https://api.acme.example/openapi/json, since Blume fetches a URL spec at build time. Then every docs build depends on the API being up, and spec changes skip review. For more on CI, see Keep API documentation in sync with backend changes in CI.

Troubleshooting

The export script gets a 404

The script requests /openapi/json, the plugin's default. If you set path or specPath in openapi(), change the URL in the script to match. And enabled: false removes both routes, so don't switch the plugin off with an environment variable the export doesn't set.

A required field shows a default

That's t.UnionEnum's default, which is its first value. At runtime, a request that leaves the field out gets that value instead of an error. Pass default: undefined, as Channel does above.

Operation URLs read like post-v1-messages

A route without detail.operationId gets one generated from its method and path, like postV1Messages, and Blume builds the page URL from it: /api/messages/post-v1-messages. Set an operationId on every route. If the old URLs are already public, add redirects for them in blume.config.ts.

The site still shows the old endpoints

The site reads the exported file, not the running API, so run bun run openapi again. The docs dev server doesn't watch the spec file, so restart bun run dev in docs-site/ too.

bun build doesn't build the site

bun build is Bun's bundler, not your build script. Use bun run build, or bunx blume build.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't read the spec. ./openapi.json resolves from docs-site/, so check that the export wrote the file there and that it's committed. In bun run dev, the same problem is a warning and the reference is left out, so a working dev server can hide it.

Send fails but curl works

That's CORS: the browser blocks a response the API didn't allow for the docs origin. Add the CORS plugin as shown above, with the exact origin the docs are served from, including the scheme.

Next step

Publish your Elysia API docs

Run it at the root of your Elysia project after exporting the spec, then point openapi() at docs-site/openapi.json.

bunx blume init docs-site --template docs --yes
Read the OpenAPI reference docs

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