DEV Community

Cover image for How to Modernize a Node.js Backend Without Rewriting It (Using Zuplo)
Chidera Humphrey
Chidera Humphrey

Posted on

How to Modernize a Node.js Backend Without Rewriting It (Using Zuplo)

1. Introduction

If you've worked with legacy backends, you'll agree: when they fail, it's not always obvious.

Routing logic duplicates across endpoints. One route checks a header; another forgets entirely. Data transformations? Same story. Price is a number in one route, a string in another. Error responses are a guessing game.

Over time, every "just ship it" decision compounds into a codebase that's easier to break than it is to read.

Imagine adding region support across endpoints with code longer than the API's age 😊. AI coding agents can help, but this is exactly where they make mistakes that become even harder to debug.

You don't have a scaling problem. You have a separation-of-concerns problem.

You don't need a rewrite or more abstraction. You need to extract those "dress codes" (routing and transformation logic) to an API gateway. Let your backend do what it does best: return data.

In this tutorial, you'll take a makeshift legacy Node.js API and modernize it with Zuplo, moving region detection and response transformation out of your handlers and into the gateway.

What You’ll Learn

By the end of this tutorial, you’ll:

  • Tag requests with region context at the gateway so your backend can serve the right dataset without duplicating detection logic across handlers
  • Transform inconsistent backend responses into a clean API contract
  • Move decision-making out of your Node.js server into an API gateway
  • Reduce duplicated logic across endpoints

2. Prerequisites

For this tutorial, I assume you:

  • Know Node.js and TypeScript
  • Have a basic knowledge of how to build an API with Node.js/Express

Also, you'll need a Zuplo account. You can create one using their official website.

Don't worry, the free version is more than enough for what I'm going to show you in this tutorial.

3. Scope

I'd like us to be on the same page as regards the scope of this article.

First, here's what we're covering in this tutorial:

  • Extracting routing and transformation logic into Zuplo
  • Deleting the code snippets we no longer need in the API

Here's what's not covered in this tutorial:

  • Building the API from the ground up. I already have a makeshift Node.js backend for the tutorial.
  • Rate limiting (I'll do a separate tutorial on that if you let me know in the comments)
  • Full production backend. The codes for routing and transformation aren't complex. Your backend will likely involve more complex routing logic, but the concepts remain the same.

The tutorial aims to show you how you can easily modernize an API using an API gateway.

4. The Legacy API (Our starting point)

The code below is the legacy Node.js backend we'll be working with:

import express from 'express';
import cors from 'cors';
import { configDotenv } from 'dotenv';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

configDotenv();

const app = express();
const PORT = process.env.PORT || 3000;
const BACKEND_URL = process.env.BACKEND_URL || `http://localhost:${PORT}`;

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dataFilePath = join(__dirname, 'data.json');
const rawData = readFileSync(dataFilePath, 'utf8');
// the catalogData is an object with two propertie: 'default' for non-EU users and eu for EU users. Each property contains products and orders arrays. This allows us to easily switch between datasets based on the region specified in the request headers.
const catalogData = JSON.parse(rawData) as {
  default: { products: any[]; orders: any[] };
  eu: { products: any[]; orders: any[] };
};

// Gets the appropriate dataset based on the region specified in the request headers. If the region is 'eu', it returns the EU dataset; otherwise, it returns the default dataset.
const getRegionData = (region: string) => {
  return region === 'eu' ? catalogData.eu : catalogData.default;
};

// Middleware
app.use(cors());
app.use(express.json());

// Backend routes (legacy format)

app.get('/backend/products', (req, res) => {
  const items = getRegionData('default').products;
  console.log('[backend] /backend/products called (default)');
  setTimeout(() => {
    res.json({ items });
  }, 180);
});

// This route simulates a backend endpoint for fetching orders in non-EU regions. It retrieves the order data based on the region specified in the request headers. It returns the data after an artificial delay of 210ms.
app.get('/backend/orders', (req, res) => {
  const items = getRegionData('default').orders;
  console.log('[backend] /backend/orders called (default)');
  setTimeout(() => {
    res.json({ items });
  }, 210);
});

// This route simulates a backend endpoint for fetching products in the EU region. It retrieves the product data based on the region specified in the request headers. It returns the data after an artificial delay of 190ms.
app.get('/backend-eu/products', (req, res) => {
  const items = getRegionData('eu').products;
  console.log('[backend] /backend-eu/products called (EU)');
  setTimeout(() => {
    res.json({ items });
  }, 190);
});

// This route simulates a backend endpoint for fetching orders in the EU region. It retrieves the order data based on the region specified in the request headers. It returns the data after an artificial delay of 220ms.
app.get('/backend-eu/orders', (req, res) => {
  const items = getRegionData('eu').orders;
  console.log('[backend] /backend-eu/orders called (EU)');
  setTimeout(() => {
    res.json({ items });
  }, 220);
});

// API routes with duplicated logic

app.get('/api/products', async (req, res) => {
  const regionHeader = req.headers['x-region'] as string;
  const isEu = regionHeader === 'eu';
  let backendUrl = `${BACKEND_URL}/backend/products`;

  if (isEu) {
    backendUrl = `${BACKEND_URL}/backend-eu/products`;
  }

  console.log(`[api][products] calling backend: ${backendUrl}`);

  try {
    const response = await fetch(backendUrl);

    if (!response.ok) {
      console.error('[api] /api/products backend non-OK', response.status);
      return res.status(502).json({ error: 'Product backend service failure' });
    }

    const backendData = await response.json();
    const transformedProducts = backendData.items.map((item: any) => {
      return {
        id: item.id,
        sku: item.sku,
        name: item.product_name,
        brand: item.brand,
        category: item.category,
        description: item.description,
        price: item.price_cents / 100,
        currency: item.currency,
        inventory_count: item.inventory_count,
        rating: item.rating,
        dimensions_mm: item.dimensions_mm,
        weight_grams: item.weight_grams,
        image_url: item.image_url,
        tags: item.tags,
      };
    });

    res.json({ products: transformedProducts });
  } catch (error) {
    console.error('[api] /api/products fetch error', error);
    res.status(500).json({ error: 'Unable to retrieve products from backend' });
  }
});

// This route handles API requests for fetching orders from the frontend. Similar to the products route, it checks the 'x-region' header to determine which backend endpoint to call (EU or default). It then fetches the data from the appropriate backend endpoint, transforms it into a consistent format, and returns it to the client. If there are any errors during the fetch process, it logs the error and returns an appropriate error response.
app.get('/api/orders', async (req, res) => {
  const regionHeader = req.headers['x-region'] as string;

  if (!regionHeader) {
    return res.status(400).json({ error: 'Missing x-region header' });
  }

  const isEu = regionHeader === 'eu';
  let backendUrl = `${BACKEND_URL}/backend/orders`;

  if (isEu) {
    backendUrl = `${BACKEND_URL}/backend-eu/orders`;
  }

  console.log(`Orders API hit -> backend: ${backendUrl}`);

  try {
    const response = await fetch(backendUrl);

    if (!response.ok) {
      console.error('[api] /api/orders backend non-OK', response.status);
      return res.status(502).json({ message: 'Orders service unavailable', code: 'ORDERS_DOWN' });
    }

    const backendData = await response.json();
    const transformedOrders = backendData.items.map((item: any) => {
      return {
        id: item.id,
        order_number: item.order_number,
        status: item.status,
        currency: item.currency,
        total: (item.total_cents / 100).toFixed(2),
        placed_at: item.placed_at,
        customer: item.customer,
        shipping_address: item.shipping_address,
        payment_method: item.payment_method,
        shipping_method: item.shipping_method,
        items: item.items,
      };
    });

    res.json({ orders: transformedOrders });
  } catch (error) {
    console.error('[api] /api/orders fetch error', error);
    res.status(500).json({ error: 'Unable to retrieve orders from backend' });
  }
});

// Start server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Enter fullscreen mode Exit fullscreen mode

This is the current flow:

Current flow of the legacy API

5. Pain points in the current design

The API works. But here's what makes it poorly designed:

1. Routing logic is duplicated across endpoints

Both /api/products and /api/orders:

  • read x-region from the request header
  • detect the region from the request header
  • build the backend URLs

This doesn't scale. When you add a new region support, you'll have to update the handlers.

Fine, you extract the logic into a standalone function. Now every handler calls it, but you're still touching multiple files when routing changes. You've centralized the logic, not the problem.

The problem isn't where the code lives. It's that your backend is still the one making routing decisions. Every new region, every new rule, means touching application code. An API gateway moves that decision upstream before your backend ever sees the request.

2. Transformation logic lives inside handlers

Each handler has its own transformation rules. That adds unnecessary complexity to your codebase.

3. The APIs are doing too much

Each handler:

  • fetches data
  • decides routing
  • transforms responses

Three tasks per handler.

As the backend grows, maintenance becomes more difficult. Instead of focusing on what your API is supposed to do, you're stuck rewriting logic that delivers little business value.

6. The plan

Here’s a simple plan of what we’re going to do:

  • Move region detection to the API gateway
  • Move the transformation to the API gateway
  • Reduce the backend to raw data delivery

In the end, we’ll have a clean backend with the same functionality.

7. Step 1: Strip the backend of the API routes

Delete the API route handlers: /api/products and /api/orders.

Keep only the raw backend routes: /backend/products and /backend/orders.

app.get('/backend/products', (req, res) => {
  const region = req.headers['x-region'] as string || 'default';
  const items = getRegionData(region).products;

  console.log(`[backend] /backend/products called (${region})`);

  setTimeout(() => {
    res.json({ items });
  }, 180);
});

app.get('/backend/orders', (req, res) => {
  const region = req.headers['x-region'] as string || 'default';
  const items = getRegionData(region).orders;

  console.log(`[backend] /backend/orders called (${region})`);

  setTimeout(() => {
    res.json({ items });
  }, 210);
});
Enter fullscreen mode Exit fullscreen mode

The backend only relies on the x-region field of the incoming request header.

Next, you’ll set up Zuplo.

8. Step 2: Using Zuplo as the Gateway

Zuplo is a programmable API gateway. It sits in front of your backend and intercepts every request before it hits your handlers and every response before it reaches the client.

That's exactly the gap we just identified. Your handlers shouldn't decide which region a request belongs to or how to reshape data. Zuplo handles both in the same place, without touching your backend code.

Now that you know what Zuplo is and have created an account, you’ll set up Zuplo to handle routing and transformation logic.

Setting up your project

After you’ve created an account, you’ll see a screen like this:

Zuplo's Getting Started screen

Click on the “Start Building” button.

Adding routes

Select the “Code” tab, select the routes.oas.json file, and click on the “Add Route” button.

Adding routes in Zuplo

The routes.oas.json file is where you’ll manage all your routes.

The Zuplo routes are the public endpoints that clients call to get access to your backend. Zuplo lies in front of your backend, so the API requests don’t hit your backend directly.

Create the /api/product route.

Set the method to GET.

Creating the product route

In the “Request Handler” section:

  • Set the "Handler" to "URL Rewrite". This tells Zuplo to forward the request to a specific URL you define, with no code required.

URL Rewrite handler

In the rewrite URL field, add the following:

For the /api/products route:

${context.custom.backendUrl}/backend/products
Enter fullscreen mode Exit fullscreen mode

Do the same configuration for the order route. The rewrite URL field:

${context.custom.backendUrl}/backend/orders
Enter fullscreen mode Exit fullscreen mode

context.custom.backendUrl is the value your inbound policy sets at runtime. The URL Rewrite handler reads it declaratively, so you don’t need path manipulation code.

Next, you’ll add inbound policies to the routes.

Note: Anytime you make a change in Zuplo, click on the “Save” at the bottom-left corner. Otherwise, your changes won’t reflect.

9. Step 3: Moving routing to Zuplo using inbound policy

In this section, you’ll add inbound policies to the routes.

But what are policies?

Policies are rules; they intercept incoming requests or outgoing responses. Think of it like middleware in Next.js.

Inbound policy intercepts and modifies requests before it reaches the request handler (or next policy in case of multiple policies for the same type of request).

Adding an inbound policy to the /api/products route

In the “Policies” section of the /api/products route, click on the “Add Policy” for “Request”.

Select the “Custom Code Inbound” policy (you’ll be writing the logic of your routing)

Custom code inbound policy

Change “YOUR_MODULE_NAME” to route-by-region.ts (you’ll create the file shortly), and delete the options field.

Click on “Create Policy”.

Creating policy

In the “module” tab on the left panel, click on the + icon and select Inbound policy. Name the file route-by-region.ts.

creating policy file

After creating the file, you should see something like this:

boilerplate Zuplo custom code policy

This is where you’ll add the routing logic.

Before writing the policy code, you'll need to add your backend URL as an environment variable in Zuplo.

Adding your environment variable

In the Zuplo dashboard, navigate to the "Environments" tab and select "Environment Variables". Click "Add Variable" and add the following:

  • Name: BACKEND_URL
  • Value: your deployed backend URL.

Adding Environmental variable

Click "Save". Zuplo makes this available in your policy code via the environment object.

Writing the logic

In the route-by-region.ts file, add the following code snippet:

import { ZuploRequest, ZuploContext, environment } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const continent = context.incomingRequestProperties.continent;
  const region = continent === "EU" ? "eu" : "default";

  context.custom.backendUrl = environment.BACKEND_URL;

  context.log.info(`Routing → continent=${continent}, region=${region}`);

  const headers = new Headers(request.headers);
  headers.set("x-region", region);

  return new Request(request.url, {
    method: request.method,
    headers,
    body: request.body,
  });
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this code:

  1. Zuplo automatically exposes geographic data on context.incomingRequestProperties at the edge. You read the continent value directly; no custom headers or third-party geolocation APIs needed.
  2. The continent value maps to a region string your backend understands. European requests get "eu", everything else gets "default". The gateway stamps this on the request as x-region—your backend reads it and returns the correct dataset. Region detection now lives at the edge, not scattered across handlers.
  3. context.custom.backendUrl is set from your BACKEND_URL environment variable. The URL Rewrite handler picks this up declaratively and appends the correct backend path.

The function forwards the request with an enriched x-region header so your backend knows which dataset to return.

Note: this tutorial uses continent because the backend only needs to distinguish between two regions—EU and non-EU. For a production backend with more granular routing (country-specific pricing, compliance rules, etc.), context.incomingRequestProperties also exposes country, region, city, and more.

Adding an inbound policy to the /api/orders route

To add the inbound policy to the /api/orders route, you’ll do the same thing as you did for the /api/products route.

You don’t need to create a separate file for the routing, as the route-by-region.ts file applies to both routes.

Testing the flow

Use the "Test Route" to test the routing logic.

Send a request without any custom headers. Zuplo will detect your continent from the incoming request and route accordingly.

If you’re inside Europe, your response will look like this:

Note: Zuplo classifies GB (United Kingdom) as continent EU, so readers in the UK will see the European dataset. This is expected behaviour.

{
  "items": [
      {
        "id": 1,
        "sku": "ZU-5001-EU",
        "product_name": "Aurora X1 5G Smartphone",
        "brand": "AstraTech",
        "category": "Smartphones",
        "description": "A premium 5G smartphone with a 6.7-inch AMOLED display, 108MP triple camera system, and 512GB storage for European markets.",
        "price_cents": 84900,
        "currency": "EUR",
        "inventory_count": 210,
        "rating": 4.8,
        "weight_grams": 185,
        "dimensions_mm": { "width": 74, "height": 162, "depth": 7.8 },
        "image_url": "https://cdn.example.com/products/aurora-x1-5g.jpg",
        "tags": ["5G", "AMOLED", "Global", "Dual SIM", "Fast Charging"]
      },
      {
        "id": 2,
        "sku": "ZU-7202-EU",
        "product_name": "NovaBook Pro 14",
        "brand": "AstraTech",
        "category": "Laptops",
        "description": "A lightweight laptop with a 14-inch Retina display, Intel i7, 32GB RAM, and 1TB SSD optimized for European business and creative users.",
        "price_cents": 139900,
        "currency": "EUR",
        "inventory_count": 95,
        "rating": 4.7,
        "weight_grams": 1290,
        "dimensions_mm": { "width": 312, "height": 215, "depth": 14.9 },
        "image_url": "https://cdn.example.com/products/novabook-pro-14.jpg",
        "tags": ["Retina", "Lightweight", "Business", "SSD", "Long Battery"]
      }
    ]
}
Enter fullscreen mode Exit fullscreen mode

If you are outside of Europe, you will go to the default backend, which uses USD as its currency.

{
    "items": [
        {
            "id": 1,
            "sku": "ZU-5001",
            "product_name": "Aurora X1 5G Smartphone",
            "brand": "AstraTech",
            "category": "Smartphones",
            "description": "A premium 5G smartphone with a 6.7-inch AMOLED display, 108MP triple camera system, and 512GB storage for global travelers.",
            "price_cents": 89900,
            "currency": "USD",
            "inventory_count": 320,
            "rating": 4.8,
            "weight_grams": 185,
            "dimensions_mm": {
                "width": 74,
                "height": 162,
                "depth": 7.8
            },
            "image_url": "https://cdn.example.com/products/aurora-x1-5g.jpg",
            "tags": [
                "5G",
                "AMOLED",
                "Global",
                "Dual SIM",
                "Fast Charging"
            ]
        },
        {
            "id": 2,
            "sku": "ZU-7202",
            "product_name": "NovaBook Pro 14",
            "brand": "AstraTech",
            "category": "Laptops",
            "description": "A lightweight laptop with a 14-inch Retina display, Intel i7, 32GB RAM, and 1TB SSD optimized for business, design, and remote work.",
            "price_cents": 149900,
            "currency": "USD",
            "inventory_count": 110,
            "rating": 4.7,
            "weight_grams": 1290,
            "dimensions_mm": {
                "width": 312,
                "height": 215,
                "depth": 14.9
            },
            "image_url": "https://cdn.example.com/products/novabook-pro-14.jpg",
            "tags": [
                "Retina",
                "Lightweight",
                "Business",
                "SSD",
                "Long Battery"
            ]
        },
        {
            "id": 3,
            "sku": "ZU-8805",
            "product_name": "PulseAir Wireless Noise-Cancelling Headphones",
            "brand": "AstraAudio",
            "category": "Audio",
            "description": "Premium over-ear headphones with adaptive noise cancellation, 45-hour battery life, and multi-device Bluetooth pairing.",
            "price_cents": 25900,
            "currency": "USD",
            "inventory_count": 540,
            "rating": 4.6,
            "weight_grams": 290,
            "dimensions_mm": {
                "width": 190,
                "height": 220,
                "depth": 85
            },
            "image_url": "https://cdn.example.com/products/pulseair-headphones.jpg",
            "tags": [
                "Noise Cancelling",
                "Bluetooth",
                "Travel",
                "Wireless",
                "Long Battery"
            ]
        },
        {
            "id": 4,
            "sku": "ZU-3104",
            "product_name": "Orbit Active Smartwatch",
            "brand": "AstraWear",
            "category": "Wearables",
            "description": "A durable smartwatch with GPS, heart rate tracking, sleep analytics, and an always-on display for day-to-day and outdoor use.",
            "price_cents": 17900,
            "currency": "USD",
            "inventory_count": 270,
            "rating": 4.5,
            "weight_grams": 52,
            "dimensions_mm": {
                "width": 44,
                "height": 44,
                "depth": 12.5
            },
            "image_url": "https://cdn.example.com/products/orbit-active-smartwatch.jpg",
            "tags": [
                "GPS",
                "Fitness",
                "Health",
                "Water Resistant",
                "Wearable"
            ]
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

The same thing applies to the /api/orders route.

In the next section, you’re going to add an outbound policy to transform the response returned by the backend.

10. Step 4: Move transformation to Zuplo using outbound policies

In this section, you’ll add outbound policies to each route to transform the response returned by the backend.

What is an outbound policy?

Outbound policy intercepts the response returned from the backend. Think of it like a packager in a factory, packaging the raw, naked cookies with a beautiful wrapper.

Outbound policies let you transform the data returned from your backend before sending it to the client.

With outbound policies, you can:

  • Convert data formats: transform XML from a legacy SOAP API into JSON for modern web clients
  • Store response data: automatically cache or archive response payloads to cloud storage (e.g., S3, R2) for audit trails or replay
  • Enrich or filter responses: add computed fields (like is_premium) or redact sensitive data (like ssn or password_hash) before it leaves the gateway
  • Log or audit: send response metadata (status, latency, size) to monitoring services like Datadog without burdening your backend

Adding an outbound policy to /api/products route

In the “Policies” section of the /api/products route, click on “Add Policy” for “Response”.

adding outbound policy

Search for “Transform Body Response” and select it.

Note: "Transform Body Response" is not a config-driven policy. It's a template that scaffolds a custom-code outbound policy. It gives you a starting TypeScript file where you write your own transformation logic. Think of it like a code starter, not a settings panel.

In the configuration file (transform-body-outbound), add the following code:

import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
) {
  const data = await response.json();

  const transformed = data.items.map((item: any) => ({
    id: item.id,
    sku: item.sku,
    name: item.product_name,
    brand: item.brand,
    category: item.category,
    description: item.description,
    price: item.price_cents / 100,
    currency: item.currency,
    inventory_count: item.inventory_count,
    rating: item.rating,
    dimensions_mm: item.dimensions_mm,
    weight_grams: item.weight_grams,
    image_url: item.image_url,
    tags: item.tags,
  }));

  return new Response(
    JSON.stringify({ products: transformed }),
    {
      status: response.status,
      headers: response.headers,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this code:

  1. You defined a function that intercepts an outgoing API response and restructures its shape before sending it to the client. The function first parses the original response JSON and extracts the items array from the response body.
  2. The function maps each item in the items array to a cleaner, more consistent product object. Key transformations include renaming product_name to name, converting price_cents from cents to dollars by dividing by 100, and preserving only the fields you actually need for your frontend.
  3. After transformation, the function returns a new Response object containing the restructured data under a products key, while preserving the original response's status code and headers for proper HTTP handling.

This keeps your frontend contract clean without touching your upstream API.

Click on “Create Policy” to add the outbound policy.

Adding an outbound policy to /api/orders route

Adding an outbound policy to the /api/orders route follows the same process as that of the /api/products route.

However, in the configuration file, add the following code:

import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
) {
  const data = await response.json();

  const transformed = data.items.map((item: any) => ({
    id: item.id,
    order_number: item.order_number,
    status: item.status,
    currency: item.currency,
    total: item.total_cents / 100,
    placed_at: item.placed_at,
    customer: item.customer,
    shipping_address: item.shipping_address,
    payment_method: item.payment_method,
    shipping_method: item.shipping_method,
    items: item.items,
  }));

  return new Response(
    JSON.stringify({ orders: transformed }),
    {
      status: response.status,
      headers: response.headers,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this code:

  1. You defined a function that intercepts an outgoing API response and restructures order data before sending it to the client. The function parses the original response JSON and extracts the items array containing order records.
  2. The function maps each order item to a cleaner, frontend-friendly structure. Key transformations include converting total_cents from cents to dollars by dividing by 100, renaming fields for consistency, and preserving only the essential order details like customer info, shipping address, and line items.
  3. After transformation, the function returns a new Response object containing the restructured data under an orders key, while maintaining the original HTTP status code and headers for proper response handling.

This gives your frontend a clean, usable order format without touching your order management system.

Click on “Create Policy" to create the outbound policy.

Now that you've added the transformation logic to both routes, you'll test the full workflow in the next section.

Testing the full workflow

Now that Zuplo handles the routing and response transformation, let's test the workflow.

But before that, here's a visual of what's happening under the hood:

API request flow with Zuplo

Testing the /api/products route

Click on "Test Route”

Test route

Send the request.

If you're inside Europe, the API should return something like this (currency in EUR):

{
  "products": [
    {
      "id": 1,
      "sku": "ZU-5001-EU",
      "name": "Aurora X1 5G Smartphone",
      "brand": "AstraTech",
      "category": "Smartphones",
      "description": "A premium 5G smartphone with a 6.7-inch AMOLED display, 108MP triple camera system, and 512GB storage for European markets.",
      "price": 849,
      "currency": "EUR",
      "inventory_count": 210,
      "rating": 4.8,
      "dimensions_mm": {
        "width": 74,
        "height": 162,
        "depth": 7.8
      },
      "weight_grams": 185,
      "image_url": "https://cdn.example.com/products/aurora-x1-5g.jpg",
      "tags": [
        "5G",
        "AMOLED",
        "Global",
        "Dual SIM",
        "Fast Charging"
      ]
    },
    {
      "id": 2,
      "sku": "ZU-7202-EU",
      "name": "NovaBook Pro 14",
      "brand": "AstraTech",
      "category": "Laptops",
      "description": "A lightweight laptop with a 14-inch Retina display, Intel i7, 32GB RAM, and 1TB SSD optimized for European business and creative users.",
      "price": 1399,
      "currency": "EUR",
      "inventory_count": 95,
      "rating": 4.7,
      "dimensions_mm": {
        "width": 312,
        "height": 215,
        "depth": 14.9
      },
      "weight_grams": 1290,
      "image_url": "https://cdn.example.com/products/novabook-pro-14.jpg",
      "tags": [
        "Retina",
        "Lightweight",
        "Business",
        "SSD",
        "Long Battery"
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

If you're outside Europe, your output should look like this (currency in USD):

{
  "products": [
    {
      "id": 1,
      "sku": "ZU-5001",
      "name": "Aurora X1 5G Smartphone",
      "brand": "AstraTech",
      "category": "Smartphones",
      "description": "A premium 5G smartphone with a 6.7-inch AMOLED display, 108MP triple camera system, and 512GB storage for global travelers.",
      "price": 899,
      "currency": "USD",
      "inventory_count": 320,
      "rating": 4.8,
      "dimensions_mm": {
        "width": 74,
        "height": 162,
        "depth": 7.8
      },
      "weight_grams": 185,
      "image_url": "https://cdn.example.com/products/aurora-x1-5g.jpg",
      "tags": [
        "5G",
        "AMOLED",
        "Global",
        "Dual SIM",
        "Fast Charging"
      ]
    },
    {
      "id": 2,
      "sku": "ZU-7202",
      "name": "NovaBook Pro 14",
      "brand": "AstraTech",
      "category": "Laptops",
      "description": "A lightweight laptop with a 14-inch Retina display, Intel i7, 32GB RAM, and 1TB SSD optimized for business, design, and remote work.",
      "price": 1499,
      "currency": "USD",
      "inventory_count": 110,
      "rating": 4.7,
      "dimensions_mm": {
        "width": 312,
        "height": 215,
        "depth": 14.9
      },
      "weight_grams": 1290,
      "image_url": "https://cdn.example.com/products/novabook-pro-14.jpg",
      "tags": [
        "Retina",
        "Lightweight",
        "Business",
        "SSD",
        "Long Battery"
      ]
    },
    {
      "id": 3,
      "sku": "ZU-8805",
      "name": "PulseAir Wireless Noise-Cancelling Headphones",
      "brand": "AstraAudio",
      "category": "Audio",
      "description": "Premium over-ear headphones with adaptive noise cancellation, 45-hour battery life, and multi-device Bluetooth pairing.",
      "price": 259,
      "currency": "USD",
      "inventory_count": 540,
      "rating": 4.6,
      "dimensions_mm": {
        "width": 190,
        "height": 220,
        "depth": 85
      },
      "weight_grams": 290,
      "image_url": "https://cdn.example.com/products/pulseair-headphones.jpg",
      "tags": [
        "Noise Cancelling",
        "Bluetooth",
        "Travel",
        "Wireless",
        "Long Battery"
      ]
    },
    {
      "id": 4,
      "sku": "ZU-3104",
      "name": "Orbit Active Smartwatch",
      "brand": "AstraWear",
      "category": "Wearables",
      "description": "A durable smartwatch with GPS, heart rate tracking, sleep analytics, and an always-on display for day-to-day and outdoor use.",
      "price": 179,
      "currency": "USD",
      "inventory_count": 270,
      "rating": 4.5,
      "dimensions_mm": {
        "width": 44,
        "height": 44,
        "depth": 12.5
      },
      "weight_grams": 52,
      "image_url": "https://cdn.example.com/products/orbit-active-smartwatch.jpg",
      "tags": [
        "GPS",
        "Fitness",
        "Health",
        "Water Resistant",
        "Wearable"
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

You can see that you're routed to a different backend depending on your region.

Also, the data returned is transformed into something cleaner.

Testing the /api/orders route

You'll do the same thing as in the /api/products route.

If you're inside Europe, here's the returned data:

{
  "orders": [
    {
      "id": 1,
      "order_number": "ZP-EU-200111",
      "status": "fulfilled",
      "currency": "EUR",
      "total": 1698,
      "placed_at": "2026-04-19T13:18:00Z",
      "customer": {
        "id": 201,
        "name": "Sofia Schmidt",
        "email": "sofia.schmidt@example.de",
        "country": "Germany"
      },
      "shipping_address": {
        "line1": "Bahnhofstrasse 21",
        "city": "Berlin",
        "postal_code": "10115",
        "country": "Germany"
      },
      "payment_method": "SEPA",
      "shipping_method": "Express EU",
      "items": [
        {
          "product_id": 1,
          "sku": "ZU-5001-EU",
          "name": "Aurora X1 5G Smartphone",
          "quantity": 1,
          "unit_price_cents": 84900,
          "total_price_cents": 84900
        },
        {
          "product_id": 2,
          "sku": "ZU-7202-EU",
          "name": "NovaBook Pro 14",
          "quantity": 1,
          "unit_price_cents": 139900,
          "total_price_cents": 139900
        }
      ]
    },
    {
      "id": 2,
      "order_number": "ZP-EU-200112",
      "status": "processing",
      "currency": "EUR",
      "total": 849,
      "placed_at": "2026-04-24T09:40:00Z",
      "customer": {
        "id": 202,
        "name": "Émile Rousseau",
        "email": "emile.rousseau@example.fr",
        "country": "France"
      },
      "shipping_address": {
        "line1": "14 Rue de la Paix",
        "city": "Paris",
        "postal_code": "75002",
        "country": "France"
      },
      "payment_method": "Carte Bancaire",
      "shipping_method": "Standard EU",
      "items": [
        {
          "product_id": 1,
          "sku": "ZU-5001-EU",
          "name": "Aurora X1 5G Smartphone",
          "quantity": 1,
          "unit_price_cents": 84900,
          "total_price_cents": 84900
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

If you're outside Europe, here's the returned data:

{
  "orders": [
    {
      "id": 1,
      "order_number": "ZP-1000432",
      "status": "fulfilled",
      "currency": "USD",
      "total": 2298,
      "placed_at": "2026-04-18T10:22:00Z",
      "customer": {
        "id": 101,
        "name": "Mia Anders",
        "email": "mia.anders@example.com",
        "country": "United States"
      },
      "shipping_address": {
        "line1": "1288 Parkview Lane",
        "city": "Austin",
        "state": "TX",
        "postal_code": "78701",
        "country": "United States"
      },
      "payment_method": "VISA",
      "shipping_method": "Express International",
      "items": [
        {
          "product_id": 1,
          "sku": "ZU-5001",
          "name": "Aurora X1 5G Smartphone",
          "quantity": 1,
          "unit_price_cents": 89900,
          "total_price_cents": 89900
        },
        {
          "product_id": 3,
          "sku": "ZU-8805",
          "name": "PulseAir Wireless Noise-Cancelling Headphones",
          "quantity": 2,
          "unit_price_cents": 25900,
          "total_price_cents": 51800
        },
        {
          "product_id": 4,
          "sku": "ZU-3104",
          "name": "Orbit Active Smartwatch",
          "quantity": 1,
          "unit_price_cents": 17900,
          "total_price_cents": 17900
        }
      ]
    },
    {
      "id": 2,
      "order_number": "ZP-1000433",
      "status": "processing",
      "currency": "USD",
      "total": 1499,
      "placed_at": "2026-04-21T15:48:00Z",
      "customer": {
        "id": 102,
        "name": "Amir Hosseini",
        "email": "amir.hosseini@example.com",
        "country": "United Kingdom"
      },
      "shipping_address": {
        "line1": "Flat 4, 32 Kingsway",
        "city": "London",
        "postal_code": "WC2B 6NH",
        "country": "United Kingdom"
      },
      "payment_method": "Mastercard",
      "shipping_method": "Standard Europe",
      "items": [
        {
          "product_id": 2,
          "sku": "ZU-7202",
          "name": "NovaBook Pro 14",
          "quantity": 1,
          "unit_price_cents": 149900,
          "total_price_cents": 149900
        }
      ]
    },
    {
      "id": 3,
      "order_number": "ZP-1000434",
      "status": "pending",
      "currency": "USD",
      "total": 1078,
      "placed_at": "2026-04-23T08:05:00Z",
      "customer": {
        "id": 103,
        "name": "Lucía Martínez",
        "email": "lucia.martinez@example.com",
        "country": "Spain"
      },
      "shipping_address": {
        "line1": "Calle Mayor 18",
        "city": "Madrid",
        "postal_code": "28013",
        "country": "Spain"
      },
      "payment_method": "PayPal",
      "shipping_method": "Priority Europe",
      "items": [
        {
          "product_id": 1,
          "sku": "ZU-5001",
          "name": "Aurora X1 5G Smartphone",
          "quantity": 1,
          "unit_price_cents": 89900,
          "total_price_cents": 89900
        },
        {
          "product_id": 4,
          "sku": "ZU-3104",
          "name": "Orbit Active Smartwatch",
          "quantity": 1,
          "unit_price_cents": 17900,
          "total_price_cents": 17900
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

You'll have to deploy your Zuplo project to expose the endpoints to the public.

Follow these steps to deploy your Zuplo project. It's pretty straightforward.

Congratulations! You’ve modernized your Node.js backend. All routing and response transformation happens on the edge, while your backend focuses on fetching and returning data.

Modernize without rebuilding

Your API still does what it always did. What changed is where certain responsibilities live.

Routing and response shaping don't belong in your handlers. They belong at the gateway.

Most legacy APIs aren't broken. They're just doing too much. Validation in handlers, transformation scattered everywhere; concerns that have nothing to do with serving data.

An API gateway peels that off.

Your production codebase will have more routes and more edge cases. But the approach applies.

Move one concern out at a time. Keep the backend focused on what it's good at.

Next Step

You now have a clean API layer sitting in front of your backend.

From here, you can:

  • Add authentication at the gateway
  • Introduce rate limiting without backend changes
  • Cache responses for performance
  • Monetize access to your API

The key shift is that your backend no longer carries these responsibilities.

Resources

Top comments (0)