MainSDK
Search⌘K
Interfaces

Developer experience

Framework adapters

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.

Building for 0.2ships as a beta APIPhase 1 · Core, public betaSpec adaptersFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

At a glance

Calls

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

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

4 drivers. Select one to see its line in main.yaml.

main.yamlNext.js App Router (default)
adapters:
  driver: next

Change this one line to swap providers; your calls stay the same.Next.js App Router integration

Codes

CodeWhere it appearsMeaning
MAIN-DATA-0001Checkmainsdk check, before deployRaw database client used outside MainSDK

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Request context and trace propagationPhase 10.2adapters#request-context
Ordered request hooksPhase 10.2adapters#lifecycle
Next.js adapterPhase 10.2adapters#next-adapter
Hono adapterPhase 10.2adapters#hono-adapter
Express adapterPhase 10.2adapters#express-adapter
Node worker adapterPhase 10.2adapters#worker-adapter
Schedule tick route for serverless deploysPhase 10.2adapters#schedule-tick
Cloudflare Workers entryPhase 10.2adapters#cloudflare-adapter
Prisma and Drizzle integrationPhase 10.2adapters#orm-integration
Adapter overhead budgetsPhase 10.2adapters#adapter-budgets
Driver marketplace with conformancePhase 31.2driver-marketplace

Quick start

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

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

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 }));
proxy.ts (middleware.ts on Next.js 15)
import { mainsdk } from "mainsdk/next";

export const proxy = mainsdk.proxy();
app/actions.ts (server action)
import { mainsdkAction } from "mainsdk/next";

export const save = mainsdkAction(async (input: { name: string }, ctx) => ({ saved: input.name }));
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.

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

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.

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).

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 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.

Calls

CallWhat 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

CodeCauseFix
MAIN-CORE-0500A handler threw an error that is not a MainErrorRead the log record with the same trace id
MAIN-ACCESS-0001The policy denies the query a wrapped Prisma or Drizzle client madeGrant the action to the role, or stop the query
MAIN-DATA-0001check found a Prisma or Drizzle client used without its wrapperWrap it with withPrisma or withDrizzle
MAIN-AUTH-0080A sign-in route got the wrong method or bodyCall it as the guide shows
MAIN-AUTH-0084The route belongs to a method auth.methods does not listAdd the method or stop calling the route
MAIN-WEB-0002A cross-site POST to a sign-in routePost 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.

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

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.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet