Web app

Talking to the API

A client typed from openapi.json, two origins, server actions, and how errors reach a form.

Types from OpenAPI

The API's Swagger decorators produce apps/api/openapi.json. The web app turns it into types:

bun run --filter @ghost/web generate     # openapi-typescript → src/lib/api/v1.d.ts
bun run openapi                          # at the root: regenerate openapi.json, then v1.d.ts

openapi-fetch clients are typed with those paths, so a request to a route that does not exist, a missing parameter, or a misspelled response field is a TypeScript error. Change an API DTO → regenerate → fix the web app's type errors. Both generated files are committed.

Two clients, two origins

ClientWhereOriginCredentials
apps/web/src/lib/api/server.ts › createServerClientserver components, server actionsINTERNAL_API_URLforwards the browser's cookie, x-forwarded-for, x-real-ip
apps/web/src/lib/api/client.ts › apiClientclient componentsNEXT_PUBLIC_API_URLcredentials: "include"

INTERNAL_API_URL exists because in Docker the Next server reaches the API under a name the browser cannot resolve (http://api:3001). Forwarding the client address matters: Better Auth and code search rate-limit by it.

notFoundIfHidden(response) turns a 404 or 401 into Next's notFound(), so a page never tells an anonymous visitor which private repositories exist.

Writes go through server actions

Mutations are server actions built with next-safe-action (apps/web/src/lib/action-client.ts), validated with zod, e.g. apps/web/src/components/pull-requests/actions.ts:

export const mergePullRequest = actionClient
  .inputSchema(target.extend({ method: z.enum(mergeMethods), … }))
  .action(async ({ parsedInput: { username, repo, number, method } }) => {
    const data = await callApi((client) =>
      client.POST("/api/repositories/{username}/{repo}/pulls/{number}/merge", {
        params: { path: { username, repo, number } },
        body: { method },
      }),
    );
    revalidatePath(`/${username}/${repo}/pull/${number}`, "layout");
    return data;
  });

callApi throws the API's error message; next-safe-action reports it to the form that called it (handleServerError: (error) => error.message). So a DomainError message thrown deep in a NestJS service is what the user reads under the button. Write those messages for humans.

Components call actions with useAction(…) from next-safe-action/hooks. A few interactive widgets (organization members, for example) use TanStack Query against the browser client instead.

Revalidation

After a write, the action calls revalidatePath for the pages it affected (often with "layout" to refresh tab counts), so the next render fetches fresh data.

Try it

Add a field to a DTO in the API (for example a description to a response), run bun run openapi at the root, and look at the diff in apps/web/src/lib/api/v1.d.ts. Revert when done.

Check yourself

A service throws new RepositoryForbiddenError() during a server action. What does the user see?
Why does the server client use INTERNAL_API_URL rather than NEXT_PUBLIC_API_URL?

On this page