Back to Blogs
Setting Up ORPC with NEXTJS
Technology

Setting Up ORPC with NEXTJS

In this blog, I will walk you through how to setup ORPC with Nextjs

Dibyajyoti Panda
#orpc#nextjs#trpc#rpc

Complete Guide to Setting Up oRPC with Next.js šŸš€

A step-by-step tutorial for beginners

Table of Contents

  1. What is oRPC?
  2. Why Use oRPC?
  3. Setting Up Your Project
  4. Installing oRPC
  5. Creating Your First Procedure
  6. Setting Up the API Route
  7. Creating the Client
  8. Using oRPC in Components
  9. Public vs Private Procedures
  10. Server Actions Mode
  11. Server-Side Rendering (SSR)
  12. Error Handling
  13. Best Practices
  14. Common Issues & Solutions

What is oRPC?

Think of oRPC as a magic bridge between your frontend (what users see) and backend (where your data lives).

Simple explanation: Instead of writing separate APIs and then figuring out how to call them, oRPC lets you write functions on your server and call them from your frontend as if they were local functions - with full type safety!

Real-world analogy: It's like having a direct phone line between your frontend and backend. When your frontend needs data, it can "call" your backend functions directly without worrying about HTTP requests, response formats, or type mismatches.


Why Use oRPC?

āœ… The Good Stuff

  • Type Safety: No more guessing what data you'll get back
  • Easy to Learn: If you know functions, you know oRPC
  • Automatic API Documentation: OpenAPI specs generated automatically
  • Server Actions: Works with Next.js server actions out of the box
  • Error Handling: Type-safe error handling built-in

šŸ†š Compared to Other Solutions

  • vs REST APIs: No need to manually write fetch requests
  • vs tRPC: Better OpenAPI support and performance
  • vs GraphQL: Simpler to set up and understand

Setting Up Your Project

Step 1: Create a New Next.js Project

# Create a new Next.js project
npx create-next-app@latest my-orpc-app --typescript --tailwind --app

# Navigate to your project
cd my-orpc-app

Step 2: Project Structure

Your project should look like this:

my-orpc-app/
ā”œā”€ā”€ src/
│   ā”œā”€ā”€ app/
│   ā”œā”€ā”€ components/
│   ā”œā”€ā”€ lib/
│   └── router/          # We'll create this for oRPC procedures
ā”œā”€ā”€ package.json
└── ...

Installing oRPC

Install the required packages:

# Core oRPC packages
npm install @orpc/server @orpc/client

# For input validation (highly recommended)
npm install zod

# For better development experience
npm install -D @types/node

What each package does:

  • @orpc/server: Creates your backend procedures
  • @orpc/client: Connects your frontend to the backend
  • zod: Validates data going in and out of your procedures

Creating Your First Procedure

Step 1: Create the Router Directory

mkdir src/router

Step 2: Create Your First Procedure

Create src/router/hello.ts:

import { os } from "@orpc/server";
import { z } from "zod";

// This is your first procedure!
export const helloProcedure = os
  .input(z.object({ 
    name: z.string().min(1, "Name cannot be empty") 
  }))
  .handler(async ({ input }) => {
    return `Hello, ${input.name}! Welcome to oRPC! šŸŽ‰`;
  });

What's happening here?

  • os is the oRPC server builder
  • .input() defines what data this procedure expects
  • .handler() is the actual function that runs
  • z.object() validates the input using Zod

Step 3: Create More Useful Procedures

Create src/router/user.ts:

import { os } from "@orpc/server";
import { z } from "zod";

// Define what a user looks like
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(13, "Must be at least 13 years old"),
});

// Fake database (in real apps, this would be a real database)
const fakeUsers = [
  { id: 1, name: "Alice", email: "[email protected]", age: 16 },
  { id: 2, name: "Bob", email: "[email protected]", age: 17 },
  { id: 3, name: "Charlie", email: "[email protected]", age: 15 },
];

// Get all users
export const getUsers = os
  .input(z.object({
    limit: z.number().min(1).max(100).optional().default(10)
  }))
  .handler(async ({ input }) => {
    // Simulate database delay
    await new Promise(resolve => setTimeout(resolve, 100));
    
    return fakeUsers.slice(0, input.limit);
  });

// Get a specific user
export const getUser = os
  .input(z.object({ 
    id: z.number().min(1, "User ID must be positive") 
  }))
  .handler(async ({ input }) => {
    const user = fakeUsers.find(u => u.id === input.id);
    
    if (!user) {
      throw new Error(`User with ID ${input.id} not found`);
    }
    
    return user;
  });

// Create a new user
export const createUser = os
  .input(UserSchema.omit({ id: true })) // All fields except id
  .handler(async ({ input }) => {
    const newUser = {
      id: fakeUsers.length + 1,
      ...input
    };
    
    fakeUsers.push(newUser);
    return newUser;
  });

Step 4: Combine All Procedures

Create src/router/index.ts:

import { helloProcedure } from "./hello";
import { getUsers, getUser, createUser } from "./user";

// This is your main router - it combines all your procedures
export const router = {
  hello: helloProcedure,
  user: {
    getAll: getUsers,
    getById: getUser,
    create: createUser,
  }
};

// Export the type for the frontend to use
export type Router = typeof router;

Understanding the Structure:

  • Your frontend will call router.hello({ name: "John" })
  • Or router.user.getById({ id: 1 })
  • The nested structure helps organize your API

Setting Up the API Route

Step 1: Create the API Route Handler

Create src/app/api/rpc/[[...rest]]/route.ts:

import { RPCHandler } from '@orpc/server/fetch';
import { router } from '@/router';

// Create the handler with your router
const handler = new RPCHandler(router);

// This function handles all API requests
async function handleRequest(request: Request) {
  const result = await handler.handle(request, {
    prefix: '/api/rpc',
    context: {}, // We'll add auth context later
  });
  
  // Return the response or 404 if no procedure matched
  return result.response ?? new Response('Not found', { status: 404 });
}

// Export handlers for all HTTP methods
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const DELETE = handleRequest;
export const HEAD = handleRequest;

What's happening:

  • [[...rest]] means this catches all routes under /api/rpc/
  • The handler automatically routes requests to the right procedure
  • We export handlers for all HTTP methods Next.js supports

Step 2: Test Your API

Start your development server:

npm run dev

Test in your browser or Postman:

  • POST http://localhost:3000/api/rpc/hello with body {"name": "World"}
  • POST http://localhost:3000/api/rpc/user/getAll with body {"limit": 5}

Creating the Client

Step 1: Set Up the Client

Create src/lib/orpc.ts:

import type { Router } from "@/router";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";

// Create the connection to your API
const rpcLink = new RPCLink({
  url: `${typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"}/api/rpc`,
  headers: async () => {
    // For server-side requests, include Next.js headers
    if (typeof window !== "undefined") return {};
    
    const { headers } = await import("next/headers");
    return Object.fromEntries(await headers());
  },
});

// Create the client with full type safety
export const orpcClient = createORPCClient<Router>(rpcLink);

Key Points:

  • The client automatically knows about all your procedures
  • It works both client-side and server-side
  • TypeScript will give you autocomplete for all your procedures

Using oRPC in Components

Step 1: Create a Simple Component

Create src/components/UserList.tsx:

"use client";

import { useState, useEffect } from "react";
import { orpcClient } from "@/lib/orpc";

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

export default function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchUsers() {
      try {
        setLoading(true);
        // Call your oRPC procedure - notice the autocomplete!
        const result = await orpcClient.user.getAll({ limit: 5 });
        setUsers(result);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Something went wrong");
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();
  }, []);

  if (loading) return <div className="p-4">Loading users...</div>;
  if (error) return <div className="p-4 text-red-500">Error: {error}</div>;

  return (
    <div className="p-4">
      <h2 className="text-2xl font-bold mb-4">Users</h2>
      <div className="grid gap-4">
        {users.map(user => (
          <div key={user.id} className="p-4 border rounded-lg">
            <h3 className="font-semibold">{user.name}</h3>
            <p className="text-gray-600">{user.email}</p>
            <p className="text-sm">Age: {user.age}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Step 2: Create a Form Component

Create src/components/CreateUser.tsx:

"use client";

import { useState } from "react";
import { orpcClient } from "@/lib/orpc";

export default function CreateUser() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [age, setAge] = useState("");
  const [loading, setLoading] = useState(false);
  const [success, setSuccess] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setSuccess(false);

    try {
      // Create user with type safety
      await orpcClient.user.create({
        name,
        email,
        age: parseInt(age)
      });
      
      setSuccess(true);
      setName("");
      setEmail("");
      setAge("");
    } catch (error) {
      console.error("Failed to create user:", error);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="p-4 max-w-md mx-auto">
      <h2 className="text-2xl font-bold mb-4">Create New User</h2>
      
      {success && (
        <div className="p-3 bg-green-100 text-green-700 rounded mb-4">
          User created successfully!
        </div>
      )}
      
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1">Name</label>
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        
        <div>
          <label className="block text-sm font-medium mb-1">Email</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        
        <div>
          <label className="block text-sm font-medium mb-1">Age</label>
          <input
            type="number"
            value={age}
            onChange={(e) => setAge(e.target.value)}
            className="w-full p-2 border rounded"
            min="13"
            required
          />
        </div>
        
        <button
          type="submit"
          disabled={loading}
          className="w-full bg-blue-500 text-white p-2 rounded disabled:opacity-50"
        >
          {loading ? "Creating..." : "Create User"}
        </button>
      </form>
    </div>
  );
}

Step 3: Use Components in Your App

Update src/app/page.tsx:

import UserList from "@/components/UserList";
import CreateUser from "@/components/CreateUser";

export default function Home() {
  return (
    <main className="container mx-auto py-8">
      <h1 className="text-4xl font-bold text-center mb-8">
        My oRPC App šŸš€
      </h1>
      
      <div className="grid md:grid-cols-2 gap-8">
        <CreateUser />
        <UserList />
      </div>
    </main>
  );
}

Public vs Private Procedures

Understanding Authentication

In real apps, some procedures should be public (anyone can use) and others should be private (only logged-in users).

Step 1: Create Middleware for Authentication

Create src/lib/auth-middleware.ts:

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

// Simple middleware to check if user is authenticated
export const requireAuth = os
  .use(async ({ context, next }) => {
    // In a real app, you'd check a JWT token or session
    // For this example, we'll check for an Authorization header
    const authHeader = context.headers?.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new Error('UNAUTHORIZED: Please log in');
    }
    
    // Fake user data (in real apps, decode JWT or check session)
    const user = {
      id: 1,
      name: 'John Doe',
      email: '[email protected]'
    };
    
    return next({
      context: { user }
    });
  });

Step 2: Create Base Procedures

Create src/lib/procedures.ts:

import { os } from "@orpc/server";
import { requireAuth } from "./auth-middleware";

// Public procedure - anyone can use
export const publicProcedure = os;

// Private procedure - requires authentication
export const privateProcedure = os.use(requireAuth);

Step 3: Update Your Procedures

Update src/router/user.ts:

import { z } from "zod";
import { publicProcedure, privateProcedure } from "@/lib/procedures";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(13),
});

const fakeUsers = [
  { id: 1, name: "Alice", email: "[email protected]", age: 16 },
  { id: 2, name: "Bob", email: "[email protected]", age: 17 },
];

// PUBLIC - Anyone can view users
export const getUsers = publicProcedure
  .input(z.object({
    limit: z.number().min(1).max(100).optional().default(10)
  }))
  .handler(async ({ input }) => {
    return fakeUsers.slice(0, input.limit);
  });

// PUBLIC - Anyone can view a specific user
export const getUser = publicProcedure
  .input(z.object({ id: z.number().min(1) }))
  .handler(async ({ input }) => {
    const user = fakeUsers.find(u => u.id === input.id);
    if (!user) {
      throw new Error(`User with ID ${input.id} not found`);
    }
    return user;
  });

// PRIVATE - Only authenticated users can create users
export const createUser = privateProcedure
  .input(UserSchema.omit({ id: true }))
  .handler(async ({ input, context }) => {
    // context.user is available because of requireAuth middleware
    console.log(`User ${context.user.name} is creating a new user`);
    
    const newUser = {
      id: fakeUsers.length + 1,
      ...input
    };
    
    fakeUsers.push(newUser);
    return newUser;
  });

// PRIVATE - Only authenticated users can get their profile
export const getProfile = privateProcedure
  .handler(async ({ context }) => {
    return context.user;
  });

Step 4: Update the API Route for Context

Update src/app/api/rpc/[[...rest]]/route.ts:

import { RPCHandler } from '@orpc/server/fetch';
import { router } from '@/router';

const handler = new RPCHandler(router);

async function handleRequest(request: Request) {
  const result = await handler.handle(request, {
    prefix: '/api/rpc',
    context: {
      // Pass request headers to context for auth middleware
      headers: Object.fromEntries(request.headers.entries())
    },
  });
  
  return result.response ?? new Response('Not found', { status: 404 });
}

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

Server Actions Mode

oRPC works great with Next.js Server Actions! This lets you call your procedures directly from forms without any client-side JavaScript.

Step 1: Make Procedures Actionable

Update your procedures to support server actions:

// In src/router/user.ts
export const createUserAction = privateProcedure
  .input(UserSchema.omit({ id: true }))
  .handler(async ({ input, context }) => {
    const newUser = {
      id: fakeUsers.length + 1,
      ...input
    };
    
    fakeUsers.push(newUser);
    return newUser;
  })
  .actionable(); // This makes it work as a server action!

Step 2: Create a Server Action Component

Create src/components/ServerActionForm.tsx:

import { createUserAction } from "@/router/user";

export default function ServerActionForm() {
  // This runs on the server!
  async function handleSubmit(formData: FormData) {
    "use server";
    
    try {
      await createUserAction({
        name: formData.get("name") as string,
        email: formData.get("email") as string,
        age: parseInt(formData.get("age") as string)
      });
      
      console.log("User created successfully!");
    } catch (error) {
      console.error("Failed to create user:", error);
    }
  }

  return (
    <div className="p-4 max-w-md mx-auto">
      <h2 className="text-2xl font-bold mb-4">Server Action Form</h2>
      
      <form action={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1">Name</label>
          <input
            name="name"
            type="text"
            className="w-full p-2 border rounded"
            required
          />
        </div>
        
        <div>
          <label className="block text-sm font-medium mb-1">Email</label>
          <input
            name="email"
            type="email"
            className="w-full p-2 border rounded"
            required
          />
        </div>
        
        <div>
          <label className="block text-sm font-medium mb-1">Age</label>
          <input
            name="age"
            type="number"
            className="w-full p-2 border rounded"
            min="13"
            required
          />
        </div>
        
        <button
          type="submit"
          className="w-full bg-green-500 text-white p-2 rounded"
        >
          Create User (Server Action)
        </button>
      </form>
    </div>
  );
}

Server-Side Rendering (SSR)

oRPC works perfectly with Next.js SSR. You can call procedures directly in your server components.

Step 1: Create a Server Component

Create src/components/ServerUserList.tsx:

import { orpcClient } from "@/lib/orpc";

// This is a server component!
export default async function ServerUserList() {
  try {
    // This runs on the server during page generation
    const users = await orpcClient.user.getAll({ limit: 10 });
    
    return (
      <div className="p-4">
        <h2 className="text-2xl font-bold mb-4">Users (Server-Side)</h2>
        <div className="grid gap-4">
          {users.map(user => (
            <div key={user.id} className="p-4 border rounded-lg">
              <h3 className="font-semibold">{user.name}</h3>
              <p className="text-gray-600">{user.email}</p>
              <p className="text-sm">Age: {user.age}</p>
            </div>
          ))}
        </div>
      </div>
    );
  } catch (error) {
    return (
      <div className="p-4 text-red-500">
        Failed to load users: {error instanceof Error ? error.message : "Unknown error"}
      </div>
    );
  }
}

Step 2: Use in Your App

// In src/app/page.tsx
import ServerUserList from "@/components/ServerUserList";

export default function Home() {
  return (
    <main className="container mx-auto py-8">
      <h1 className="text-4xl font-bold text-center mb-8">
        SSR with oRPC šŸš€
      </h1>
      
      {/* This data is loaded on the server */}
      <ServerUserList />
    </main>
  );
}

Benefits of SSR:

  • Faster initial page load
  • Better SEO
  • Works without JavaScript
  • Data is available immediately

Error Handling

Step 1: Define Type-Safe Errors

Create src/router/errors.ts:

import { os, ORPCError } from "@orpc/server";
import { z } from "zod";

// Define specific error types
export const userProcedure = os
  .errors({
    USER_NOT_FOUND: {
      message: "The requested user was not found",
      data: z.object({
        userId: z.number(),
        suggestion: z.string()
      })
    },
    INVALID_AGE: {
      message: "Age must be between 13 and 120",
      data: z.object({
        providedAge: z.number(),
        minAge: z.number(),
        maxAge: z.number()
      })
    }
  });

Step 2: Use Type-Safe Errors

// In src/router/user.ts
import { userProcedure } from "./errors";

export const getUserWithErrors = userProcedure
  .input(z.object({ id: z.number().min(1) }))
  .handler(async ({ input, errors }) => {
    const user = fakeUsers.find(u => u.id === input.id);
    
    if (!user) {
      // Throw a type-safe error
      throw errors.USER_NOT_FOUND({
        userId: input.id,
        suggestion: "Try a user ID between 1 and 3"
      });
    }
    
    return user;
  });

Step 3: Handle Errors on the Client

import { orpcClient } from "@/lib/orpc";
import { isDefinedError } from "@orpc/client";

export default function ErrorHandlingComponent() {
  async function fetchUser(id: number) {
    try {
      const user = await orpcClient.user.getById({ id });
      console.log("User found:", user);
    } catch (error) {
      if (isDefinedError(error)) {
        // This error is type-safe!
        if (error.code === 'USER_NOT_FOUND') {
          console.log("User not found. Suggestion:", error.data.suggestion);
        }
      } else {
        // Handle unexpected errors
        console.log("Unexpected error:", error);
      }
    }
  }

  return (
    <button onClick={() => fetchUser(999)}>
      Test Error Handling
    </button>
  );
}

Best Practices

1. Organize Your Procedures

src/router/
ā”œā”€ā”€ auth/           # Authentication procedures
│   ā”œā”€ā”€ login.ts
│   └── register.ts
ā”œā”€ā”€ user/           # User management
│   ā”œā”€ā”€ profile.ts
│   └── settings.ts
ā”œā”€ā”€ blog/           # Blog features
│   └── posts.ts
└── index.ts        # Main router

2. Use Meaningful Input Validation

// āŒ Bad - unclear validation
.input(z.object({ data: z.string() }))

// āœ… Good - clear validation with helpful messages
.input(z.object({
  email: z.string().email("Please enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  age: z.number().min(13, "You must be at least 13 years old")
}))

3. Handle Loading States

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadUser() {
      try {
        setLoading(true);
        const userData = await orpcClient.user.getById({ id: userId });
        setUser(userData);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    }
    
    loadUser();
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>User not found</div>;

  return <div>{user.name}</div>;
}

4. Use Environment Variables

Create .env.local:

# Database URL
DATABASE_URL="your-database-url"

# JWT Secret
JWT_SECRET="your-secret-key"

# API Base URL
NEXT_PUBLIC_API_URL="http://localhost:3000"

Common Issues & Solutions

Issue 1: "Module not found" errors

Problem: Import errors when setting up oRPC

Solution: Make sure you have the correct imports and file paths:

// āœ… Correct imports
import { os } from "@orpc/server";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";

Issue 2: Type errors with procedures

Problem: TypeScript complaining about procedure types

Solution: Make sure your router type is properly exported:

// In src/router/index.ts
export const router = { /* your procedures */ };
export type Router = typeof router;

// In src/lib/orpc.ts
import type { Router } from "@/router";
export const orpcClient = createORPCClient<Router>(rpcLink);

Issue 3: CORS errors in development

Problem: Browser blocking requests to your API

Solution: Add CORS headers to your API route:

// In src/app/api/rpc/[[...rest]]/route.ts
async function handleRequest(request: Request) {
  const result = await handler.handle(request, {
    prefix: '/api/rpc',
    context: {},
  });
  
  const response = result.response ?? new Response('Not found', { status: 404 });
  
  // Add CORS headers for development
  if (process.env.NODE_ENV === 'development') {
    response.headers.set('Access-Control-Allow-Origin', '*');
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }
  
  return response;
}

Issue 4: Server actions not working

Problem: Server actions throwing errors

Solution: Make sure you're using the .actionable() method and proper form handling:

// Make your procedure actionable
export const myProcedure = publicProcedure
  .input(schema)
  .handler(handler)
  .actionable(); // Don't forget this!

// Use proper server action syntax
async function myAction(formData: FormData) {
  "use server"; // This is required
  
  await myProcedure({
    // Extract data from formData
  });
}

šŸŽ‰ Congratulations!

You've successfully learned how to integrate oRPC with Next.js! You now know how to:

  • āœ… Set up oRPC in a Next.js project
  • āœ… Create public and private procedures
  • āœ… Handle errors properly
  • āœ… Use server actions
  • āœ… Implement SSR
  • āœ… Follow best practices

Next Steps

  1. Add a real database (PostgreSQL, MongoDB, etc.)
  2. Implement proper authentication (NextAuth.js, Clerk, etc.)
  3. Add file uploads for handling images and documents
  4. Deploy your app to Vercel or other platforms
  5. Add tests to ensure your procedures work correctly

Useful Resources

Happy coding! šŸš€