What is Context?
Context is an object that flows through every ORPC procedure, providing access to request-scoped data like the user’s session, database connection, and other shared resources. It’s created once per request and passed to all procedures and middleware.
Context Creation
The context is created in src/lib/context.ts:
import type { NextRequest } from "next/server"
import { auth } from "./auth"
export async function createContext(req: NextRequest) {
const session = await auth.api.getSession({
headers: req.headers
})
return {
session
}
}
export type Context = Awaited<ReturnType<typeof createContext>>
How It Works
- Request Arrives: A request comes into your Next.js API
- Context Created:
createContext() is called with the request
- Session Retrieved: Better Auth extracts the session from request headers (cookies)
- Context Returned: An object containing the session is returned
- Type Exported: TypeScript infers the context type for use throughout your app
The Awaited<ReturnType<typeof createContext>> pattern automatically infers the context type, so you don’t need to manually maintain type definitions.
Session in Context
The session object contains information about the authenticated user (if logged in):
interface Session {
user: {
id: string
name: string
email: string
emailVerified: boolean
image: string | null
createdAt: Date
updatedAt: Date
}
session: {
id: string
userId: string
expiresAt: Date
token: string
ipAddress: string | null
userAgent: string | null
}
}
Session States
- Logged In:
context.session is an object with user and session properties
- Logged Out:
context.session is null or undefined
Accessing Context in Procedures
Every procedure handler receives the context as part of its argument:
In Public Procedures
Public procedures have access to context, but the session may be null:
import { publicProcedure } from "../lib/orpc"
export const todoRouter = {
create: publicProcedure
.input(z.object({ text: z.string().min(1) }))
.handler(async ({ input, context }) => {
// context.session might be null
if (context.session) {
console.log(`User ${context.session.user.email} created a todo`)
}
return await db.insert(todo).values({
text: input.text,
userId: context.session?.user.id // Optional user association
})
})
}
In Protected Procedures
Protected procedures guarantee that context.session exists:
import { protectedProcedure } from "../lib/orpc"
export const todoRouter = {
getAll: protectedProcedure.handler(async ({ context }) => {
// context.session is guaranteed to exist here
const userId = context.session.user.id
const userEmail = context.session.user.email
console.log(`Fetching todos for ${userEmail}`)
return await db
.select()
.from(todo)
.where(eq(todo.userId, userId))
})
}
The requireAuth middleware ensures the session exists before the handler runs, providing type safety.
Type-Safe Context
ORPC is configured to use your context type in src/lib/orpc.ts:
import { os } from "@orpc/server"
import type { Context } from "./context"
export const o = os.$context<Context>()
This line tells ORPC about your context shape, enabling:
- IntelliSense: Auto-completion for context properties
- Type Checking: Errors if you access non-existent properties
- Refactoring: Renaming context properties updates all usages
Example: Type Safety in Action
.handler(async ({ context }) => {
// TypeScript knows context.session exists (or is null)
const session = context.session
// TypeScript provides auto-completion
if (session) {
session.user.email // ✓ Valid
session.user.invalidProp // ✗ TypeScript error
}
})
Context Flow
Here’s how context flows through a request:
1. Request → /api/orpc
↓
2. createContext(req) → { session: {...} }
↓
3. ORPC Router receives context
↓
4. Middleware can access/modify context
↓
5. Procedure handler receives final context
↓
6. Handler returns response
Example Flow with Middleware
// 1. Context created
const context = await createContext(req)
// { session: { user: {...}, session: {...} } }
// 2. requireAuth middleware runs
if (!context.session?.user) {
throw new ORPCError("UNAUTHORIZED")
}
// 3. Middleware returns refined context
return next({
context: {
session: context.session // Now guaranteed to exist
}
})
// 4. Handler receives refined context
.handler(async ({ context }) => {
// context.session.user is guaranteed here
})
Extending Context
You can add more data to the context as your app grows:
import type { NextRequest } from "next/server"
import { auth } from "./auth"
import { db } from "../db"
export async function createContext(req: NextRequest) {
const session = await auth.api.getSession({
headers: req.headers
})
return {
session,
db, // Add database instance
requestId: crypto.randomUUID(), // Add request ID
userAgent: req.headers.get("user-agent"), // Add user agent
}
}
export type Context = Awaited<ReturnType<typeof createContext>>
Now all procedures have access to these additional properties:
.handler(async ({ context }) => {
console.log(`Request ${context.requestId} from ${context.userAgent}`)
await context.db.select().from(todo)
})
Be cautious about adding expensive operations to context creation since it runs on every request.
Middleware Context Transformation
Middleware can transform the context for downstream procedures. This is how requireAuth refines the context type:
export const requireAuth = o.middleware(async ({ context, next }) => {
if (!context.session?.user) {
throw new ORPCError("UNAUTHORIZED")
}
// Return a new context with guaranteed session
return next({
context: {
session: context.session // TypeScript now knows this exists
}
})
})
This pattern allows you to:
- Add data: Include user preferences, permissions, etc.
- Refine types: Make optional fields required
- Transform data: Format or enrich context data
Example: Adding User Permissions
const requireAdmin = o.middleware(async ({ context, next }) => {
if (!context.session?.user) {
throw new ORPCError("UNAUTHORIZED")
}
const permissions = await getUserPermissions(context.session.user.id)
if (!permissions.includes("admin")) {
throw new ORPCError("FORBIDDEN")
}
return next({
context: {
session: context.session,
permissions // Add permissions to context
}
})
})
const adminProcedure = publicProcedure.use(requireAdmin)
adminProcedure.handler(async ({ context }) => {
// context.permissions is available here
})
Best Practices
- Keep Context Lean: Only include what’s needed across multiple procedures
- Avoid Heavy Computation: Don’t run expensive queries in
createContext()
- Use Middleware for Transformations: Let middleware add specialized context data
- Type Everything: Always export and use the
Context type
- Make It Serializable: Avoid adding class instances or functions to context
- Document Custom Fields: If you extend context, document what each field contains
Context vs Props
Use Context for:
- Request-scoped data (session, request ID)
- Shared resources (database, cache)
- Cross-cutting concerns (logging, tracing)
Use Input for:
- User-provided data
- Procedure-specific parameters
- Business logic inputs
// Good: User ID comes from context (session)
.handler(async ({ context }) => {
const userId = context.session.user.id
})
// Good: Todo text comes from input
.input(z.object({ text: z.string() }))
.handler(async ({ input }) => {
const text = input.text
})
Next Steps