Tulip Logo IconTulip
Router

Router

App-local oRPC setup and Tulip query client helpers.

Tulip's router setup has two jobs:

  • keep oRPC context, middleware, and route handlers local to each app
  • use shared React Query helpers for router-driven client state

Keep the rule simple:

  • @orpc/server gives you the base RPC builder and server helpers
  • @tulip-systems/query/tanstack-query gives you shared query client helpers

Server API

Use os.$context to create your app's base RPC builder.

import { os } from "@orpc/server";
import { auth } from "../auth/init";
import { db } from "../db/init";

export type RPCContext = {
  db: typeof db;
  auth: typeof auth;
  headers: Headers;
};

export const rpc = os.$context<RPCContext>();

export function createContext<TExtendedContext>(context: TExtendedContext & { headers: Headers }) {
  return { db, auth, ...context } as RPCContext & TExtendedContext;
}

This base builder should stay close to your app. Tulip intentionally does not force a global protectedProcedure or permission middleware policy from core.

Use three files in your app:

  1. server/router/init.ts
  2. server/router/middleware.ts
  3. server/router/procedures.ts

init.ts

import "server-cli-only";

import { os } from "@orpc/server";
import { auth } from "../auth/init";
import { db } from "../db/init";

export type RPCContext = {
  db: typeof db;
  auth: typeof auth;
  headers: Headers;
};

export const rpc = os.$context<RPCContext>();

export function createContext<TExtendedContext>(context: TExtendedContext & { headers: Headers }) {
  return { db, auth, ...context } as RPCContext & TExtendedContext;
}

middleware.ts

Define auth and permission middleware in the app, where the concrete auth options and access control are known.

import { os } from "@orpc/server";
import type { Permission } from "@tulip-systems/auth/permissions";
import { ORPCError } from "@orpc/server";
import type { AuthServerOptions } from "../auth/init";
import type { AccessControl } from "../auth/permissions";
import { type RPCContext, rpc } from "./init";

export const sessionMiddleware = rpc.middleware(async ({ next, context }) => {
  const data = await context.auth.api.getSession({ headers: context.headers });

  if (!data?.session || !data?.user) {
    throw new ORPCError("UNAUTHORIZED", {
      message: "Jou hebt geen toegang om deze actie uit te voeren",
    });
  }

  return next({ context: { session: data.session, user: data.user } });
});

type ProtectedContext = RPCContext & {
  session: NonNullable<Awaited<ReturnType<typeof context.auth.api.getSession>>>["session"];
  user: NonNullable<Awaited<ReturnType<typeof context.auth.api.getSession>>>["user"];
};

export function permissionMiddleware(permissions: Permission<AccessControl>) {
  return os.$context<ProtectedContext>().middleware(async ({ next, context }) => {
    const { success, error } = await context.auth.api.userHasPermission({
      headers: context.headers,
      body: {
        userId: context.user.id,
        permissions,
      },
    });

    if (error || !success) {
      throw new ORPCError("UNAUTHORIZED", {
        message: "Jou hebt geen toegang om deze actie uit te voeren",
      });
    }

    return next({ context });
  });
}

procedures.ts

Compose your procedure presets locally.

import { rpc } from "./init";
import { sessionMiddleware } from "./middleware";

export const publicProcedure = rpc;
export const protectedProcedure = rpc.use(sessionMiddleware);

This pattern keeps core small, preserves strong app inference, and makes it easy to add later middleware for OAuth, scopes, or feature-specific policies.

Route handlers

For app RPC routes, use RPCHandler directly.

import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import { createContext } from "@/server/router/init";
import { appRouter } from "@/server/router/router";

const handler = new RPCHandler(appRouter, {
  interceptors: [onError((error) => console.error("[orpc] Unhandled RPC error", error))],
});

async function handleRequest(request: Request) {
  const { response } = await handler.handle(request, {
    prefix: "/api/rpc",
    context: createContext({ headers: request.headers }),
  });

  return response ?? new Response("Not found", { status: 404 });
}

export const HEAD = handleRequest;
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const DELETE = handleRequest;

Client helpers

Tulip also exports lightweight React Query helpers so app code can share one consistent query client setup.

Entry point

import { createQueryClient, getQueryClient } from "@tulip-systems/query/tanstack-query";

createQueryClient()

Creates a fresh QueryClient with Tulip's serializer and default mutation invalidation behavior.

Use this when your app needs to create the root query client explicitly, for example in a custom provider.

import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@tulip-systems/query/tanstack-query";

const queryClient = createQueryClient();

export function AppProviders({ children }: { children: React.ReactNode }) {
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

getQueryClient()

Returns a stable query client for the current environment:

  • on the server: a fresh client per request
  • in the browser: a reused singleton client

This is the safer default when feature modules need a query client but do not own the top-level provider lifecycle.

import { createCollection } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import { getQueryClient } from "@tulip-systems/query/tanstack-query";

const queryClient = getQueryClient();

export const customerCollection = createCollection(
  queryCollectionOptions({
    id: "customers",
    queryClient,
    queryKey: ["customers"],
    queryFn: async () => [],
    getKey: (item: { id: string }) => item.id,
  }),
);

When to use which

  • use createQueryClient() when bootstrapping app-wide providers
  • use getQueryClient() inside feature code, shared collections, or modules that should reuse Tulip's current client automatically

Why Tulip keeps this minimal

Tulip does not try to wrap all of oRPC.

The intended split is:

  • apps own their oRPC context, middleware, and route handlers
  • @tulip-systems/query/tanstack-query provides shared query helpers
  • apps define their own middleware and procedure presets

This keeps the auth model flexible and works well with:

  • session-only apps
  • contract-first routers via implement(contract).$context<RPCContext<...>>()
  • future OAuth 2.1 or mixed-auth middleware chains

On this page