Features

API Layer

Build type-safe APIs with ORPC for end-to-end type safety.

Overview

Raypx uses ORPC for building type-safe APIs. ORPC provides a tRPC-like development experience with a simpler architecture, giving you end-to-end type safety from server to client.

Features

  • End-to-End Type Safety: Full TypeScript inference from server to client
  • Minimal Bundle Size: Lightweight compared to alternatives
  • Server-Side Focus: Designed for server-first architectures
  • React Integration: Built-in hooks for data fetching
  • Middleware Support: Composable middleware for auth, logging, etc.

Project Structure

packages/rpc/
├── src/
│   ├── context.ts      # Request context types
│   ├── middleware/     # Middleware (auth, logging)
│   ├── procedures/     # API procedures
│   ├── routers/        # Route definitions
│   └── client.ts       # Client exports
└── index.ts

Defining Procedures

Basic Procedure

// packages/rpc/procedures/user.ts
import { o } from "@orpc/server";
import { publicProcedure } from "../trpc";

export const getUser = publicProcedure
  .input(o.object({ id: o.string() }))
  .handler(async ({ input, context }) => {
    const user = await context.db.user.findById(input.id);
    if (!user) {
      throw new Error("User not found");
    }
    return user;
  });

Protected Procedure

// packages/rpc/procedures/user.ts
import { protectedProcedure } from "../trpc";

export const getProfile = protectedProcedure
  .handler(async ({ context }) => {
    return context.user;
  });

export const updateProfile = protectedProcedure
  .input(o.object({
    name: o.string().optional(),
    email: o.string().email().optional(),
  }))
  .handler(async ({ input, context }) => {
    const updated = await context.db.user.update(context.user.id, input);
    return updated;
  });

With Validation

import { o } from "@orpc/server";
import { protectedProcedure } from "../trpc";

export const createPost = protectedProcedure
  .input(o.object({
    title: o.string().min(1).max(200),
    content: o.string().min(1),
    tags: o.array(o.string()).optional(),
    published: o.boolean().default(false),
  }))
  .handler(async ({ input, context }) => {
    const post = await context.db.post.create({
      ...input,
      authorId: context.user.id,
    });
    return post;
  });

Creating Routers

Define Router

// packages/rpc/routers/index.ts
import { router } from "@orpc/server";
import * as userProcedures from "../procedures/user";
import * as postProcedures from "../procedures/post";

export const appRouter = router({
  user: {
    get: userProcedures.getUser,
    profile: userProcedures.getProfile,
    update: userProcedures.updateProfile,
  },
  post: {
    list: postProcedures.listPosts,
    create: postProcedures.createPost,
    delete: postProcedures.deletePost,
  },
});

export type AppRouter = typeof appRouter;

Server Setup

// apps/web/src/routes/api/rpc/[...catchall].ts
import { createAPIFileRoute } from "@tanstack/react-start";
import { appRouter } from "@raypx/rpc";
import { createContext } from "@raypx/rpc/context";

export const APIRoute = createAPIFileRoute("/api/rpc/$")({
  POST: async ({ request, params }) => {
    const context = await createContext(request);
    const response = await appRouter.handler(request, {
      context,
      path: params.catchall,
    });
    return response;
  },
});

Client Usage

React Hooks

// In your React components
import { useORPC } from "@raypx/rpc/client";

function UserProfile() {
  const { data, isLoading, error } = useORPC.user.profile();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
}

With Input

import { useORPC } from "@raypx/rpc/client";

function UserDetail({ userId }: { userId: string }) {
  const { data } = useORPC.user.get({ id: userId });

  return <div>{data?.name}</div>;
}

Mutations

import { useORPCMuation } from "@raypx/rpc/client";

function EditProfile() {
  const mutation = useORPCMuation.user.update();

  const handleSubmit = async (formData: FormData) => {
    await mutation.mutateAsync({
      name: formData.get("name") as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <button type="submit" disabled={mutation.isPending}>
        Save
      </button>
    </form>
  );
}

Server-Side Calls

// In server components or server functions
import { serverClient } from "@raypx/rpc/server";

async function getUserServer(id: string) {
  const user = await serverClient.user.get({ id });
  return user;
}

Middleware

Built-in Middleware

// packages/rpc/src/middleware.ts
import { ORPCError, os } from "@orpc/server";

const o = os.$context<Context>();

// Public procedure (no auth required)
export const publicProcedure = o;

// Protected procedure (auth required)
export const protectedProcedure = publicProcedure.use(requireAuthMiddleware);

// Admin procedure (admin role required)
export const adminProcedure = publicProcedure.use(requireAdminMiddleware);

Raypx provides three procedure types:

  • publicProcedure - No authentication required
  • protectedProcedure - Requires authenticated user
  • adminProcedure - Requires user with admin role

See Admin Features for more details on admin procedures.

Auth Middleware

// packages/rpc/middleware/auth.ts
import { middleware } from "@orpc/server";
import { getServerSession } from "@raypx/auth/server";

export const authMiddleware = middleware(async ({ context, next }) => {
  const session = await getServerSession();

  if (!session) {
    throw new Error("Unauthorized");
  }

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

Logging Middleware

// packages/rpc/middleware/logging.ts
import { middleware } from "@orpc/server";
import { logger } from "@raypx/logger";

export const loggingMiddleware = middleware(async ({ path, input, next }) => {
  const start = Date.now();

  logger.info(`[ORPC] ${path}`, { input });

  try {
    const result = await next();
    const duration = Date.now() - start;
    logger.info(`[ORPC] ${path} completed`, { duration });
    return result;
  } catch (error) {
    const duration = Date.now() - start;
    logger.error(`[ORPC] ${path} failed`, { error, duration });
    throw error;
  }
});

Rate Limiting Middleware

// packages/rpc/middleware/rateLimit.ts
import { middleware } from "@orpc/server";
import { ratelimit } from "@raypx/redis";

export const rateLimitMiddleware = middleware(async ({ context, next }) => {
  const identifier = context.user?.id ?? context.ip ?? "anonymous";

  const { success } = await ratelimit.limit(identifier);

  if (!success) {
    throw new Error("Too many requests");
  }

  return next();
});

Context

Define Context Type

// packages/rpc/context.ts
import type { User, Session } from "@raypx/auth/types";

export interface RequestContext {
  user?: User;
  session?: Session;
  db: typeof import("@raypx/database").db;
  headers: Headers;
  ip?: string;
}

Create Context

// packages/rpc/context.ts
import { getServerSession } from "@raypx/auth/server";
import { db } from "@raypx/database";

export async function createContext(request: Request): Promise<RequestContext> {
  const session = await getServerSession();

  return {
    user: session?.user,
    session,
    db,
    headers: request.headers,
    ip: request.headers.get("x-forwarded-for") ?? undefined,
  };
}

Error Handling

Custom Errors

// packages/rpc/errors.ts
import { ORPCError } from "@orpc/server";

export class NotFoundError extends ORPCError {
  constructor(message: string) {
    super({
      code: "NOT_FOUND",
      message,
    });
  }
}

export class UnauthorizedError extends ORPCError {
  constructor(message = "Unauthorized") {
    super({
      code: "UNAUTHORIZED",
      message,
    });
  }
}

export class ForbiddenError extends ORPCError {
  constructor(message = "Forbidden") {
    super({
      code: "FORBIDDEN",
      message,
    });
  }
}

Using Errors

import { NotFoundError, ForbiddenError } from "../errors";

export const getPost = publicProcedure
  .input(o.object({ id: o.string() }))
  .handler(async ({ input, context }) => {
    const post = await context.db.post.findById(input.id);

    if (!post) {
      throw new NotFoundError("Post not found");
    }

    if (post.published === false && post.authorId !== context.user?.id) {
      throw new ForbiddenError("You don't have access to this post");
    }

    return post;
  });

Best Practices

Input Validation

import { o } from "@orpc/server";

// Define reusable schemas
const PaginationSchema = o.object({
  page: o.number().int().min(1).default(1),
  limit: o.number().int().min(1).max(100).default(20),
});

const IdSchema = o.string().uuid();

// Use in procedures
export const listPosts = publicProcedure
  .input(PaginationSchema.extend({
    authorId: IdSchema.optional(),
    search: o.string().optional(),
  }))
  .handler(async ({ input }) => {
    // input is fully typed and validated
  });

Procedure Organization

procedures/
├── user/
│   ├── get.ts
│   ├── update.ts
│   └── index.ts      # Export all user procedures
├── post/
│   ├── list.ts
│   ├── create.ts
│   ├── update.ts
│   ├── delete.ts
│   └── index.ts
└── index.ts          # Export all procedures

Type Exports

// packages/rpc/index.ts
export type { AppRouter } from "./routers";
export type { RequestContext } from "./context";

// For client
export { createClient } from "./client";

Comparison with tRPC

FeatureORPCtRPC
Bundle Size~3KB~40KB
Learning CurveSimplerMore features
Server-FirstYesYes
React IntegrationHooksHooks + SSG
SubscriptionsLimitedFull support
PluginsBasicExtensive

On this page