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}`);
});
This is the current flow:
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-regionfrom 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);
});
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:
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.
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.
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.
In the rewrite URL field, add the following:
For the /api/products route:
${context.custom.backendUrl}/backend/products
Do the same configuration for the order route. The rewrite URL field:
${context.custom.backendUrl}/backend/orders
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)
Change “YOUR_MODULE_NAME” to route-by-region.ts (you’ll create the file shortly), and delete the options field.
Click on “Create Policy”.
In the “module” tab on the left panel, click on the + icon and select Inbound policy. Name the file route-by-region.ts.
After creating the file, you should see something like this:
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.
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,
});
}
Let's break down what's happening in this code:
- Zuplo automatically exposes geographic data on
context.incomingRequestPropertiesat the edge. You read thecontinentvalue directly; no custom headers or third-party geolocation APIs needed. - 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 asx-region—your backend reads it and returns the correct dataset. Region detection now lives at the edge, not scattered across handlers. -
context.custom.backendUrlis set from yourBACKEND_URLenvironment 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"]
}
]
}
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"
]
}
]
}
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 (likessnorpassword_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”.
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,
}
);
}
Let's break down what's happening in this code:
- 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
itemsarray from the response body. - The function maps each item in the
itemsarray to a cleaner, more consistent product object. Key transformations include renamingproduct_nametoname, convertingprice_centsfrom cents to dollars by dividing by 100, and preserving only the fields you actually need for your frontend. - After transformation, the function returns a new Response object containing the restructured data under a
productskey, 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,
}
);
}
Let's break down what's happening in this code:
- 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
itemsarray containing order records. - The function maps each order item to a cleaner, frontend-friendly structure. Key transformations include converting
total_centsfrom 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. - After transformation, the function returns a new Response object containing the restructured data under an
orderskey, 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:
Testing the /api/products route
Click on "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"
]
}
]
}
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"
]
}
]
}
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
}
]
}
]
}
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
}
]
}
]
}
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.













Top comments (0)