Setting Up ORPC with NEXTJS
In this blog, I will walk you through how to setup ORPC with Nextjs
Complete Guide to Setting Up oRPC with Next.js š
A step-by-step tutorial for beginners
Table of Contents
- What is oRPC?
- Why Use oRPC?
- Setting Up Your Project
- Installing oRPC
- Creating Your First Procedure
- Setting Up the API Route
- Creating the Client
- Using oRPC in Components
- Public vs Private Procedures
- Server Actions Mode
- Server-Side Rendering (SSR)
- Error Handling
- Best Practices
- 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 backendzod: 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?
osis the oRPC server builder.input()defines what data this procedure expects.handler()is the actual function that runsz.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/hellowith body{"name": "World"}POST http://localhost:3000/api/rpc/user/getAllwith 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
- Add a real database (PostgreSQL, MongoDB, etc.)
- Implement proper authentication (NextAuth.js, Clerk, etc.)
- Add file uploads for handling images and documents
- Deploy your app to Vercel or other platforms
- Add tests to ensure your procedures work correctly
Useful Resources
Happy coding! š