---
title: "Framework adapters"
description: "Wire MainSDK into Next.js, Hono or Express with one import, so every request gets a trace, a context, ordered hooks, problem-detail errors and the sign-in routes."
section: "interfaces"
status: "Building for 0.2 · ships as a beta API"
spec: "adapters"
canonical: "https://mainsdk.dev/docs/interfaces/adapters"
updated: "2026-10-28"
---

# Framework adapters

> Wire MainSDK into Next.js, Hono or Express with one import, so every request gets a trace, a context, ordered hooks, problem-detail errors and the sign-in routes.

Status: Building for 0.2 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/adapters · Updated: 2026-10-28

An adapter connects MainSDK to the web framework you already run. One line in your server gives every request a trace, a request context, ordered hooks and problem-detail errors.

> **Note:** Adapters are `beta` and run from the repository. Next.js, Hono, Express, the Cloudflare Workers entry and the Node worker adapter are built; the Prisma and Drizzle integration is built against the shape of the clients, so neither ORM is a dependency of MainSDK. Market overlays (`main.<market>.yaml`) are built; segment, target and locale overlays are not.

## At a glance

- Status: Building for 0.2 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.2 Core loop
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/adapters.md
- Category: Developer experience

Calls:

- `mainsdk.proxy()`
- `withMainSDK(handler)`
- `mainsdkAction(handler)`
- `mainsdk()`
- `mainsdk.errors()`
- `worker({ fetch })`
- `worker(handler)`
- `withPrisma(client)`
- `withDrizzle(db)`

In main.yaml:

```yaml

```

Guarantees, whatever the driver:

- Every request carries a trace, tenant, user, agent and region through AsyncLocalStorage
- Hooks run in a fixed order and a failing hook never changes the answer
- Errors become RFC 9457 problems and unknown errors never leak detail
- Health probes, shutdown draining and inbound protection stay in front of your handlers
- No peer dependency on the framework and no network call to MainSDK Cloud
- A market overlay answers config.get for the market of the request, with no redeploy
- A Prisma or Drizzle query is decided by the access policy and audited before it runs


## Drivers

Pick one in main.yaml with `adapters.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Next.js App Router | `next` | yes |  |
| Hono | `hono` |  |  |
| Express | `express` |  |  |
| Cloudflare Workers | `cloudflare` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-DATA-0001` | check | Raw database client used outside MainSDK |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Request context and trace propagation | 1 | 0.2 | `adapters#request-context` |
| Ordered request hooks | 1 | 0.2 | `adapters#lifecycle` |
| Next.js adapter | 1 | 0.2 | `adapters#next-adapter` |
| Hono adapter | 1 | 0.2 | `adapters#hono-adapter` |
| Express adapter | 1 | 0.2 | `adapters#express-adapter` |
| Node worker adapter | 1 | 0.2 | `adapters#worker-adapter` |
| Schedule tick route for serverless deploys | 1 | 0.2 | `adapters#schedule-tick` |
| Cloudflare Workers entry | 1 | 0.2 | `adapters#cloudflare-adapter` |
| Prisma and Drizzle integration | 1 | 0.2 | `adapters#orm-integration` |
| Adapter overhead budgets | 1 | 0.2 | `adapters#adapter-budgets` |
| Driver marketplace with conformance | 3 | 1.2 | `driver-marketplace` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```ts title="server.ts"
import { Hono } from "hono";
import { mainsdk } from "mainsdk/hono";

const app = new Hono();
app.use("*", mainsdk());
app.get("/hello", (c) => c.json({ ok: true }));
```

Call `/hello` with `traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01` and every log record the route writes carries that trace id. Without the header the adapter starts a new one. Inside any handler, `requestContext()` from `mainsdk/adapters` returns the context.

## Configuration

```yaml title="main.yaml"
schema: 1
project: my-app
auth:
  routes: { base: /_mainsdk/auth }
```

## What you get

An adapter connects MainSDK to the web framework you already run. One line in your server gives every request a trace, a request context (tenant, user, agent, region), health probes, shutdown draining, inbound protection, ordered hooks and RFC 9457 errors. It also mounts the browser sign-in routes when `auth` is configured. Adapters add no dependency: they read the framework's request and response shapes, so nothing is installed next to your framework.

## Next.js, Hono and Express

```ts title="app/api/orders/route.ts (Next.js App Router)"
import { withMainSDK } from "mainsdk/next";

export const GET = withMainSDK(async (req, ctx) => Response.json({ trace: ctx.traceId }));
```

```ts title="proxy.ts (middleware.ts on Next.js 15)"
import { mainsdk } from "mainsdk/next";

export const proxy = mainsdk.proxy();
```

```ts title="app/actions.ts (server action)"
import { mainsdkAction } from "mainsdk/next";

export const save = mainsdkAction(async (input: { name: string }, ctx) => ({ saved: input.name }));
```

```ts title="server.ts (Express)"
import express from "express";
import { mainsdk } from "mainsdk/express";

const app = express();
app.use(mainsdk());
app.get("/hello", (_req, res) => res.json({ ok: true }));
app.use(mainsdk.errors());     // last: turns a thrown error into a problem
```

## Cloudflare Workers

The `main = "src/worker.ts"` that `mainsdk deploy config` writes into `wrangler.toml` imports the Workers entry. `fetch` runs through the same layers as the other adapters; `scheduled` runs every schedule whose cron matches the event, in process, with no HTTP call.

```ts title="src/worker.ts"
import { Hono } from "hono";
import { worker } from "mainsdk/cloudflare";

const app = new Hono();
app.get("/hello", (c) => c.json({ ok: true }));

export default worker({ fetch: app.fetch });
```

## Schedules on serverless

When `deploy` is in `main.yaml`, every adapter answers `POST /_mainsdk/jobs/tick?schedule=<name>` itself, with the bearer in `deploy.tick.secret`, so Vercel cron can wake a schedule. Without a `deploy` section the path reaches your own routes.

## Job workers

```ts title="jobs/report.ts"
import { worker } from "mainsdk/worker";

declare function buildReport(orderId: string): Promise<void>;

export default worker(async (input: { orderId: string }, ctx) => {
  // ctx.traceId is the producer's trace; ctx.user.id is a pseudonym; ctx.retry is 0 on the first attempt
  await buildReport(input.orderId);
});
```

Register it with `jobs.on("report", mod.default)`. Every log record of the attempt carries the producer's `trace_id`, `user.id` and `retry`. `buildReport` is your own function.

## Market overlays

`main.<market>.yaml` holds only what differs in one market. The adapter reads the market of each request from `resolve` (or a `context` hook) and `config.get` answers from that market's overlay, so one deployment serves every market. Files are read at start and again on `config.refresh()`; a broken file keeps the last good overlay. A pushed value still wins over an overlay, and an overlay wins over `config.values`.

```ts title="app/actions.ts"
import { config } from "mainsdk";
import { mainsdkAction } from "mainsdk/next";
import { headers } from "next/headers";

export const pay = mainsdkAction(
  async () => config.get("payments.driver"), // "razorpay" in market in, from main.in.yaml
  { headers, resolve: (req) => ({ market: req.headers.get("x-market") ?? undefined }) },
);
```

## Prisma and Drizzle

Wrap the client once. Every query is decided by `access` for the person in the request context before it runs, and the decision is audited. A denied query never reaches the database and answers `MAIN-ACCESS-0001` (403).

```ts title="src/db.ts"
import { PrismaClient } from "@prisma/client";
import { withPrisma } from "mainsdk/prisma";
import { withDrizzle } from "mainsdk/drizzle";

declare const pool: object;                      // your own connection pool
declare function drizzle(pool: object): object;  // from your own drizzle driver

export const prisma = withPrisma(new PrismaClient());   // orders.read, orders.create, orders.update, orders.delete
export const db = withDrizzle(drizzle(pool));           // same actions, per table
```

The action is `<model or table>.<read|create|update|delete>`; a raw query is `db.raw`. An operation the wrapper does not know is decided as `<model>.<operation>`, so it is denied unless a role grants it. Pass `subject` when roles do not come from the tenant. `mainsdk check` reports a raw client with `MAIN-DATA-0001`. `drizzle` and `pool` are your own.

## Hooks

Pass `hooks` to any adapter. They run in a fixed order: `context`, `before`, your handler, `after`, then `error` (only when something threw) and `finally`. A failing `before` skips the handler and runs `error` and `finally`. A hook that throws is logged and never changes the answer.

```ts
import { Hono } from "hono";
import { mainsdk } from "mainsdk/hono";

declare const metrics: { done(traceId: string): void };
declare function warmCaches(): Promise<void>;
declare function flushQueues(): Promise<void>;

const app = new Hono();

app.use("*", mainsdk({
  hooks: {
    context: (ctx, req) => ({ segment: req.headers.get("x-segment") ?? undefined }),
    finally: (ctx) => metrics.done(ctx.traceId),
  },
  startup: async () => warmCaches(),     // once, before /readyz says ready
  shutdown: async () => flushQueues(),   // after draining, before exit
}));
```

`metrics`, `warmCaches` and `flushQueues` are your own.

## Errors

A `MainError` thrown in a handler is answered as `application/problem+json` with its `code`, `fix` and `trace_id`. Any other error becomes `MAIN-CORE-0500` with no message, path or stack. Probes (`/healthz`, `/readyz`), the draining 503 and load shedding come first, so a failing handler never hides health.

## Sign-in routes

When `auth` is configured, every adapter answers the routes of the [browser client](https://mainsdk.dev/docs/interfaces/auth) under `auth.routes.base` (default `/_mainsdk/auth`). Set `authRoutes: false` to keep them off. Routes for a method missing from `auth.methods` answer 404 `MAIN-AUTH-0084`. See the guide [Add sign-in to a page](https://mainsdk.dev/docs/guides/sign-in-page).

## Calls

| Call | What it does |
| --- | --- |
| `mainsdk()` | Hono or Express middleware (`mainsdk/hono`, `mainsdk/express`) |
| `mainsdk.errors()` | Express error handler, mounted last |
| `withMainSDK(handler)` | Next.js route handler wrapper |
| `mainsdkAction(handler)` | Next.js server action wrapper |
| `mainsdk.proxy()` | Next.js `proxy.ts` or `middleware.ts` |
| `worker({ fetch })` | Cloudflare Workers entry (`mainsdk/cloudflare`) with `fetch` and `scheduled` |
| `worker(handler)` | Job consumer wrapper (`mainsdk/worker`) |
| `withPrisma(client)` | Prisma client whose queries are decided and audited (`mainsdk/prisma`) |
| `withDrizzle(db)` | Drizzle database whose queries are decided and audited (`mainsdk/drizzle`) |

## Errors and checks

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-CORE-0500` | A handler threw an error that is not a `MainError` | Read the log record with the same trace id |
| `MAIN-ACCESS-0001` | The policy denies the query a wrapped Prisma or Drizzle client made | Grant the action to the role, or stop the query |
| `MAIN-DATA-0001` | `check` found a Prisma or Drizzle client used without its wrapper | Wrap it with `withPrisma` or `withDrizzle` |
| `MAIN-AUTH-0080` | A sign-in route got the wrong method or body | Call it as the guide shows |
| `MAIN-AUTH-0084` | The route belongs to a method `auth.methods` does not list | Add the method or stop calling the route |
| `MAIN-WEB-0002` | A cross-site POST to a sign-in route | Post from your own origin |

## Swapping drivers

The drivers are the frameworks: Next.js is the default, then Hono, Express and Cloudflare Workers. Each is its own import (`mainsdk/next`, `mainsdk/hono`, `mainsdk/express`, `mainsdk/cloudflare`) and they behave alike, so moving a server from one to another changes the import and the wiring line, not your handlers or your `main.yaml`.

## Testing

`createTestApp()` gives the adapter an app with in-memory drivers, and the handler takes a standard `Request`, so no framework needs to run. Pass the test app as `app` and assert on the answer and on `app.log.records`.

```ts title="hello.test.ts"
import { createTestApp } from "@mainsdk/testing";
import { withMainSDK } from "mainsdk/next";

const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\n" });
const handler = withMainSDK(async () => Response.json({ ok: true }), { app });
const res = await handler(new Request("http://app.test/hello"));   // res.status is 200
```

## Next

- [Browser sign-in](https://mainsdk.dev/docs/interfaces/auth): the routes every adapter mounts.
- [Logs](https://mainsdk.dev/docs/interfaces/log): the records that carry the trace id.
- [Add sign-in to a page](https://mainsdk.dev/docs/guides/sign-in-page)

## For agents

```text
Goal: wire MainSDK into this server framework.
Touch: the server entry file (one app.use or one exported proxy) and, for Next.js, the route files you want wrapped.
Do not: write your own trace, error or health handling next to the adapter, or install a framework package for it.
Verify: call a route with a traceparent header and check the log record; npx mainsdk check.
Stop and ask if: the framework is not Next.js, Hono or Express.
```
