Skip to content

Tutorial: Share Context Across Functions

In this tutorial you will set up the async context middleware, access request context from deeply nested utility functions, build a request-scoped logger, and create a helper to get the current user from anywhere.

Prerequisites

  • A Remix project with a working server (see the Getting Started guide).
  • Basic understanding of async/await in JavaScript.

Step 1: Add the Async Context Middleware

Place asyncContext() early in the middleware chain so all downstream code can use getContext():

ts
import { createRouter } from 'remix/fetch-router'
import { asyncContext } from 'remix/async-context-middleware'
import { session } from 'remix/session-middleware'
import { auth } from 'remix/auth-middleware'
import { route } from 'remix/fetch-router/routes'

let routes = route({
  posts: '/posts',
  deletePost: '/posts/:id',
})

let router = createRouter({
  middleware: [
    asyncContext(),
    session(sessionCookie, sessionStorage),
    auth({ schemes: [sessionAuth] }),
  ],
})

The middleware order matters. Because asyncContext() is first, every subsequent middleware and handler can call getContext().

Step 2: Create a getCurrentUser Helper

Instead of passing the user through every function, create a utility that reads it from the async context:

ts
// utils/current-user.ts
import { getContext } from 'remix/async-context-middleware'
import { Auth } from 'remix/auth-middleware'

export function getCurrentUser() {
  let context = getContext()
  let authState = context.get(Auth)

  if (!authState.ok) {
    return null
  }

  return authState.identity
}

Now you can call getCurrentUser() from any function during a request:

ts
// services/posts.ts
import { getCurrentUser } from '../utils/current-user.ts'

export async function deletePost(postId: string) {
  let user = getCurrentUser()
  if (!user) throw new Error('Not authenticated')

  // Check ownership
  let post = await db.find(posts, postId)
  if (post.authorId !== user.id) throw new Error('Not authorized')

  await db.delete(posts, { where: eq(posts.columns.id, postId) })
}

Your route handler becomes simple:

ts
import { deletePost } from './services/posts.ts'

router.delete(routes.deletePost, async ({ params }) => {
  await deletePost(params.id)
  return redirect('/posts')
})

Step 3: Build a Request-Scoped Logger

Create a logger that automatically includes request metadata in every log entry:

ts
// utils/logger.ts
import { getContext } from 'remix/async-context-middleware'

export function log(level: string, message: string, extra?: Record<string, unknown>) {
  let context = getContext()

  let entry = {
    level,
    message,
    method: context.request.method,
    url: context.url.pathname,
    timestamp: new Date().toISOString(),
    ...extra,
  }

  console.log(JSON.stringify(entry))
}

Use it from anywhere:

ts
// services/payment.ts
import { log } from '../utils/logger.ts'

export async function processPayment(orderId: string, amount: number) {
  log('info', 'Processing payment', { orderId, amount })

  try {
    let result = await paymentGateway.charge(amount)
    log('info', 'Payment succeeded', { orderId, transactionId: result.id })
    return result
  } catch (error) {
    log('error', 'Payment failed', { orderId, error: error.message })
    throw error
  }
}

Every log entry includes the HTTP method and URL automatically -- no need to pass them as arguments.

Step 4: Add Request Tracing

Generate a unique ID for each request and access it from anywhere:

ts
import { createContextKey } from 'remix/fetch-router'
import { asyncContext, getContext } from 'remix/async-context-middleware'

// Define a context key for the trace ID
export let TraceId = createContextKey<string>('TraceId')

// Middleware that assigns a trace ID
function tracing(context, next) {
  let traceId = context.request.headers.get('X-Trace-Id') ?? crypto.randomUUID()
  context.set(TraceId, traceId)
  return next()
}

let router = createRouter({
  middleware: [
    asyncContext(),
    tracing,
  ],
})

Now retrieve the trace ID from anywhere:

ts
// utils/trace.ts
import { getContext } from 'remix/async-context-middleware'
import { TraceId } from '../middleware/tracing.ts'

export function getTraceId(): string {
  return getContext().get(TraceId)
}

Use it in your logger, error reports, or outgoing HTTP requests:

ts
import { getTraceId } from '../utils/trace.ts'

async function callExternalApi(path: string) {
  return fetch(`https://api.example.com${path}`, {
    headers: {
      'X-Trace-Id': getTraceId(), // Propagate trace ID to downstream services
    },
  })
}

Step 5: Handle Missing Context Gracefully

getContext() throws an error if called outside a request (e.g., during server startup or in a background job). For utility functions that might run in both contexts, add a fallback:

ts
import { getContext } from 'remix/async-context-middleware'

export function log(level: string, message: string) {
  let requestInfo = {}

  try {
    let context = getContext()
    requestInfo = {
      method: context.request.method,
      url: context.url.pathname,
    }
  } catch {
    // Not inside a request -- that's fine
  }

  console.log(JSON.stringify({ level, message, ...requestInfo }))
}

What You Learned

  • How to set up the asyncContext middleware.
  • How to access the current user from any function with getContext().
  • How to build a request-scoped logger that automatically includes request metadata.
  • How to propagate trace IDs through the call stack and to external services.
  • How to handle cases where async context is not available.

Next Steps

Released under the MIT License.