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:
bun add @beignet/core @beignet/next zodKeep 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.
// 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.
// 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:
// 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:
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:
[{ "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:
// 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:
// 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:
bun add --dev tsxCall the actual Next.js route export to verify default values, valid input, and invalid input:
// 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();
}
});node --import tsx --test features/catalog/tests/route.test.ts
bun run buildRun 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 feature | features/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 tests | features/catalog/use-cases.ts, bound through lib/use-case.ts. Extract reused validation into features/catalog/schemas.ts. |
| Database access or another external dependency | A 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 failures | Session-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
| Symptom | Check |
|---|---|
| Next.js reports conflicting routes | Check existing dynamic and catch-all routes before choosing the catalog path. |
| Every catalog request returns 404 | Keep 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 URL | Set an absolute baseUrl on the client used for server-side HTTP calls. |
| Private data is visible | The example is public. Add session-derived identity and authorization before returning private records. |
Work with your agent
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.