Skip to main content

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

  1. Request Arrives: A request comes into your Next.js API
  2. Context Created: createContext() is called with the request
  3. Session Retrieved: Better Auth extracts the session from request headers (cookies)
  4. Context Returned: An object containing the session is returned
  5. 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:
  1. IntelliSense: Auto-completion for context properties
  2. Type Checking: Errors if you access non-existent properties
  3. 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

  1. Keep Context Lean: Only include what’s needed across multiple procedures
  2. Avoid Heavy Computation: Don’t run expensive queries in createContext()
  3. Use Middleware for Transformations: Let middleware add specialized context data
  4. Type Everything: Always export and use the Context type
  5. Make It Serializable: Avoid adding class instances or functions to context
  6. 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

Build docs developers (and LLMs) love