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/servergives you the base RPC builder and server helpers@tulip-systems/core/routergives 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.
Recommended app structure
Use three files in your app:
server/router/init.tsserver/router/middleware.tsserver/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:
coreprovides 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