Redirections
Integration Guides

Node.js

Integrate redirect lookups with Express and Fastify using middleware and the Edge Query API

Overview

This guide shows you how to integrate redirect lookups into your Node.js application using Express or Fastify. You'll learn both real-time API fetching and startup-time syncing with in-memory caching.

Estimated time: ~15 minutes

Prerequisites:

  • Node.js 20+ or 22 LTS
  • Express 4.x/5.x or Fastify 4.x+ project
  • API key from the dashboard
  • Project ID from your redirect project

Target versions:

  • Node.js 20/22 LTS (native fetch support)
  • Express 4.x/5.x
  • Fastify 4.x+

Request Flow

Here's how redirect lookups work in Node.js middleware:

flowchart LR
    A[Client Request] --> B[Express/Fastify]
    B --> C[Early Middleware/Hook]
    C --> D{Redirect Check}
    D -->|Match Found| E[res.redirect / reply.redirect]
    D -->|No Match| F[next / return]
    D -->|Error| F
    E --> G[Redirect Response]
    F --> H[Continue to Route Handler]

Express: Pattern 1 — Real-Time Middleware

The real-time pattern calls the Edge Query API on every request to check for redirects. This keeps redirects always up-to-date.

Step 1: Create Redirect Middleware

Create a middleware function that fetches redirect rules:

middleware/redirects.js
async function redirectMiddleware(req, res, next) {
  const path = req.path;

  try {
    const response = await fetch(
      `https://api.3xx.app/v1/lookup?path=${encodeURIComponent(path)}&projectId=${process.env.REDIRECTIONS_PROJECT_ID}`,
      {
        headers: {
          'X-API-Key': process.env.REDIRECTIONS_API_KEY,
        },
      }
    );

    if (response.status === 200) {
      const data = await response.json();
      return res.redirect(data.statusCode, data.destination);
    }

    // 204 = no match, pass through
    next();
  } catch (error) {
    console.error('Redirect lookup failed:', error);
    // On error, pass through to avoid breaking the site
    next();
  }
}

module.exports = redirectMiddleware;

Native fetch: Node.js 18+ includes native fetch() support. No need for axios or node-fetch dependencies.

Step 2: Register Middleware Early

In your main app file, register the middleware before route handlers:

app.js
const express = require('express');
const redirectMiddleware = require('./middleware/redirects');

const app = express();

// Register redirect middleware EARLY (before routes)
app.use(redirectMiddleware);

// Your route handlers
app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

Middleware order matters. The redirect middleware must come before your route handlers. If a route handler runs first, the middleware won't execute.

Step 3: Configure Environment Variables

Create a .env file in your project root:

.env
REDIRECTIONS_API_KEY=YOUR_API_KEY
REDIRECTIONS_PROJECT_ID=YOUR_PROJECT_ID

Load environment variables using dotenv (Express 4.x) or native .env support (Node.js 20+):

// Option 1: dotenv (if using Express 4.x)
require('dotenv').config();

// Option 2: Node.js 20+ native support
// Run with: node --env-file=.env app.js

Step 4: Test the Integration

Start your server:

node app.js

Test with curl:

# Test a redirect
curl -I http://localhost:3000/test-path

# Should return:
# HTTP/1.1 301 Moved Permanently
# Location: https://example.com/destination

Express: Pattern 2 — Startup-Time Sync + In-Memory Map

For high-traffic applications, fetch redirects once at startup and cache them in memory. This eliminates API calls on every request.

Step 1: Create Load Function

Create a function to fetch and parse redirects:

lib/redirects.js
const redirectsMap = new Map();

async function loadRedirects() {
  const apiKey = process.env.REDIRECTIONS_API_KEY;
  const projectId = process.env.REDIRECTIONS_PROJECT_ID;

  if (!apiKey || !projectId) {
    throw new Error('Missing REDIRECTIONS_API_KEY or REDIRECTIONS_PROJECT_ID');
  }

  console.log('Loading redirects from API...');

  const response = await fetch(
    `https://api.3xx.app/v1/export?format=csv&projectId=${projectId}`,
    {
      headers: {
        'X-API-Key': apiKey,
      },
    }
  );

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${response.statusText}`);
  }

  const csv = await response.text();
  const lines = csv.split('\n').slice(1); // Skip header

  redirectsMap.clear();

  for (const line of lines) {
    if (!line.trim()) continue;

    const [path, destination, statusCode] = line.split(',');
    redirectsMap.set(path, {
      destination,
      statusCode: parseInt(statusCode),
    });
  }

  console.log(`✓ Loaded ${redirectsMap.size} redirects`);
}

function syncRedirectMiddleware(req, res, next) {
  const redirect = redirectsMap.get(req.path);

  if (redirect) {
    return res.redirect(redirect.statusCode, redirect.destination);
  }

  next();
}

module.exports = { loadRedirects, syncRedirectMiddleware };

Step 2: Load at Startup

Load redirects before starting the server:

app.js
const express = require('express');
const { loadRedirects, syncRedirectMiddleware } = require('./lib/redirects');

const app = express();

app.use(syncRedirectMiddleware);

app.get('/', (req, res) => {
  res.send('Hello World');
});

async function start() {
  // Load redirects before starting server
  await loadRedirects();

  // Refresh every 5 minutes
  setInterval(() => {
    loadRedirects().catch(err => {
      console.error('Failed to refresh redirects:', err);
    });
  }, 5 * 60 * 1000);

  app.listen(3000, () => {
    console.log('Server listening on port 3000');
  });
}

start().catch(err => {
  console.error('Startup failed:', err);
  process.exit(1);
});

Step 3: Test the Sync

Start your server and watch the console:

node app.js
# Output:
# Loading redirects from API...
# ✓ Loaded 42 redirects
# Server listening on port 3000

Test redirects:

curl -I http://localhost:3000/test-path

Add a new redirect in the dashboard, wait 5 minutes, then test again to verify the refresh works.

Fastify: Pattern 1 — Real-Time Hook

Fastify uses hooks instead of middleware. The onRequest hook is perfect for redirect checking.

Step 1: Create Redirect Hook

plugins/redirects.js
async function redirectPlugin(fastify, options) {
  fastify.addHook('onRequest', async (request, reply) => {
    const path = request.url;

    try {
      const response = await fetch(
        `https://api.3xx.app/v1/lookup?path=${encodeURIComponent(path)}&projectId=${process.env.REDIRECTIONS_PROJECT_ID}`,
        {
          headers: {
            'X-API-Key': process.env.REDIRECTIONS_API_KEY,
          },
        }
      );

      if (response.status === 200) {
        const data = await response.json();
        reply.redirect(data.statusCode, data.destination);
        return; // Hook complete
      }

      // 204 = no match, continue
    } catch (error) {
      console.error('Redirect lookup failed:', error);
      // On error, continue to avoid breaking the site
    }
  });
}

module.exports = redirectPlugin;

Fastify hooks don't use next(): In Fastify, hooks either send a response (like reply.redirect()) or return to continue the request lifecycle.

Step 2: Register Plugin

Register the plugin in your main app:

app.js
const fastify = require('fastify')({ logger: true });
const redirectPlugin = require('./plugins/redirects');

// Register redirect plugin
fastify.register(redirectPlugin);

// Your routes
fastify.get('/', async (request, reply) => {
  return { hello: 'world' };
});

async function start() {
  try {
    await fastify.listen({ port: 3000 });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
}

start();

Step 3: Test with Curl

curl -I http://localhost:3000/test-path

Fastify: Pattern 2 — Startup-Time Sync + In-Memory Map

For high-traffic Fastify applications, sync redirects at startup:

Step 1: Create Sync Plugin

plugins/redirects-sync.js
const redirectsMap = new Map();

async function loadRedirects() {
  const apiKey = process.env.REDIRECTIONS_API_KEY;
  const projectId = process.env.REDIRECTIONS_PROJECT_ID;

  if (!apiKey || !projectId) {
    throw new Error('Missing REDIRECTIONS_API_KEY or REDIRECTIONS_PROJECT_ID');
  }

  console.log('Loading redirects from API...');

  const response = await fetch(
    `https://api.3xx.app/v1/export?format=csv&projectId=${projectId}`,
    {
      headers: {
        'X-API-Key': apiKey,
      },
    }
  );

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${response.statusText}`);
  }

  const csv = await response.text();
  const lines = csv.split('\n').slice(1); // Skip header

  redirectsMap.clear();

  for (const line of lines) {
    if (!line.trim()) continue;

    const [path, destination, statusCode] = line.split(',');
    redirectsMap.set(path, {
      destination,
      statusCode: parseInt(statusCode),
    });
  }

  console.log(`✓ Loaded ${redirectsMap.size} redirects`);
}

async function redirectsSyncPlugin(fastify, options) {
  // Load at startup
  await loadRedirects();

  // Refresh every 5 minutes
  setInterval(() => {
    loadRedirects().catch(err => {
      fastify.log.error('Failed to refresh redirects:', err);
    });
  }, 5 * 60 * 1000);

  // Hook to check redirects
  fastify.addHook('onRequest', async (request, reply) => {
    const redirect = redirectsMap.get(request.url);

    if (redirect) {
      reply.redirect(redirect.statusCode, redirect.destination);
    }
  });
}

module.exports = redirectsSyncPlugin;

Step 2: Register Plugin

app.js
const fastify = require('fastify')({ logger: true });
const redirectsSyncPlugin = require('./plugins/redirects-sync');

fastify.register(redirectsSyncPlugin);

fastify.get('/', async (request, reply) => {
  return { hello: 'world' };
});

async function start() {
  try {
    await fastify.listen({ port: 3000 });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
}

start();

Step 3: Test the Sync

Start the server and verify redirects load:

node app.js
# Output:
# Loading redirects from API...
# ✓ Loaded 42 redirects
# Server listening at http://127.0.0.1:3000

Shared Patterns

TypeScript Types

For TypeScript projects, define types for API responses:

types/redirects.ts
export interface RedirectResponse {
  path: string;
  destination: string;
  statusCode: number;
  projectId: string;
}

export interface RedirectRule {
  destination: string;
  statusCode: number;
}

Use these types in your middleware/hooks:

import type { RedirectResponse, RedirectRule } from './types/redirects';

const response = await fetch(/* ... */);
const data: RedirectResponse = await response.json();

Error Handling

Always wrap API calls in try/catch and continue on error:

try {
  const response = await fetch(/* ... */);
  // Handle response
} catch (error) {
  console.error('Redirect lookup failed:', error);
  // Don't break the request - pass through
  next(); // Express
  return; // Fastify
}

Environment Variable Configuration

Both Express and Fastify can use the same .env file:

.env
REDIRECTIONS_API_KEY=YOUR_API_KEY
REDIRECTIONS_PROJECT_ID=YOUR_PROJECT_ID

Load with dotenv or Node.js 20+ native support:

// Option 1: dotenv
require('dotenv').config();

// Option 2: Node.js 20+ --env-file flag
// Run: node --env-file=.env app.js

Fallback Behavior

Implement graceful fallback to avoid breaking your application:

Express:

try {
  // API call
} catch (error) {
  console.error('Redirect lookup failed:', error);
  next(); // Continue to next middleware/route
}

Fastify:

try {
  // API call
} catch (error) {
  fastify.log.error('Redirect lookup failed:', error);
  // Return without sending response = continue
}

Advanced: Local Caching

For even better performance, implement local caching with TTL:

const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async function cachedLookup(path) {
  const cached = cache.get(path);

  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data;
  }

  const response = await fetch(/* API call */);
  const data = await response.json();

  cache.set(path, {
    data,
    timestamp: Date.now(),
  });

  return data;
}

Testing

Express Testing

# Start server
node app.js

# Test redirect
curl -I http://localhost:3000/test-path

# Test non-existent path (should pass through)
curl -I http://localhost:3000/no-redirect

# Test server restart with sync pattern
# (add redirect in dashboard, restart server, verify new redirect works)

Fastify Testing

# Start server
node app.js

# Test redirect
curl -I http://localhost:3000/test-path

# Test with verbose output
curl -v http://localhost:3000/test-path

# Verify sync refresh (after 5 minutes)
# Add new redirect in dashboard, wait 5 min, test

Troubleshooting

Middleware Order (Express)

Symptom: Redirects don't work, routes handle requests first.

Cause: Redirect middleware registered after route handlers.

Fix: Move app.use(redirectMiddleware) before route definitions.

Async Middleware in Express 4.x

Symptom: Errors in async middleware aren't caught.

Cause: Express 4.x doesn't natively support async middleware error handling.

Fix: Wrap async middleware or use Express 5.x:

// Express 4.x: wrap in error handler
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

app.use(asyncHandler(redirectMiddleware));

Express 5.x handles async middleware natively.

Fastify Hook Order

Symptom: Redirects execute after other logic.

Cause: Plugin registered too late.

Fix: Register redirect plugin early, before route-specific plugins.

Fetch Not Available

Symptom: fetch is not defined error.

Cause: Node.js version < 18.

Fix: Upgrade to Node.js 18+ or use a fetch polyfill:

// Install: npm install node-fetch
const fetch = require('node-fetch');

For more help, see the Troubleshooting Guide.