Skip to content

Tutorial: Proxy API Requests

In this tutorial, you will build a gateway server that proxies API requests to a backend service. You will learn how to set up a proxy, configure cookie rewriting, forward headers, and handle multiple backend services.

Prerequisites

You should be comfortable with JavaScript and have a basic understanding of HTTP requests. Reading the node-fetch-server tutorial first is helpful but not required.

What is Proxying?

Proxying means forwarding a request from one server to another. Your server acts as an intermediary -- it receives the client's request, sends it to the real backend, and returns the backend's response to the client. The client only talks to your server and never connects to the backend directly.

Step 1: Project Setup

bash
mkdir proxy-gateway
cd proxy-gateway
npm init -y
npm install remix

Step 2: Create a Simple Proxy

Let's start with a basic setup that proxies all requests under /api/ to a backend service:

ts
// server.ts
import * as http from 'node:http'
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'
import { createRequestListener } from 'remix/node-fetch-server'
import { createFetchProxy } from 'remix/fetch-proxy'

// Create a proxy that forwards requests to our backend
let apiProxy = createFetchProxy('http://localhost:8080')

let routes = route({
  home: '/',
  api: '/api/*path',
})

let router = createRouter()

// Serve the frontend
router.get(routes.home, () => {
  return new Response('<h1>My App</h1>', {
    headers: { 'Content-Type': 'text/html' },
  })
})

// Proxy API requests to the backend
router.map(routes.api, {
  handler({ request }) {
    return apiProxy(request)
  },
})

let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000, () => {
  console.log('Gateway running at http://localhost:3000')
})

Now when a client requests http://localhost:3000/api/users, the proxy forwards it to http://localhost:8080/api/users and returns the response.

Step 3: Proxy to a Versioned API

Backend APIs often have a version prefix like /v2. You can include this in the proxy target so your clients do not need to know about it:

ts
// Requests to /api/users go to http://api.internal:8080/v2/api/users
let apiProxy = createFetchProxy('http://api.internal:8080/v2')

All paths are appended after the target's path. A request to /api/users?page=2 becomes http://api.internal:8080/v2/api/users?page=2. Query strings are preserved automatically.

When a backend service sets a cookie, the cookie often includes a Domain attribute (like Domain=api.internal) and a Path attribute. These attributes tell the browser which requests should include the cookie.

The problem: if the cookie says Domain=api.internal, the browser will not send it back to your proxy at localhost:3000. The domains do not match.

By default, createFetchProxy rewrites cookies to fix this:

# Backend sets:
Set-Cookie: session=abc123; Domain=api.internal; Path=/v2/auth

# Proxy rewrites to:
Set-Cookie: session=abc123; Domain=localhost:3000; Path=/auth
  • Domain rewriting: The Domain attribute is changed to match the client's request host.
  • Path rewriting: The target's path prefix (/v2) is stripped from the cookie's Path.

This happens automatically. The client's browser stores and sends the cookie correctly without knowing a proxy is involved.

If your backend sets cookies that should not be rewritten (for example, if the client talks to the backend directly for some requests), you can disable one or both rewrites:

ts
let proxy = createFetchProxy('http://api.internal:8080', {
  rewriteCookieDomain: false,
  rewriteCookiePath: false,
})

Step 5: Forward Headers

Backend services sometimes need to know the original client's protocol and hostname (for example, to generate correct redirect URLs). Enable xForwardedHeaders to add standard proxy headers:

ts
let apiProxy = createFetchProxy('http://api.internal:8080', {
  xForwardedHeaders: true,
})

This adds two headers to every forwarded request:

  • X-Forwarded-Proto -- The original protocol (e.g., https)
  • X-Forwarded-Host -- The original hostname (e.g., myapp.com)

The backend can use these to construct correct URLs in its responses.

Step 6: Add Authentication to Proxied Requests

Sometimes you need to add headers (like an API key) to requests before they reach the backend. Use the fetch option to customize how requests are sent:

ts
let apiProxy = createFetchProxy('http://api.internal:8080', {
  fetch(input, init) {
    let request = new Request(input, init)
    request.headers.set(
      'Authorization',
      `Bearer ${process.env.API_TOKEN}`
    )
    return globalThis.fetch(request)
  },
})

Every request forwarded through this proxy will include the Authorization header, even though the client did not send it.

Step 7: Proxy to Multiple Backend Services

A real application often has several backend services. Create a separate proxy for each one and route requests accordingly:

ts
// server.ts
import * as http from 'node:http'
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'
import { createRequestListener } from 'remix/node-fetch-server'
import { createFetchProxy } from 'remix/fetch-proxy'

// Backend service proxies
let authProxy = createFetchProxy('http://auth-service:4000', {
  xForwardedHeaders: true,
})

let usersProxy = createFetchProxy('http://users-service:4001', {
  xForwardedHeaders: true,
})

let ordersProxy = createFetchProxy('http://orders-service:4002', {
  xForwardedHeaders: true,
})

let routes = route({
  home: '/',
  auth: '/auth/*path',
  users: '/api/users/*path',
  orders: '/api/orders/*path',
})

let router = createRouter()

router.get(routes.home, () => {
  return Response.json({
    name: 'API Gateway',
    services: ['auth', 'users', 'orders'],
  })
})

router.map(routes.auth, {
  handler({ request }) {
    return authProxy(request)
  },
})

router.map(routes.users, {
  handler({ request }) {
    return usersProxy(request)
  },
})

router.map(routes.orders, {
  handler({ request }) {
    return ordersProxy(request)
  },
})

let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000, () => {
  console.log('API Gateway running at http://localhost:3000')
})

This gives you a single entry point at http://localhost:3000 that fans out to three separate backend services:

Client RequestForwarded To
GET /auth/loginhttp://auth-service:4000/auth/login
GET /api/users/42http://users-service:4001/api/users/42
POST /api/ordershttp://orders-service:4002/api/orders

Step 8: Development Proxy

During development, you might run a frontend dev server (like Vite) on a separate port. Proxy static asset requests to it:

ts
import { createFetchProxy } from 'remix/fetch-proxy'

let viteProxy = createFetchProxy('http://localhost:5173')

// Proxy static assets and HMR to Vite
router.get('/assets/*path', ({ request }) => {
  return viteProxy(request)
})

router.get('/@vite/*path', ({ request }) => {
  return viteProxy(request)
})

This lets you use a single origin during development, avoiding cross-origin issues while still benefiting from Vite's hot module replacement.

What You Learned

  • createFetchProxy() creates a fetch-compatible function that forwards requests to a target server.
  • Cookie Domain and Path attributes are rewritten automatically so proxied cookies work on the client.
  • xForwardedHeaders tells the backend about the original client protocol and host.
  • The custom fetch option lets you add headers (like API keys) to forwarded requests.
  • Multiple proxies can route to different backend services, creating an API gateway.

Next Steps

Released under the MIT License.