@beignet/next 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,47 +122,46 @@ export const contracts = contractsFromRoutes(routes);
122
122
 
123
123
  ```typescript
124
124
  // server/index.ts
125
- import { createNextServer } from "@beignet/next";
125
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
126
126
  import type { AppContext } from "@/app-context";
127
127
  import { routes } from "@/server/routes";
128
128
 
129
- export const server = await createNextServer<AppContext>({
130
- ports: {},
131
- routes,
132
- context: async ({ req }) => {
133
- // DEMO ONLY: this reads an unauthenticated header to simulate identity.
134
- // Real applications should verify a signed token or session cookie first.
135
- return {
136
- userId: req.headers.get("x-user-id") || "anonymous",
137
- };
138
- },
139
- mapUnhandledError: () => ({
140
- status: 500,
141
- body: {
142
- code: "INTERNAL_SERVER_ERROR",
143
- message: "Internal server error",
129
+ export const getServer = createNextServerLoader(() =>
130
+ createNextServer<AppContext>({
131
+ ports: {},
132
+ routes,
133
+ context: async ({ req }) => {
134
+ // DEMO ONLY: this reads an unauthenticated header to simulate identity.
135
+ // Real applications should verify a signed token or session cookie first.
136
+ return {
137
+ userId: req.headers.get("x-user-id") || "anonymous",
138
+ };
144
139
  },
140
+ mapUnhandledError: () => ({
141
+ status: 500,
142
+ body: {
143
+ code: "INTERNAL_SERVER_ERROR",
144
+ message: "Internal server error",
145
+ },
146
+ }),
145
147
  }),
146
- });
148
+ );
147
149
  ```
148
150
 
149
151
  ### 5. Set up routes
150
152
 
151
153
  Expose ordinary application routes through one catch-all framework route. Next
152
- App Router requires literal named exports for each HTTP method, so assign the
153
- central `server.api` handler to every verb your API should accept:
154
+ App Router imports route modules during production builds, so keep server boot
155
+ behind the memoized `getServer` loader and expose literal named exports with
156
+ `createApiRoute`:
154
157
 
155
158
  ```typescript
156
159
  // app/api/[[...path]]/route.ts
157
- import { server } from "@/server";
158
-
159
- export const DELETE = server.api;
160
- export const GET = server.api;
161
- export const HEAD = server.api;
162
- export const OPTIONS = server.api;
163
- export const PATCH = server.api;
164
- export const POST = server.api;
165
- export const PUT = server.api;
160
+ import { createApiRoute } from "@beignet/next";
161
+ import { getServer } from "@/server";
162
+
163
+ export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
164
+ createApiRoute(getServer);
166
165
  ```
167
166
 
168
167
  This catch-all file is Next.js adapter glue. It forwards requests to the
@@ -179,13 +178,13 @@ as webhooks, redirects, downloads, or adapter-specific glue:
179
178
  ```typescript
180
179
  // app/api/webhooks/payments/route.ts
181
180
  import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
182
- import { server } from "@/server";
181
+ import { getServer } from "@/server";
183
182
  import { createPaymentWebhookRoute } from "@beignet/next";
184
183
 
185
184
  export const runtime = "nodejs";
186
185
 
187
186
  export const { POST } = createPaymentWebhookRoute({
188
- server,
187
+ server: getServer,
189
188
  handle: async ({ ctx, event }) => {
190
189
  await handlePaymentWebhookUseCase.run({ ctx, input: event });
191
190
  return { status: 200, body: { received: true } };
@@ -202,7 +201,7 @@ validates the typed event catalog before your app handles the event.
202
201
 
203
202
  ```typescript
204
203
  export const { POST } = createWebhookRoute({
205
- server,
204
+ server: getServer,
206
205
  webhook: providerWebhook,
207
206
  handle: async ({ ctx, event }) => {
208
207
  await handleProviderEventUseCase.run({ ctx, input: event.payload });
@@ -214,21 +213,27 @@ export const { POST } = createWebhookRoute({
214
213
  For downloads, plain text, and redirects, return a native web `Response`:
215
214
 
216
215
  ```typescript
217
- export const GET = server.route(downloadFile).handle(async () =>
218
- new Response(await loadFile(), {
219
- headers: { "Content-Type": "application/octet-stream" },
220
- }),
221
- );
216
+ import { getServer } from "@/server";
222
217
 
223
- export const GET = server.route(robotsTxt).handle(async () =>
224
- new Response("User-agent: *\nAllow: /\n", {
225
- headers: { "Content-Type": "text/plain; charset=utf-8" },
226
- }),
227
- );
218
+ export async function GET(req: Request) {
219
+ const server = await getServer();
220
+ const handle = server.route(downloadFile).handle(async () =>
221
+ new Response(await loadFile(), {
222
+ headers: { "Content-Type": "application/octet-stream" },
223
+ }),
224
+ );
228
225
 
229
- export const POST = server.route(startCheckout).handle(async () =>
230
- Response.redirect("https://checkout.example.com/session/123", 303),
231
- );
226
+ return handle(req);
227
+ }
228
+
229
+ export async function POST(req: Request) {
230
+ const server = await getServer();
231
+ const handle = server.route(startCheckout).handle(async () =>
232
+ Response.redirect("https://checkout.example.com/session/123", 303),
233
+ );
234
+
235
+ return handle(req);
236
+ }
232
237
  ```
233
238
 
234
239
  Native `Response` instances intentionally bypass JSON serialization and
@@ -262,29 +267,26 @@ Creates a Next.js server instance with the given options.
262
267
 
263
268
  ### NextServer methods
264
269
 
265
- #### `server.api`
270
+ #### `createApiRoute(getServer)`
266
271
 
267
- A Next.js handler for routes registered in `server/index.ts`. Framework-style
268
- apps usually expose it once from a catch-all API route.
272
+ A Next.js catch-all route helper for routes registered in `server/index.ts`.
273
+ Framework-style apps usually expose it once from a catch-all API route.
269
274
 
270
275
  ```typescript
271
276
  // app/api/[[...path]]/route.ts
272
- import { server } from "@/server";
273
-
274
- export const DELETE = server.api;
275
- export const GET = server.api;
276
- export const HEAD = server.api;
277
- export const OPTIONS = server.api;
278
- export const PATCH = server.api;
279
- export const POST = server.api;
280
- export const PUT = server.api;
277
+ import { createApiRoute } from "@beignet/next";
278
+ import { getServer } from "@/server";
279
+
280
+ export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
281
+ createApiRoute(getServer);
281
282
  ```
282
283
 
283
284
  This route file is not a catch-all contract. Keep contract paths explicit and
284
285
  use the file only to expose the central server handler.
285
286
 
286
- Next App Router requires literal named exports for each HTTP method, so assign
287
- the same `server.api` handler to every verb your API should accept.
287
+ Next App Router requires literal named exports for each HTTP method. The route
288
+ helper keeps those exports literal while deferring provider startup until a
289
+ request arrives.
288
290
 
289
291
  #### `server.route(contract)`
290
292
 
@@ -292,25 +294,74 @@ Returns a route builder for focused per-file handlers such as webhooks,
292
294
  redirects, downloads, or other adapter-owned endpoints. Use
293
295
  `defineRouteGroup<AppContext>()({ ... })` plus `defineRoutes(...)` for
294
296
  ordinary application routes; `server.route(contract).handle(...)` route files
295
- are not imported by the central `server.api` handler.
297
+ are not imported by the central API handler.
296
298
 
297
299
  **Returns:** Route builder with:
298
300
  - `handle(fn)`: Create a custom handler function
299
301
 
300
302
  ```typescript
301
303
  // app/api/reports/[id]/download/route.ts
302
- import { server } from "@/server";
304
+ import { getServer } from "@/server";
303
305
  import { downloadReport } from "@/features/reports/contracts";
304
306
 
305
- export const GET = server
306
- .route(downloadReport)
307
- .handle(async ({ ctx, path }) =>
307
+ export async function GET(req: Request) {
308
+ const server = await getServer();
309
+ const handle = server.route(downloadReport).handle(async ({ ctx, path }) =>
308
310
  new Response(await ctx.ports.reports.loadBytes(path.id), {
309
311
  headers: { "Content-Type": "application/pdf" },
310
312
  }),
311
313
  );
314
+
315
+ return handle(req);
316
+ }
312
317
  ```
313
318
 
319
+ #### `server.rawRoute(init)`
320
+
321
+ Builds a handler for a route that cannot be a contract — third-party callback
322
+ endpoints with externally defined request shapes, signature-verified
323
+ webhooks, streaming endpoints — that still runs the whole server pipeline:
324
+ correlation, hooks (rate limiting, idempotency, CORS, logging, error
325
+ reporting), context creation, instrumentation, and framework error mapping.
326
+ Request parsing and validation are skipped and the request body is left
327
+ unconsumed, so the handler owns body reading.
328
+
329
+ `init.name`, `init.method`, and `init.path` identify the route to hooks,
330
+ instrumentation, and devtools — routing itself belongs to the route file
331
+ that mounts the handler. `init.metadata` feeds metadata-driven hooks exactly
332
+ like contract metadata.
333
+
334
+ **Returns:** Builder with `handle(fn)` returning
335
+ `(req: Request) => Promise<Response>`.
336
+
337
+ ```typescript
338
+ // app/api/liveblocks-auth/route.ts
339
+ import { getServer } from "@/server";
340
+
341
+ let roomAuth: ((req: Request) => Promise<Response>) | undefined;
342
+
343
+ export async function POST(req: Request) {
344
+ roomAuth ??= (await getServer())
345
+ .rawRoute({
346
+ name: "collab.roomAuth",
347
+ method: "POST",
348
+ path: "/api/liveblocks-auth",
349
+ metadata: { rateLimit: { max: 300, windowSec: 60, scope: "user" } },
350
+ })
351
+ .handle(async ({ req, ctx }) => {
352
+ const body = await req.text();
353
+ return { status: 200, body: { token: "..." } };
354
+ });
355
+
356
+ return roomAuth(req);
357
+ }
358
+ ```
359
+
360
+ The webhook, payment webhook, schedule, and outbox drain route factories run
361
+ through this pipeline automatically when the `server` they receive exposes
362
+ `rawRoute(...)`; their `pipeline` option supplies the route identity and
363
+ metadata.
364
+
314
365
  #### `server.createContextFromNext()`
315
366
 
316
367
  Creates a context object from Next.js Server Components by automatically extracting headers and cookies. This allows you to call use cases directly from React Server Components without going through API routes.
@@ -319,11 +370,14 @@ Creates a context object from Next.js Server Components by automatically extract
319
370
 
320
371
  ```typescript
321
372
  // app/my-page/page.tsx
322
- import { server } from "@/server";
373
+ import { getServer } from "@/server";
323
374
  import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
324
375
 
376
+ export const dynamic = "force-dynamic";
377
+
325
378
  export default async function MyPage() {
326
379
  // Create context from Next.js headers and cookies
380
+ const server = await getServer();
327
381
  const ctx = await server.createContextFromNext();
328
382
 
329
383
  // Call use case directly
@@ -365,9 +419,11 @@ background work:
365
419
  ```typescript
366
420
  // server/schedules.ts
367
421
  import { createServiceActor } from "@beignet/core/ports";
368
- import { server } from "./index";
422
+ import { getServer } from "./index";
369
423
 
370
424
  export async function createScheduleContext() {
425
+ const server = await getServer();
426
+
371
427
  return server.createServiceContext({
372
428
  actor: createServiceActor("beignet-schedule"),
373
429
  });
@@ -427,7 +483,7 @@ export const GET = server
427
483
  Hooks can be added at the server level:
428
484
 
429
485
  ```typescript
430
- import { createNextServer } from "@beignet/next";
486
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
431
487
  import { createLoggingHooks } from "@beignet/core/server";
432
488
 
433
489
  const logging = createLoggingHooks({
@@ -435,18 +491,20 @@ const logging = createLoggingHooks({
435
491
  requestIdHeader: "x-request-id",
436
492
  });
437
493
 
438
- export const server = await createNextServer({
439
- ports: {},
440
- hooks: [logging],
441
- context: async () => ({}),
442
- mapUnhandledError: () => ({
443
- status: 500,
444
- body: {
445
- code: "INTERNAL_SERVER_ERROR",
446
- message: "Internal server error",
447
- },
494
+ export const getServer = createNextServerLoader(() =>
495
+ createNextServer({
496
+ ports: {},
497
+ hooks: [logging],
498
+ context: async () => ({}),
499
+ mapUnhandledError: () => ({
500
+ status: 500,
501
+ body: {
502
+ code: "INTERNAL_SERVER_ERROR",
503
+ message: "Internal server error",
504
+ },
505
+ }),
448
506
  }),
449
- });
507
+ );
450
508
  ```
451
509
 
452
510
  ## OpenAPI documentation
@@ -459,20 +517,20 @@ docs; request-origin inference is available only when you opt in.
459
517
  // app/api/openapi/route.ts
460
518
  import { createOpenAPIHandler } from "@beignet/next";
461
519
  import { env } from "@/lib/env";
462
- import { server } from "@/server";
520
+ import { contracts } from "@/server/routes";
463
521
 
464
- export const GET = createOpenAPIHandler(server.contracts, {
522
+ export const GET = createOpenAPIHandler(contracts, {
465
523
  title: "My API",
466
524
  version: "1.0.0",
467
525
  servers: [{ url: env.APP_URL }],
468
526
  });
469
527
  ```
470
528
 
471
- `server.contracts` is populated from contracts registered through
472
- `createNextServer({ routes })`. If you export per-file Next handlers with
473
- `server.route(contract).handle(...)`, keep an explicit contract list or exported
474
- route registry for OpenAPI because those route files are not imported by the
475
- server automatically.
529
+ Export `contracts = contractsFromRoutes(routes)` from `server/routes.ts` so the
530
+ OpenAPI route can stay static and avoid booting providers during Next builds.
531
+ If you export per-file Next handlers with `server.route(contract).handle(...)`,
532
+ keep an explicit contract list because those route files are not imported by
533
+ the server automatically.
476
534
 
477
535
  You can also serve Swagger UI without writing the HTML route by hand:
478
536
 
@@ -495,11 +553,14 @@ objects, private objects, invalid keys, and paths outside `basePath` to 404.
495
553
  ```typescript
496
554
  // app/storage/[...key]/route.ts
497
555
  import { createStorageRoute } from "@beignet/next";
498
- import { server } from "@/server";
556
+ import { getServer } from "@/server";
499
557
 
500
- export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
501
- basePath: "/storage",
502
- });
558
+ export const { GET, HEAD } = createStorageRoute(
559
+ async () => (await getServer()).ports.storage,
560
+ {
561
+ basePath: "/storage",
562
+ },
563
+ );
503
564
  ```
504
565
 
505
566
  Served responses preserve object `Content-Type`, `Cache-Control`,
@@ -521,16 +582,18 @@ import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
521
582
  import { createUploadRouter } from "@beignet/core/uploads";
522
583
  import { createUploadRoute } from "@beignet/next";
523
584
  import { postUploads } from "@/features/posts/uploads";
524
- import { server } from "@/server";
585
+ import { getServer } from "@/server";
525
586
 
526
- const uploadRouter = createUploadRouter({
527
- uploads: postUploads,
528
- ctx: () => server.createContextFromNext(),
529
- storage: server.ports.storage,
530
- instrumentation: resolveProviderInstrumentationPort(server.ports),
531
- });
587
+ export const { POST } = createUploadRoute(async () => {
588
+ const server = await getServer();
532
589
 
533
- export const { POST } = createUploadRoute(uploadRouter);
590
+ return createUploadRouter({
591
+ uploads: postUploads,
592
+ ctx: () => server.createContextFromNext(),
593
+ storage: server.ports.storage,
594
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
595
+ });
596
+ });
534
597
  ```
535
598
 
536
599
  The `action` segment must be `prepare`, `upload`, or `complete`.
@@ -539,15 +602,26 @@ The `action` segment must be `prepare`, `upload`, or `complete`.
539
602
 
540
603
  Use `createPaymentWebhookRoute(...)` for billing flows backed by
541
604
  `ctx.ports.payments`. Use `createWebhookRoute(...)` for generic inbound
542
- webhooks backed by a `defineWebhook(...)` catalog and a provider verifier. Both
543
- helpers preserve the raw request body before app code runs.
605
+ webhooks backed by a `defineWebhook(...)` catalog and a provider verifier.
606
+ The `server` option accepts any object exposing `createRequestContext` a
607
+ `NextServer`, a core `ServerInstance`, or a test fake — so
608
+ `server: getServer` keeps working unchanged.
609
+
610
+ When the server also exposes `rawRoute(...)` — real Beignet servers do — the
611
+ route runs inside the full hooks pipeline: rate limiting, CORS, logging,
612
+ error reporting, and instrumentation apply, the request appears in devtools,
613
+ and the `pipeline` option supplies the route identity and metadata for
614
+ metadata-driven hooks. The raw body stays unconsumed until the route reads
615
+ it, so signature verification still sees the exact bytes. Minimal test fakes
616
+ without `rawRoute` keep the direct flow: raw body first, then app context
617
+ through `server.createRequestContext(...)`.
544
618
 
545
619
  ```typescript
546
620
  // app/api/webhooks/github/route.ts
547
621
  import { githubWebhook } from "@/features/integrations/webhooks";
548
622
  import { handleGitHubWebhookUseCase } from "@/features/integrations/use-cases";
549
623
  import { env } from "@/lib/env";
550
- import { server } from "@/server";
624
+ import { getServer } from "@/server";
551
625
  import { createWebhookRoute } from "@beignet/next";
552
626
  import { createGitHubWebhookVerifier } from "@beignet/provider-webhooks-github";
553
627
 
@@ -558,7 +632,7 @@ const githubWebhookVerifier = createGitHubWebhookVerifier({
558
632
  });
559
633
 
560
634
  export const { POST } = createWebhookRoute({
561
- server,
635
+ server: getServer,
562
636
  webhook: githubWebhook,
563
637
  verify: ({ input }) => githubWebhookVerifier.verify(input),
564
638
  handle: async ({ ctx, event }) => {
@@ -571,9 +645,10 @@ export const { POST } = createWebhookRoute({
571
645
  });
572
646
  ```
573
647
 
574
- Verification failures return 400 so providers do not treat an invalid signature
575
- as a fulfilled event. Handler failures return 500 so at-least-once webhook
576
- providers can retry.
648
+ Body read failures return 400 before context creation runs. Verification
649
+ failures return 400 so providers do not treat an invalid signature as a
650
+ fulfilled event. Context creation failures return 500. Handler failures return
651
+ 500 so at-least-once webhook providers can retry.
577
652
 
578
653
  Use provider verifiers such as `createGitHubWebhookVerifier(...)` from
579
654
  `@beignet/provider-webhooks-github` or `createStripeWebhookVerifier(...)` from
@@ -589,23 +664,24 @@ billing flows and the route generated by `beignet make payments`.
589
664
  ## Outbox drain routes
590
665
 
591
666
  Use `createOutboxDrainRoute` to expose durable outbox delivery from a cron or
592
- scheduled serverless route. The helper requires a bearer secret, drains one
593
- bounded batch with `@beignet/core/outbox`, records a drain summary into the
594
- instrumentation port resolved from `ctx.ports` (`ports.instrumentation`, then
595
- `ports.devtools`), passes request correlation fields to outbox
667
+ scheduled serverless route. The helper requires a bearer secret, builds app
668
+ context from the real request with `server.createRequestContext(...)`, drains
669
+ one bounded batch with `@beignet/core/outbox`, records a drain summary into
670
+ the instrumentation port resolved from `ctx.ports` (`ports.instrumentation`,
671
+ then `ports.devtools`), passes request correlation fields to outbox
596
672
  instrumentation, and returns a JSON summary.
597
673
 
598
674
  ```typescript
599
675
  // app/api/cron/outbox/drain/route.ts
600
676
  import { createOutboxDrainRoute } from "@beignet/next";
601
677
  import { env } from "@/lib/env";
602
- import { server } from "@/server";
678
+ import { getServer } from "@/server";
603
679
  import { outboxRegistry } from "@/server/outbox";
604
680
 
605
681
  export const runtime = "nodejs";
606
682
 
607
683
  export const { GET, POST } = createOutboxDrainRoute({
608
- server,
684
+ server: getServer,
609
685
  registry: outboxRegistry,
610
686
  secret: env.CRON_SECRET,
611
687
  batchSize: 100,
@@ -631,9 +707,10 @@ serverless apps.
631
707
  ## Schedule trigger routes
632
708
 
633
709
  Use `createScheduleRoute` to trigger one registered schedule from a cron or
634
- scheduled serverless route. The helper requires a bearer secret, creates app
635
- context with `server.createContextFromNext()`, runs the schedule with the
636
- inline runner from `@beignet/core/schedules`, and records `schedule` events
710
+ scheduled serverless route. The helper requires a bearer secret, builds app
711
+ context from the real request with `server.createRequestContext(...)`, runs
712
+ the schedule with the inline runner from `@beignet/core/schedules`, and
713
+ records `schedule` events
637
714
  through the instrumentation port resolved from `ctx.ports`
638
715
  (`ports.instrumentation`, then `ports.devtools`) with the request's
639
716
  correlation fields.
@@ -642,13 +719,13 @@ correlation fields.
642
719
  // app/api/cron/digests/daily-digest/route.ts
643
720
  import { createScheduleRoute } from "@beignet/next";
644
721
  import { env } from "@/lib/env";
645
- import { server } from "@/server";
722
+ import { getServer } from "@/server";
646
723
  import { schedules } from "@/server/schedules";
647
724
 
648
725
  export const runtime = "nodejs";
649
726
 
650
727
  export const { GET, POST } = createScheduleRoute({
651
- server,
728
+ server: getServer,
652
729
  schedules,
653
730
  schedule: "digests.send-daily",
654
731
  secret: env.CRON_SECRET,
@@ -667,6 +744,62 @@ through `ctx.ports.logger` and return a 500 so schedule providers can retry.
667
744
  `source` defaults to `"next-cron-route"` and is recorded on run metadata and
668
745
  devtools events.
669
746
 
747
+ ## Testing route files
748
+
749
+ The webhook, payment webhook, schedule, and outbox drain route factories build
750
+ app context from the incoming request, not from `next/headers`, so route
751
+ modules execute under `bun test` with a plain `Request`. Import the route
752
+ module directly and call the exported handler:
753
+
754
+ ```typescript
755
+ // app/api/webhooks/github/route.test.ts
756
+ import { expect, it } from "bun:test";
757
+ import { POST } from "./route";
758
+
759
+ it("rejects unsigned webhook deliveries", async () => {
760
+ const response = await POST(
761
+ new Request("http://localhost/api/webhooks/github", {
762
+ method: "POST",
763
+ body: JSON.stringify({ action: "opened" }),
764
+ }),
765
+ );
766
+
767
+ expect(response.status).toBe(400);
768
+ });
769
+ ```
770
+
771
+ To test a route factory against controlled context without booting the real
772
+ server, pass any object exposing `createRequestContext` as the `server`
773
+ option (`NextRouteServer<Ctx>`):
774
+
775
+ ```typescript
776
+ import { expect, it } from "bun:test";
777
+ import { createScheduleRoute } from "@beignet/next";
778
+ import { schedules } from "@/server/schedules";
779
+
780
+ it("triggers the daily digest schedule", async () => {
781
+ const { POST } = createScheduleRoute({
782
+ schedules,
783
+ schedule: "digests.send-daily",
784
+ secret: "test-secret",
785
+ server: {
786
+ async createRequestContext() {
787
+ return createTestScheduleContext();
788
+ },
789
+ },
790
+ });
791
+
792
+ const response = await POST(
793
+ new Request("http://localhost/api/cron/digests/daily-digest", {
794
+ method: "POST",
795
+ headers: { authorization: "Bearer test-secret" },
796
+ }),
797
+ );
798
+
799
+ expect(response.status).toBe(200);
800
+ });
801
+ ```
802
+
670
803
  ## Client creation
671
804
 
672
805
  Use `createClient` from `@beignet/core/client` for modules under `client/` or
@@ -708,7 +841,7 @@ Beignet supports the normal App Router split:
708
841
  - Server Components can call use cases directly with `server.createContextFromNext()`.
709
842
  - Server Components can prefetch contract queries and hydrate Client Components with TanStack Query.
710
843
  - Client Components use `createClient()` plus React Query options for interactive server state.
711
- - Route handlers stay thin and expose `server.api` or focused helpers such as OpenAPI, uploads, storage, devtools, and outbox drains.
844
+ - Route handlers stay thin and use `createApiRoute(getServer)` or focused helpers such as OpenAPI, uploads, storage, devtools, and outbox drains.
712
845
 
713
846
  ### Server Component use-case calls
714
847
 
@@ -717,9 +850,11 @@ browser cache for the result:
717
850
 
718
851
  ```typescript
719
852
  // app/posts/[slug]/page.tsx
720
- import { server } from "@/server";
853
+ import { getServer } from "@/server";
721
854
  import { getPostUseCase } from "@/features/posts/use-cases/get-post";
722
855
 
856
+ export const dynamic = "force-dynamic";
857
+
723
858
  type PageProps = {
724
859
  params: Promise<{
725
860
  slug: string;
@@ -728,6 +863,7 @@ type PageProps = {
728
863
 
729
864
  export default async function Page({ params }: PageProps) {
730
865
  const { slug } = await params;
866
+ const server = await getServer();
731
867
  const ctx = await server.createContextFromNext();
732
868
  const post = await getPostUseCase.run({
733
869
  ctx,
@@ -783,33 +919,35 @@ handlers. Await them before building contract path or query params.
783
919
  Providers are service adapters that implement ports (database, cache, logger, etc.):
784
920
 
785
921
  ```typescript
786
- import { createNextServer } from "@beignet/next";
922
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
787
923
  import { createDrizzleSqliteProvider } from "@beignet/provider-db-drizzle/sqlite";
788
924
  import { loggerPinoProvider } from "@beignet/provider-logger-pino";
789
925
  import * as schema from "@/db/schema";
790
926
 
791
927
  const drizzleSqliteProvider = createDrizzleSqliteProvider({ schema });
792
928
 
793
- export const server = await createNextServer({
794
- ports: {},
795
- providers: [
796
- drizzleSqliteProvider,
797
- loggerPinoProvider,
798
- ],
799
- providerEnv: process.env,
800
- context: async ({ ports }) => ({
801
- // Access providers via ports
802
- db: ports.db,
803
- logger: ports.logger,
804
- }),
805
- mapUnhandledError: () => ({
806
- status: 500,
807
- body: {
808
- code: "INTERNAL_SERVER_ERROR",
809
- message: "Internal server error",
810
- },
929
+ export const getServer = createNextServerLoader(() =>
930
+ createNextServer({
931
+ ports: {},
932
+ providers: [
933
+ drizzleSqliteProvider,
934
+ loggerPinoProvider,
935
+ ],
936
+ providerEnv: process.env,
937
+ context: async ({ ports }) => ({
938
+ // Access providers via ports
939
+ db: ports.db,
940
+ logger: ports.logger,
941
+ }),
942
+ mapUnhandledError: () => ({
943
+ status: 500,
944
+ body: {
945
+ code: "INTERNAL_SERVER_ERROR",
946
+ message: "Internal server error",
947
+ },
948
+ }),
811
949
  }),
812
- });
950
+ );
813
951
  ```
814
952
 
815
953
  ## Error handling
@@ -817,20 +955,24 @@ export const server = await createNextServer({
817
955
  ### Global error handler
818
956
 
819
957
  ```typescript
820
- export const server = await createNextServer({
821
- ports: {},
822
- context: async () => ({}),
823
- mapUnhandledError: ({ err }) => {
824
- console.error("Unhandled error:", err);
825
- return {
826
- status: 500,
827
- body: {
828
- code: "INTERNAL_SERVER_ERROR",
829
- message: "Internal server error",
830
- },
831
- };
832
- },
833
- });
958
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
959
+
960
+ export const getServer = createNextServerLoader(() =>
961
+ createNextServer({
962
+ ports: {},
963
+ context: async () => ({}),
964
+ mapUnhandledError: ({ err }) => {
965
+ console.error("Unhandled error:", err);
966
+ return {
967
+ status: 500,
968
+ body: {
969
+ code: "INTERNAL_SERVER_ERROR",
970
+ message: "Internal server error",
971
+ },
972
+ };
973
+ },
974
+ }),
975
+ );
834
976
  ```
835
977
 
836
978
  `mapUnhandledError` response bodies are sent to clients. Use `onCaughtError`
@@ -875,9 +1017,9 @@ Creates a Next.js route handler that returns an OpenAPI 3.1 JSON document. Requi
875
1017
  Pass `servers` for deployed docs. If `servers` is omitted, no OpenAPI
876
1018
  `servers` entry is generated unless `inferServersFromRequest: true` is set.
877
1019
 
878
- When you use central route registration, prefer `server.contracts` or
879
- `contractsFromRoutes(routes)` so OpenAPI is generated from the same route list
880
- used by the runtime.
1020
+ When you use central route registration, export `contractsFromRoutes(routes)`
1021
+ from `server/routes.ts` so OpenAPI is generated from the same route list used
1022
+ by the runtime without booting providers during route-module import.
881
1023
 
882
1024
  ### `createSwaggerUIHandler(options?): (req: Request) => Response`
883
1025
 
@@ -885,17 +1027,20 @@ Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
885
1027
 
886
1028
  ### `createWebhookRoute(options): { POST }`
887
1029
 
888
- Creates a generic Next.js webhook route. The route reads the raw body, verifies
889
- the event through a webhook definition or context-aware verifier, validates the
890
- matching event payload schema, and delegates the event to your app-owned
891
- handler.
1030
+ Creates a generic Next.js webhook route. The route reads the raw body, builds
1031
+ app context from the real request with `server.createRequestContext(...)`,
1032
+ verifies the event through a webhook definition or context-aware verifier,
1033
+ validates the matching event payload schema, and delegates the event to your
1034
+ app-owned handler.
892
1035
 
893
1036
  ### `createPaymentWebhookRoute(options): { POST }`
894
1037
 
895
- Creates a Next.js payment webhook route. The route reads the raw body, verifies
896
- the provider signature through `ctx.ports.payments.verifyWebhook(...)`, and
897
- delegates the normalized event to your app-owned handler. Prefer
898
- `createWebhookRoute(...)` for new non-payment webhook integrations.
1038
+ Creates a Next.js payment webhook route. The route reads the raw body, builds
1039
+ app context from the real request with `server.createRequestContext(...)`,
1040
+ verifies the provider signature through
1041
+ `ctx.ports.payments.verifyWebhook(...)`, and delegates the normalized event to
1042
+ your app-owned handler. Prefer `createWebhookRoute(...)` for new non-payment
1043
+ webhook integrations.
899
1044
 
900
1045
  ### `toRequestLike(req: Request): HttpRequestLike`
901
1046
 
@@ -995,66 +1140,66 @@ export const contracts = contractsFromRoutes(routes);
995
1140
 
996
1141
  ```typescript
997
1142
  // server/index.ts
998
- import { createNextServer } from "@beignet/next";
1143
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
999
1144
  import type { AppContext } from "@/app-context";
1000
1145
  import { routes } from "@/server/routes";
1001
1146
 
1002
- export const server = await createNextServer<AppContext>({
1003
- ports: {},
1004
- routes,
1005
- context: async () => ({ todos: [] }),
1006
- mapUnhandledError: () => ({
1007
- status: 500,
1008
- body: {
1009
- code: "INTERNAL_SERVER_ERROR",
1010
- message: "Internal server error",
1011
- },
1147
+ export const getServer = createNextServerLoader(() =>
1148
+ createNextServer<AppContext>({
1149
+ ports: {},
1150
+ routes,
1151
+ context: async () => ({ todos: [] }),
1152
+ mapUnhandledError: () => ({
1153
+ status: 500,
1154
+ body: {
1155
+ code: "INTERNAL_SERVER_ERROR",
1156
+ message: "Internal server error",
1157
+ },
1158
+ }),
1012
1159
  }),
1013
- });
1160
+ );
1014
1161
  ```
1015
1162
 
1016
1163
  ```typescript
1017
1164
  // app/api/[[...path]]/route.ts
1018
- import { server } from "@/server";
1019
-
1020
- export const DELETE = server.api;
1021
- export const GET = server.api;
1022
- export const HEAD = server.api;
1023
- export const OPTIONS = server.api;
1024
- export const PATCH = server.api;
1025
- export const POST = server.api;
1026
- export const PUT = server.api;
1165
+ import { createApiRoute } from "@beignet/next";
1166
+ import { getServer } from "@/server";
1167
+
1168
+ export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
1169
+ createApiRoute(getServer);
1027
1170
  ```
1028
1171
 
1029
1172
  ### With authentication
1030
1173
 
1031
1174
  ```typescript
1032
1175
  // server/index.ts
1033
- import { createNextServer } from "@beignet/next";
1176
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
1034
1177
  import { AuthUnauthorizedError } from "@beignet/core/ports";
1035
1178
  import { getTodo } from "@/features/todos/contracts";
1036
1179
 
1037
- export const server = await createNextServer({
1038
- ports: {},
1039
- context: async ({ req }) => {
1040
- const user = await getUserFromRequest(req);
1180
+ export const getServer = createNextServerLoader(() =>
1181
+ createNextServer({
1182
+ ports: {},
1183
+ context: async ({ req }) => {
1184
+ const user = await getUserFromRequest(req);
1041
1185
 
1042
- if (!user) {
1043
- throw new AuthUnauthorizedError();
1044
- }
1186
+ if (!user) {
1187
+ throw new AuthUnauthorizedError();
1188
+ }
1045
1189
 
1046
- return { user };
1047
- },
1048
- mapUnhandledError: () => {
1049
- return {
1050
- status: 500,
1051
- body: {
1052
- code: "INTERNAL_SERVER_ERROR",
1053
- message: "Internal server error",
1054
- },
1055
- };
1056
- },
1057
- });
1190
+ return { user };
1191
+ },
1192
+ mapUnhandledError: () => {
1193
+ return {
1194
+ status: 500,
1195
+ body: {
1196
+ code: "INTERNAL_SERVER_ERROR",
1197
+ message: "Internal server error",
1198
+ },
1199
+ };
1200
+ },
1201
+ }),
1202
+ );
1058
1203
  ```
1059
1204
 
1060
1205
  ### Server component usage
@@ -1063,11 +1208,14 @@ You can call use cases directly from React Server Components using `createContex
1063
1208
 
1064
1209
  ```typescript
1065
1210
  // app/todos/page.tsx
1066
- import { server } from "@/server";
1211
+ import { getServer } from "@/server";
1067
1212
  import { listTodosUseCase } from "@/features/todos/use-cases/list-todos";
1068
1213
 
1214
+ export const dynamic = "force-dynamic";
1215
+
1069
1216
  export default async function TodosPage() {
1070
1217
  // Create context from Next.js runtime
1218
+ const server = await getServer();
1071
1219
  const ctx = await server.createContextFromNext();
1072
1220
 
1073
1221
  // Call use case directly - no API route needed!