Skip to content

API: createApp, OpenAPI, Swagger and Redoc

createApp is the factory that assembles an already wired Express app: JSON parsing, request-id, optional CORS, /health, your routers, the documentation (Swagger + Redoc from Zod schemas) and, last, the error-handling stack. It's the port of api.app + api.server from tempest-fastapi-sdk.

You almost never configure these pieces by hand — you pass options to createApp and boot with runServer.


1. The minimum that boots

import { createApp, runServer } from "tempest-express-sdk";

const app = await createApp();
await runServer(app, { host: "127.0.0.1", port: 8000 });

That already gives you:

  • Parsed JSON body (100kb limit) + urlencoded.
  • X-Request-ID on every response (generated if the client doesn't send one).
  • GET /health answering { "status": "ok", "checks": {} }.
  • The canonical error envelope for any AppException or unmatched route.

createApp is async

It's async because the configure hook may be async (open a connection, load keys…). Always await it.


2. Registering your routers: the configure hook

Routers and OpenAPI paths go through the configure hook, which runs after the middlewares and before the error stack — the right order for Express.

import { createApp, runServer } from "tempest-express-sdk";
import { usersRouter } from "@/api/routers/users";

const app = await createApp({
  configure: (app) => {
    app.use(usersRouter);
    // any app.use / app.get goes here
  },
});

await runServer(app, { port: 8000 });

Don't register the error handler by hand

Don't call registerExceptionHandlers inside configure — createApp already registers it last, which is where Express requires it. Adding it earlier makes the handler miss routes registered afterwards.


3. createApp options

All optional. The most used:

Option Type Default For
corsOrigins string \| string[] \| false false (no CORS) Allow origins. "*" or a list.
health HealthRouterOptions \| false mounts /health Health check; false removes it.
configure (app) => void \| Promise — Mount routers and OpenAPI paths.
openapi CreateAppOpenApi — Wire Swagger/Redoc (section 4).
catalog MessageCatalog — Localized error messages.
errorHandling options — Forwarded to the exception handler.
jsonLimit string "100kb" Max JSON body size.

A wired example:

const app = await createApp({
  corsOrigins: ["https://app.example.com", "http://localhost:5173"],
  jsonLimit: "1mb",
  health: {
    checks: [
      {
        name: "db",
        check: async () => {
          await db.raw("SELECT 1");
          return true;
        },
      },
    ],
  },
  configure: (app) => {
    app.use(usersRouter);
  },
});

Bind: 127.0.0.1 vs 0.0.0.0

runServer's default is 127.0.0.1 (local only). Use host: "0.0.0.0" only when another host needs to reach the service (e.g. a frontend in a separate container).

checks is a list of { name, check }

Each probe is { name: string, check: () => Promise<boolean> | boolean }. /health runs them all, exposes the result under checks ({ [name]: boolean }) and degrades to 503 with status: "degraded" if any fails (or throws). With no checks it answers 200 with { status: "ok", checks: {} }.


4. Automatic docs (OpenAPI → Swagger + Redoc)

Here's the trick: since every SDK Zod schema carries .openapi(), descriptions and examples flow straight into the docs. The flow is 3 steps.

Step 1 — create a registry

import { createOpenApiRegistry, z } from "tempest-express-sdk";

const registry = createOpenApiRegistry();

Step 2 — name your schemas with .meta({ id })

A schema carrying an id becomes a component (components.schemas), and every route using it emits a $ref instead of repeating the shape inline:

import { z } from "tempest-express-sdk";

export const Item = z
  .object({
    id: z.uuid().openapi({ description: "The item id." }),
    name: z.string().openapi({ description: "The item name." }),
  })
  .meta({ id: "Item" });

registry.registerPath({
  method: "get",
  path: "/api/items/{id}",
  summary: "Fetch an item",
  request: { params: z.object({ id: z.uuid() }) },
  responses: {
    200: { description: "ok", content: { "application/json": { schema: Item } } },
  },
});

The result: components.schemas.Item is populated and the response body reads {"$ref": "#/components/schemas/Item"}.

.meta({ id }) is the default path for two reasons

It is immune to import order (see the warning below) and it marks the schema itself — any route importing it emits a $ref without having to receive a value back. .meta is native zod v4, not an extension.

registry.register(name, schema)

Still supported, and the right call when a schema has to be published as a component without appearing on any route:

const Item = registry.register("Item", z.object({ id: z.uuid() }));

Use the value register returns

register does not modify the schema you passed in — it returns a tagged copy. A route referencing the original variable comes out with an inline body rather than a $ref. That is how 18 routes of one gateway shipped without components, making the client generator emit an anonymous type on the other side.

Module evaluation order, and why it no longer breaks you

.openapi() is added by patching the prototype, and zod v4 copies prototype members into each instance at construction. A schema built before the SDK module was evaluated — the natural order, with schemas in schemas/*.ts and the SDK imported only in the docs layer — never receives the patch, and the library's register used to throw TypeError: zodSchema.openapi is not a function from inside node_modules, at boot.

Since 0.31.0 the SDK's registry normalizes the schema before handing it over, so the same call site works in either order. Three things still hold:

  • import { z } from "tempest-express-sdk" in your schema modules gets you the already-extended instance — the cheapest way out, which is why this recipe uses that import;
  • anything needing .openapi() on the schema itself (a parameter with param, an extend with anyOf) needs the extended instance, so import z from the SDK or call extendZodWithOpenApi(z) — now re-exported — in your application entrypoint;
  • .meta({ id }) depends on none of it.

Step 3 — pass the registry to createApp

const app = await createApp({
  configure: (app) => {
    app.use(itemsRouter);
  },
  openapi: {
    registry,
    info: { title: "My API", version: "1.0.0", description: "Demo." },
    servers: [{ url: "http://127.0.0.1:8000" }],
  },
});

Now the app serves:

Route What
GET /openapi.json The generated OpenAPI 3.0 document.
GET /docs Swagger UI (interactive, served offline).
GET /redoc Redoc (read-oriented reference).

openapi block options (CreateAppOpenApi):

Field Default For
registry — (required) The populated registry.
info — (required) { title, version, description? }.
servers — List of { url, description? }.
v31 false Emit OpenAPI 3.1 instead of 3.0.
jsonPath /openapi.json JSON route.
swaggerPath /docs Swagger mount; false disables.
redocPath /redoc Redoc mount; false disables.
swagger — Swagger page's { title?, favicon?, ui? }.
redoc — Redoc page's { title?, favicon?, bundle?, bundlePath?, scriptUrl? }.

Favicon: both pages already ship one

Without a <link rel="icon">, the browser requests /favicon.ico at the origin root. On an API-only service that root has no favicon: the request is a 404, or a 401 when it sits behind the auth middleware, or it lands on an SPA catch-all. The result is a red console error on every visit to /docs:

[ERROR] Failed to load resource: the server responded with a status of 401
        (Unauthorized) @ http://127.0.0.1:3111/favicon.ico

Both pages declare an inline SVG icon (a data: URI), so the request never goes out. To use your own:

const app = await createApp({
  openapi: {
    registry,
    info: { title: "My API", version: "1.0.0" },
    swagger: { favicon: "/static/icon.svg" },
    redoc: { favicon: "/static/icon.svg" },
  },
});

favicon: false omits the tag — the browser goes back to requesting /favicon.ico, which is what you want when the root does have a real favicon to serve.

Configuring Swagger UI: the ui option

SwaggerOptions.ui is passed straight into the SwaggerUIBundle constructor, after the SDK's defaults and before presets. Anything Swagger UI accepts and JSON can carry goes through it.

Three defaults the SDK picks, differing from Swagger UI's own:

Option Swagger UI Here Why
layout StandaloneLayout BaseLayout No Explore topbar
deepLinking false true An operation becomes a shareable link
persistAuthorization false true Credentials survive a reload

Why move off StandaloneLayout

Standalone renders the Explore topbar: an editable URL field that loads any spec from any origin. It exists for the Swagger editor/demo, where choosing the spec is the point. On a page documenting one service it is surface nobody asked for — a reader swaps your spec for another and your URL now documents someone else's API. Nothing of the server leaks, but there is no reason to offer it either.

Want it back? ui: { layout: "StandaloneLayout" } — the SDK re-includes the standalone preset script along with it.

Turn "Try it out" off when the effect is irreversible

Swagger UI enables Try it out for every verb. On a messaging gateway, POST /message/send-text from the docs page sends a real message:

mountSwaggerUi(app, "/docs", "/openapi.json", {
  ui: { supportedSubmitMethods: ["get"] },   // only reads can be executed
});

mountSwaggerUi(app, "/docs", "/openapi.json", {
  ui: { supportedSubmitMethods: [] },        // documents, does not execute
});

The default stays Swagger UI's (every verb) — the right call for most APIs, and now it is a choice.

A function in ui throws at mount time

The options are serialized as JSON into the page's <script>, and JSON.stringify drops functions silently. A requestInterceptor passed here would simply never run, with nothing to indicate why — so mountSwaggerUi throws right away, naming the offending key. Swagger UI options that take a callback have to be wired in the browser.

Offline Redoc: install redoc next to the service

Swagger UI is offline by construction — its assets come from swagger-ui-dist, served at ${swaggerPath}/assets. Redoc's renderer is ~1 MB and is not vendored: the redoc package pulls 22 dependencies and peers on react, react-dom, styled-components, mobx and core-js, bounds no backend service should inherit just to render a reference page.

It is an optional peer dependency instead. Install it and the page serves the bundle from the service itself, touching no CDN:

npm install redoc
bundle What it does
"auto" (default) Serves redoc's bundle when it is installed; falls back to the CDN when it is not.
"local" Serves from disk and throws at mount time when redoc is not installed.
"cdn" Always loads from jsDelivr.
redoc: { bundle: "local" }                              // fail loudly, never degrade
redoc: { bundlePath: "/opt/app/redoc.standalone.js" }   // your copy, served by the SDK
redoc: { scriptUrl: "/vendor/redoc.standalone.js" }     // a URL you already serve

Closed network: use \"local\", not \"auto\"

On an air-gapped deploy, or one with a restrictive CSP, "auto" silently falls back to the CDN if somebody forgets to install redoc — and the page then fails blank, because the <script> never loads and Redoc.init never runs. "local" turns that omission into a boot-time error.

When the bundle fails to load, the page explains itself

Instead of a blank screen it shows which URL failed, confirms /openapi.json is still up, and says how to fix it. That covers a blocked CDN and a wrong scriptUrl alike.

Redoc still fetches its own watermark

Even with a local bundle, Redoc requests https://cdn.redoc.ly/redoc/logo-mini.svg — the "API docs by Redocly" logo baked into their bundle, outside the SDK's control. With no network the little image is missing; the page renders fine.

No trailing slash works too

As of v0.20.1 Swagger's assets use an absolute path (/docs/assets/...), so GET /docs and GET /docs/ both render the full UI. Before, visiting /docs without the slash fetched /assets/... and the page came up unstyled (assets 404). If you pinned an earlier version, upgrade.


5. Mounting the docs manually (advanced)

If you don't use createApp (a legacy Express app, say), wire the pieces by hand:

import express from "express";
import {
  createOpenApiRegistry,
  generateOpenApiDocument,
  mountOpenApiJson,
  mountSwaggerUi,
  mountRedoc,
} from "tempest-express-sdk";

const app = express();
const registry = createOpenApiRegistry();
// ... registry.register / registerPath ...

const document = generateOpenApiDocument(registry, {
  info: { title: "My API", version: "1.0.0" },
});

mountOpenApiJson(app, "/openapi.json", document);
mountSwaggerUi(app, "/docs", "/openapi.json", { title: "My API" });
mountRedoc(app, "/redoc", "/openapi.json");

generateOpenApiDocument returns a plain JSON object — you can save it to a file, version it or serve it from wherever you like.


Recap

  • createApp(options) assembles middlewares → routers (configure) → docs → error, in that order; runServer(app, { host, port }) boots it.
  • Register routers inside configure; do not register the error handler by hand.
  • Docs in 3 steps: createOpenApiRegistry() → register/registerPath → pass the registry in openapi. You get /openapi.json, /docs and /redoc.
  • swagger: { ui: {...} } forwards options to SwaggerUIBundle; the defaults here are BaseLayout (no Explore topbar), deepLinking and persistAuthorization.
  • Swagger is offline; Redoc is too once redoc is installed (bundle: "local" to refuse the CDN fallback). Both pages ship a favicon, so no /favicon.ico request hits your API root.
  • /docs and /docs/ behave identically — absolute asset paths. 🚀