--- title: "Add Beignet to an existing Next.js app" description: "Add a public catalog endpoint in three files, then use its shared validation and typed client alongside existing Next.js routes." url: "https://www.beignetjs.com/guides/existing-nextjs-app" markdown_url: "https://www.beignetjs.com/markdown/guides/existing-nextjs-app.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "63ee4d92e831de857b32fdebb0d41a7da244f147" source_modified: true --- # 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](https://www.beignetjs.com/markdown/deployment.md#preflight-checks). 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](https://www.beignetjs.com/markdown/getting-started.md) 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(); const items = [ { id: "notebook", name: "Notebook", priceInCents: 1200 }, { id: "pencil", name: "Pencil", priceInCents: 200 }, ]; export const getServer = createNextServerLoader(() => createNextServer({ ports: {}, context: async () => ({}), routes: defineRoutes([ 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](https://www.beignetjs.com/markdown/application.md#route-handler-or-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](https://www.beignetjs.com/markdown/client.md). For query caching and loading states in React, use the same contract with [React Query](https://www.beignetjs.com/markdown/react-query.md). ## 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 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](https://www.beignetjs.com/markdown/build-first-resource.md) walks through a feature with persistence, ownership, and a UI. [App architecture](https://www.beignetjs.com/markdown/app-architecture.md) 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](https://www.beignetjs.com/markdown/config.md). Those tools inspect conventions beyond this focused integration. If you consolidate endpoints into a catch-all adapter, review the [route setup](https://www.beignetjs.com/markdown/server.md) 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 ```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. ```