Skip to content

Add Beignet to an existing Next.js app

Add a public catalog endpoint in three files: a contract, a server, and a Next.js route. Then call it with a typed client that shares the contract's validation. Your existing pages and API routes keep their current setup.

Before you start

Use a working TypeScript App Router app with a maintained Next.js release and Node.js 22.12 or newer. Check the supported Next.js versions. Commit your current work so you can review the integration separately.

These examples assume app/ at the project root and @/* mapped to ./* in tsconfig.json. For a src/ app, put all the TypeScript files shown here under src/, keep @/* pointing there, and adjust the test command accordingly. Merge declarations into existing files instead of replacing application code.

Choose an unused /api/catalog path, including checking existing dynamic and catch-all routes. The catalog returns two public, checked-in records. It needs no database or session state. Keep private data behind your app's authentication and authorization; a new route does not inherit another route's checks.

Install the packages

Use your app's existing package manager. With Bun:

bash
bun add @beignet/core @beignet/next zod

Keep the Beignet packages on the same release. This guide adds the integration manually; the quickstart is the path for generating a new app.

Define the contract

The contract describes the URL, accepted query, and response. limit is a number between 1 and 20, defaulting to 1 when omitted. The query transport converts between a URL parameter and that number.

typescript
// features/catalog/contracts.ts
import {
  defineContractGroup,
  defineQueryTransport,
  query,
} from "@beignet/core/contracts";
import { z } from "zod";

const catalog = defineContractGroup()
  .namespace("catalog")
  .prefix("/api/catalog");

export const listCatalog = catalog
  .get("/")
  .query(
    z.object({ limit: z.number().int().min(1).max(20).default(1) }),
    defineQueryTransport({ limit: query.number() }),
  )
  .responses({
    200: z.array(
      z.object({
        id: z.string(),
        name: z.string(),
        priceInCents: z.number().int().nonnegative(),
      }),
    ),
  });

This file is safe to import in browser code. Keep database access and other server-only dependencies out of it.

Create the server

Register the contract with a handler that returns the requested number of items. defineRoute infers the handler's input and response types from the contract; Beignet validates requests before calling the handler.

typescript
// server/index.ts
import "@beignet/core/server-only";
import { createRoutes, defineRoutes } from "@beignet/core/server";
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { listCatalog } from "@/features/catalog/contracts";

const { defineRoute } = createRoutes<object>();
const items = [
  { id: "notebook", name: "Notebook", priceInCents: 1200 },
  { id: "pencil", name: "Pencil", priceInCents: 200 },
];

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: {},
    context: async () => ({}),
    routes: defineRoutes<object>([
      defineRoute({
        contract: listCatalog,
        handle: async ({ query }) => ({
          status: 200,
          body: items.slice(0, query.limit),
        }),
      }),
    ]),
    mapUnhandledError: () => ({
      status: 500,
      body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error" },
    }),
  }),
);

The server uses empty ports and context because this endpoint has no external dependencies or session state. The loader creates and reuses the server when a request first needs it. A direct handler fits this static response; add a use case when the operation owns business rules or needs to run outside HTTP.

Mount the Next.js route

The third file connects Next.js to the Beignet server:

typescript
// app/api/catalog/route.ts
import { createApiRoute } from "@beignet/next";
import { getServer } from "@/server";

export const runtime = "nodejs";
export const { GET } = createApiRoute(getServer);

Start your app with its existing dev command, then try the endpoint:

bash
curl -i 'http://localhost:3000/api/catalog?limit=1'
curl -i 'http://localhost:3000/api/catalog?limit=0'

The first request returns HTTP 200 with one item:

json
[{ "id": "notebook", "name": "Notebook", "priceInCents": 1200 }]

The second returns HTTP 422 with code VALIDATION_ERROR, because the contract requires a positive limit. Omitting limit returns the notebook; ?limit=2 returns both records. Use your app's port if it differs from 3000, and check an existing page and API endpoint too.

Call it with the shared contract

Add a client once for your app, or reuse the Beignet client you already have:

typescript
// client/index.ts
import { createClient } from "@beignet/core/client";

export const apiClient = createClient({ validateInput: true });

In a client component's event handler or existing browser data-fetching code, bind the same contract and call it:

typescript
// Inside an existing client component module (excerpt)
import { apiClient } from "@/client";
import { listCatalog } from "@/features/catalog/contracts";

const catalogEndpoint = apiClient.endpoint(listCatalog);

async function loadCatalog() {
  const items = await catalogEndpoint.call({ query: { limit: 1 } });
  return items.map((item) => item.name);
}

loadCatalog() resolves to ["Notebook"]. TypeScript knows the query and item types without another interface or generated client. With validateInput: true, passing limit: 0 rejects with INPUT_VALIDATION_ERROR before sending a request; the server independently rejects invalid requests from other callers.

Try changing .max(20) in the contract to .max(1). After your app reloads, both a client call with limit: 2 and a direct HTTP request with ?limit=2 fail validation. Restore the limit to 20 before continuing.

The browser uses same-origin URLs. Server-side HTTP calls need an absolute baseUrl; see the client guide. For query caching and loading states in React, use the same contract with React Query.

Verify the integration

If your app already has test tooling, adapt the assertions below to it. To run this Node test as written, install tsx:

bash
bun add --dev tsx

Call the actual Next.js route export to verify default values, valid input, and invalid input:

typescript
// features/catalog/tests/route.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { GET } from "@/app/api/catalog/route";
import { getServer } from "@/server";

test("serves the catalog and validates its limit", async () => {
  try {
    const defaults = await GET(new Request("http://localhost/api/catalog"));
    assert.equal(defaults.status, 200);
    assert.deepEqual(await defaults.json(), [
      { id: "notebook", name: "Notebook", priceInCents: 1200 },
    ]);

    const limited = await GET(
      new Request("http://localhost/api/catalog?limit=2"),
    );
    assert.equal(limited.status, 200);
    assert.deepEqual(await limited.json(), [
      { id: "notebook", name: "Notebook", priceInCents: 1200 },
      { id: "pencil", name: "Pencil", priceInCents: 200 },
    ]);

    const invalid = await GET(
      new Request("http://localhost/api/catalog?limit=0"),
    );
    assert.equal(invalid.status, 422);
    assert.equal((await invalid.json()).code, "VALIDATION_ERROR");
  } finally {
    await (await getServer()).stop();
  }
});
bash
node --import tsx --test features/catalog/tests/route.test.ts
bun run build

Run your existing lint, typecheck, and test scripts too. The endpoint uses three files; the shared client and route test bring this walkthrough to five.

Add structure as the feature grows

Keep the server and client shared across endpoints. Introduce the other pieces when the feature needs them:

When you need…Add…
Several operations in the featurefeatures/catalog/routes.ts with a route group, composed in server/routes.ts. Move the shared route builders to lib/routes.ts.
Business rules or behavior called from jobs, scripts, or testsfeatures/catalog/use-cases.ts, bound through lib/use-case.ts. Extract reused validation into features/catalog/schemas.ts.
Database access or another external dependencyA feature port in features/catalog/ports.ts and an adapter in infra/. Declare shared app context and wire the dependency once for the server.
Private records or expected application failuresSession-derived identity in context, authorization in use cases or policies, and declared errors from the app's error catalog.

The Projects tutorial walks through a feature with persistence, ownership, and a UI. App architecture explains the shared setup and feature files in a larger application.

Before using generators or strict diagnostics, align the app with that structure and CLI configuration. Those tools inspect conventions beyond this focused integration. If you consolidate endpoints into a catch-all adapter, review the route setup and remove conflicting route files deliberately.

Troubleshooting

SymptomCheck
Next.js reports conflicting routesCheck existing dynamic and catch-all routes before choosing the catalog path.
Every catalog request returns 404Keep the Next.js path, contract prefix, and registered contract aligned.
TypeScript cannot resolve @/…Match the file locations to the app's existing tsconfig.json path alias.
A server-side client call reports an invalid URLSet an absolute baseUrl on the client used for server-side HTTP calls.
Private data is visibleThe example is public. Add session-derived identity and authorization before returning private records.

Work with your agent

text
Add one public catalog endpoint to this existing Next.js app using the
existing-nextjs-app guide. Inspect routing, aliases, package versions, and local
instructions first. Keep current routes, auth, database, and tooling intact.
Start with a contract, a server with a direct handler, and a focused Next.js
adapter. Reuse existing server and client setup where present. Demonstrate the
typed client and shared validation. Verify default, valid, and invalid input,
run existing checks and a production build, and confirm an existing page and API
route still work. Explain when to extract use cases and ports, and what is needed
before returning private data or using Beignet generators.