Tulip Logo IconTulip
Router

Router

Tulip router primitives for RPC context, app-local middleware, and query client helpers.

Tulip's router module has two jobs:

  • provide a small server-side RPC bootstrap API around oRPC
  • provide shared React Query helpers for router-driven client state

Keep the rule simple:

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

Server API

Use RPC.init to create your app's base RPC builder.

import { RPC } from "@tulip-systems/core/router/server";

export const rpc = RPC.init<DatabaseSchema, AuthServerOptions>();

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 { RPC } from "@tulip-systems/core/router/server";
import type { DatabaseSchema } from "@/server/db/types";
import type { AuthServerOptions } from "@/server/auth/init";

export const rpc = RPC.init<DatabaseSchema, AuthServerOptions>();

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/core/auth";
import { type RPCContext, ServerError } from "@tulip-systems/core/router/server";
import type { AuthServerOptions } from "../auth/init";
import type { AccessControl } from "../auth/permissions";
import type { DatabaseSchema } from "../db/types";
import { 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 ServerError("UNAUTHORIZED", {
      message: "Jou hebt geen toegang om deze actie uit te voeren",
    });
  }

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

type ProtectedContext = RPCContext<DatabaseSchema, AuthServerOptions> & {
  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 ServerError("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 handleRPCRoute.

import { handleRPCRoute } from "@tulip-systems/core/router/server";
import { context } from "@/server/context";
import { appRouter } from "@/server/router/router";

export const { HEAD, GET, POST, PUT, PATCH, DELETE } = handleRPCRoute(appRouter, { context });

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/core/router";

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/core/router";

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/core/router";

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:

  • core provides a stable base RPC builder and 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