Nuxt reverse proxy

Before you start
  • If you use a self-hosted proxy, PostHog can't help troubleshoot. Use our managed reverse proxy if you want support.
  • Use domains matching your PostHog region: us.i.posthog.com for US, eu.i.posthog.com for EU.
  • Don't use obvious path names like /analytics, /tracking, /telemetry, or /posthog. Blockers will catch them. Use something unique to your app instead.

This guide shows you how to use Nuxt server routes as a reverse proxy for PostHog.

How it works

Nuxt server routes run on the server and can intercept requests before they reach the client. When a request matches your proxy path, the server route fetches the response from PostHog and returns it under your domain.

Here's the request flow:

  1. User triggers an event in your app
  2. Request goes to your domain (e.g., yourdomain.com/ph/e)
  3. Nuxt's server middleware intercepts requests matching your proxy path
  4. The server route fetches the response from PostHog's servers
  5. PostHog's response is returned to the user under your domain

This works because the proxy runs server-side, so the browser only sees requests to your domain. Ad blockers that filter by domain won't block these requests.

Prerequisites

This guide requires a Nuxt 3 project with server-side rendering enabled (the default).

Setup

  1. Create the server route

    Create a file at server/routes/ph/[...path].ts:

    export default defineEventHandler(async (event) => {
    const path = event.context.params?.path || ''
    const url = getRequestURL(event)
    const search = url.search || ''
    const hostname = path.startsWith('static/')
    ? 'us-assets.i.posthog.com'
    : 'us.i.posthog.com'
    const targetUrl = `https://${hostname}/${path}${search}`
    // Forward headers, excluding ones that shouldn't be proxied
    const headers = new Headers()
    const excludedHeaders = ['host', 'connection', 'content-length', 'transfer-encoding', 'accept-encoding']
    const requestHeaders = getRequestHeaders(event)
    for (const [key, value] of Object.entries(requestHeaders)) {
    if (value && !excludedHeaders.includes(key.toLowerCase())) {
    headers.set(key, value)
    }
    }
    headers.set('host', hostname)
    // Forward client IP for geolocation
    const clientIp = getHeader(event, 'x-forwarded-for') || getRequestIP(event)
    if (clientIp) {
    headers.set('x-forwarded-for', clientIp)
    }
    // Read body as binary buffer to preserve gzip compression
    const body = event.method !== 'GET' && event.method !== 'HEAD'
    ? await readRawBody(event, false)
    : undefined
    const response = await fetch(targetUrl, {
    method: event.method,
    headers,
    body,
    })
    // Copy response headers, excluding problematic ones
    for (const [key, value] of response.headers.entries()) {
    if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) {
    setResponseHeader(event, key, value)
    }
    }
    setResponseStatus(event, response.status)
    // Return binary response
    const arrayBuffer = await response.arrayBuffer()
    return Buffer.from(arrayBuffer)
    })

    The [...path] in the filename is a Nuxt catch-all route that matches any path after /ph/. This handles all PostHog endpoints like /ph/e, /ph/decide, and /ph/static/array.js.

    Here's what the code does:

    • Routes /static/* requests to PostHog's asset server and everything else to the main API
    • Preserves query parameters (like ?compression=gzip-js) required for compressed requests
    • Forwards relevant headers while excluding ones that shouldn't be proxied
    • Reads the request body as binary to preserve gzip-compressed data
    • Forwards the client's IP address for accurate geolocation
    • Returns the response as binary data to handle all content types correctly
  2. Update your PostHog SDK

    Update your PostHog configuration to use your proxy path.

    Using @posthog/nuxt (Nuxt 3.7+)

    If you're using the @posthog/nuxt module, update your nuxt.config.ts:

    // nuxt.config.ts
    export default defineNuxtConfig({
    modules: ['@posthog/nuxt'],
    posthogConfig: {
    publicKey: '<ph_project_api_key>',
    clientConfig: {
    api_host: '/ph',
    ui_host: 'https://us.posthog.com'
    }
    }
    })

    Using posthog-js plugin (Nuxt 3.0-3.6)

    If you're using the posthog-js library directly with a plugin:

    // plugins/posthog.client.ts
    import { defineNuxtPlugin } from '#app'
    import posthog from 'posthog-js'
    export default defineNuxtPlugin(() => {
    const runtimeConfig = useRuntimeConfig()
    posthog.init(runtimeConfig.public.posthogKey, {
    api_host: '/ph',
    ui_host: 'https://us.posthog.com'
    })
    return {
    provide: {
    posthog
    }
    }
    })

    The api_host tells the SDK where to send events. Using a relative path ensures requests go to your domain. The ui_host must point to PostHog's actual domain so features like the toolbar link correctly.

  3. Deploy your changes

    Commit and push your changes. The server route will be active once deployed.

    In development, restart your dev server after creating the server route.

  4. Verify your setup

    Checkpoint

    Confirm events are flowing through your proxy:

    1. Open your browser's developer tools and go to the Network tab
    2. Navigate to your site or trigger an event
    3. Look for requests to your domain with your proxy path (e.g., yourdomain.com/ph)
    4. Verify the response status is 200 OK
    5. Check the PostHog app to confirm events appear in your activity feed

    If you see errors, check troubleshooting below.

Troubleshooting

Server route not matching

If requests to your proxy path return 404:

  1. Verify the file is at server/routes/ph/[...path].ts (not server/api/)
  2. Check the file extension is .ts not .js if you're using TypeScript
  3. Restart your dev server after creating the file

Static site generation

If you're using nuxt generate for static site generation, server routes won't be available. Use a CDN-level proxy like Cloudflare Workers or a platform-specific option like Netlify redirects or Vercel rewrites instead.

All users show same location

If geolocation data is wrong or all users appear in the same location, verify the x-forwarded-for header is being set correctly. If you're behind multiple proxies, you may need to adjust which header value is forwarded.

Community questions

Was this page useful?

Questions about this page? or post a community question.