The Backend-for-Frontend pattern using NextJS: A Step-by-Step Guide

The Backends-for-Frontends pattern might be exactly what you need to avoid monolithic Backend APIs and Frontends bloated with business logic. Let's implement one in Next.js, using WunderGraph as a BFF framework.

Picture this: you've just developed and deployed your dream app. (Congratulations!) To serve your desktop/web browser UI, you've probably built some sort of backend...but what happens when you grow, and perhaps start offering a mobile app to serve your growing userbase? That backend now has to be jury-rigged to become more of a general-purpose solution, serving more than one platform (or more accurately, user experience).

Now, things get a little less fun, for both devex and developer velocity in your org:

  • Your mobile app has needs and requirements that are significantly different from your desktop app, to the point where your backend is now flooded with competing requirements.
  • To ease some of the pressure on the backend team, you end up writing complex business logic in the frontend, tightly coupling your UI layer to it. The result? Messy codebases that are difficult to read and to maintain.
  • Sure, separate UI/UX teams working on each frontend will speed up development...until both find that the backend team is the bottleneck, not being able to come up with feature adds/refactors/bugfixes that balance requirements of both platforms without breaking functionality for both user bases.
  • You also end up with overfetching/underfetching issues on at least some of your supported platforms. The mobile UI/UX doesn't need the data that your desktop app does, but processing and sending out bespoke data for each client on the backend layer isn't an option - that's going to slow things down further.

At this point, you're probably thinking something along these lines:

Ugh, this wouldn't be a problem if I could just have a separate 'backend' for each of my frontends! Perhaps one such API per frontend, tailor made for it, and maintained by the team working on that frontend so they have full control over how they consume data internally.

Congratulations, you've just described the Backends-for-Frontends (BFF) pattern!

Pioneered by SoundCloud, it makes developing for varied user experiences (a desktop/web browser, a mobile app, a voice-based assistant, a smartwatch, and so on.) much more manageable in terms of orchestrating and normalizing data flows, without your backend services becoming inundated with requests or your frontend code having to include complex business logic and in-code data JOINs.

For this tutorial, let's build a simple Dashboard for an eCommerce app that we'll implement using the BFF pattern. We'll use Next.js as our frontend, two simple microservices for our data, and WunderGraph as our Backend-for-Frontend.

image

image

The Products pages, using the Products microservice for their data.

The Architecture

Before we start coding, here's a quick diagram of our architecture, so you know what you'll be building.

image

And here's what our tech stack will look like:

Express.js

To build our backend as microservices, each an independent API, with its own OpenAPI V3 specification. This is NOT a tutorial about microservices, so to keep things simple for this tutorial, we'll limit our backend to 2 microservices - Products, and Orders -  and mock the downstream data calls they make, rather than interfacing with an actual database.

Next.js (with TypeScript)

For our frontend.

WunderGraph as our Backend-for-Frontend server.

You can think of this as an API gateway that serves as the only 'backend' that your frontend can see, coordinating all calls between the two on a request, transforming data according to unique requirements, and optionally even incorporating auth and middleware, instead of having your frontend be concerned with any of that, or interacting directly with your backend APIs.

WunderGraph works by consolidating data from the datasources that you name in its config file (Databases or API integrations. For this tutorial, it's microservices as OpenAPI REST) into a unified API layer that you can then use GraphQL queries or TypeScript Operations to get data out of, massage that data as needed for each separate user experience that you want to provide, and deliver it to the Next.js frontend via auto-generated, type-safe data fetching hooks.

TailwindCSS for styling.

Here are the instructions for setting it up for Next.js. I'm using it because I love utility-first CSS; but you could use literally any styling solution you prefer. Tailwind classes translate quite well because it's literally just a different way to write vanilla CSS.

Let's dive right in!

The Code

1. The Backend - Microservices with Express.js

First of all, don't let "microservices" scare you. This is just an architectural style where a large application (our Express.js backend) is divided into small, independent, and loosely-coupled services (here, one Express.js app for the Product Catalog, and another for Orders). In real world projects, each microservice would be managed by a separate team, often with their own database.

./backend/products.ts

import express, { Application, Request, Response } from "express";
import { products } from "./mockdata/tutorialData";
const app: Application = express();
const PORT: number = 3001;
// Endpoint to get all products
app.get("/products", (req: Request, res: Response) => {
  res.json(products);
});
// Endpoint to get product by ID
app.get("/products/:id", (req: Request, res: Response) => {
  const product = products.find((p) => p.id === parseInt(req.params.id));
  if (!product) {
    res.status(404).send("Product not found");
  } else {
    res.json(product);
  }
});
// Start server
app.listen(PORT, () => {
  console.log(`Product catalog service started on port ${PORT}`);
});

./backend/orders.ts

import express, { Application, Request, Response } from "express";
import { orders, Order } from "./mockdata/tutorialData";
const app: Application = express();
const PORT: number = 3002;
app.use(express.json());
// Endpoint to get all orders
app.get("/orders", (req: Request, res: Response) => {
  res.json(orders);
});
// Endpoint to get order by ID
app.get("/orders/:id", (req: Request, res: Response) => {
  const order: Order | undefined = orders.find(
    (o) => o.id === parseInt(req.params.id)
  );
  if (!order) {
    res.status(404).send("Order not found");
  } else {
    res.json(order);
  }
});
// Endpoint to update order status
app.put("/orders/:id", (req: Request, res: Response) => {
  const order: Order | undefined = orders.find(
    (o) => o.id === parseInt(req.params.id)
  );
  if (!order) {
    res.status(404).send("Order not found");
  } else {
    order.status = req.body.status;
    res.json(order);
  }
});
// Start server
app.listen(PORT, () => {
  console.log(`Order tracking service started on port ${PORT}`);
});

Each microservice is just an Express app with its own endpoints, each focusing on performing a single specific task - sending the product catalog, and the list of orders, respectively.

The next step would be to include these APIs as data dependencies for WunderGraph. The latter works by introspecting your data sources and consolidating them all into a single, unified virtual graph, that you can then define operations on, and serve the results via JSON-over-RPC. For this introspection to work on a REST API, you'll need an OpenAPI (you might also know this as Swagger) specification for it.

💡 An OpenAPI/Swagger specification is a human-readable description of your RESTful API. This is just a JSON or YAML file describing the servers an API uses, its authentication methods, what each endpoint does, the format for the params/request body each needs, and the schema for the response each returns.

Fortunately, writing this isn't too difficult once you know what to do, and there are several libraries that can automate it. If you'd rather not generate your own, you can grab my OpenAPI V3 spec for our two microservices, in JSON here, and here. They're far too verbose to be included within the article itself.

Finally, to keep things simple for this tutorial, we're using mock eCommerce data from the Fake Store API for them rather than making database connections ourselves. If you need the same data I'm using, here you go.

2. The BFF - WunderGraph

WunderGraph's create-wundergraph-app CLI is the best way to set up both our BFF server and the Next.js app in one go, so let's do just that. Just make sure you have the latest Node.js LTS installed, first.

npx create-wundergraph-app my-project -E nextjs

Then, cd into the project directory, and

npm install && npm start

If you see a WunderGraph splash page pop up in your browser (at localhost:3000) with data from an example query, you're good to go!

In your terminal, you'll see the WunderGraph server (WunderNode) running in the background, watching for any new data sources added, or changes made, ready to introspect and consolidate them into a namespaced virtual graph layer that you can define data operations on. Let's not keep it waiting.

Adding our Microservices as Data Dependencies

Our data dependencies are the two microservices we just built, as Express.js REST APIs. For this. So add them to wundergraph.config.ts , pointing them at the OpenAPI spec JSONs, and including them in the configureWunderGraphApplication dependency array.

./.wundergraph/wundergraph.config.ts

// products catalog microservice\
const products = introspect.openApi({
  apiNamespace: "products",
  source: {
    kind: "file",
    filePath: "../backend/products-service-openAPI.json", // this is the OpenAPI specification.
  },
});
// orders microservice
const orders = introspect.openApi({
  apiNamespace: "orders",
  source: {
    kind: "file",
    filePath: "../backend/orders-service-openAPI.json", // this is the OpenAPI specification.
  },
});
// configureWunderGraph emits the configuration
configureWunderGraphApplication({
  apis: [products, orders],
  //  ...
});

Once you hit save, the WunderNode will introspect the Products and Orders microservices via their OpenAPI spec, and generate their models. If you want, check these generated types/interfaces at ./components/generated/models.ts. You'll be able to see the exact shapes of all your data.

Now, you have all the data your services provide at your disposal within the WunderGraph BFF layer, and can write either...

  1. GraphQL queries/mutations, or
  2. Async resolvers in TypeScript (WunderGraph calls these TypeScript Operations)

... to interact with that data.

Choose whichever one you're familiar with, as ultimately, accessing that data from the frontend is going to be identical. This tutorial is simple enough that we can cover both!

Defining Data Operations on the Virtual Graph

With GraphQL

WunderGraph's GraphQL Operations are just .graphql files within ./.wundergraph/operations.

./.wundergraph/operations/AllProducts.graphql

query AllProducts {
  products: products_getProducts {
    id
    name
    price
    description
  }
}

./.wundergraph/operations/AllOrders.graphql

query AllOrders {
  orders: orders_getOrders {
    id
    items {
      productId
      quantity
    }
    status
    deliveryDate
    shippingAddress {
      name
      address
      city
      state
      zip
    }
  }
}

With TypeScript Operations

TypeScript Operations are just .ts files within ./.wundergraph/operations, use file-based routing similar to NextJS, and are namespaced. So here's what our operations will look like :

./.wundergraph/operations/products/getAll.ts

import { createOperation } from "../../generated/wundergraph.factory";
export default createOperation.query({
  handler: async () => {
    const response = await fetch("http://localhost:3001/products");
    const productsData = await response.json();
    return response.ok
      ? { success: true, products: productsData }
      : { success: false, products: [] };
  },
});

./.wundergraph/operations/orders/getAll.ts

import { createOperation } from "../../generated/wundergraph.factory";
export default createOperation.query({
  handler: async () => {
    const response = await fetch("http://localhost:3002/orders");
    const ordersResponse = await response.json();
    return response.ok
      ? { success: true, orders: ordersResponse }
      : { success: false, orders: [] };
  },
});

These TypeScript Operations have one advantage over GraphQL - they are entirely in server-land - meaning you can natively async/await any Promise within them to fetch, modify, expand, and otherwise return completely custom data, from any data source you want.

Let's go ahead and write operations to get Products and Orders by their respective IDs, too.

./.wundergraph/operations/products/getByID.ts

import { createOperation, z } from "../../generated/wundergraph.factory";

export default createOperation.query({
  input: z.object({
    productID: z.number(),
  }),
  handler: async ({ input }) => {
    const response = await fetch(
      `http://localhost:3001/products/${input.productID}`
    );
    const productResponse = await response.json();
    return response.ok
      ? { success: true, product: productResponse }
      : { success: false, product: {} };
  },
});

./.wundergraph/operations/orders/getByID.ts

import { createOperation, z } from "../../generated/wundergraph.factory";

export default createOperation.query({
  input: z.object({
    orderID: z.number(),
  }),
  handler: async ({ input }) => {
    const response = await fetch(
      `http://localhost:3002/orders/${input.orderID}`
    );
    const orderData = await response.json();
    return response.ok
      ? { success: true, order: orderData }
      : { success: false, order: {} };
  },
});

Note how we're using Zod as a built-in JSON schema validation tool.

Which one should you use? Well, that depends on your requirements. If your BFF needs to process and massage the data returned by your microservices/other datasources, TypeScript operations will be the way to go, being much more flexible in how you can join cross-API data, process error messages from your backend into something consistent and uniform for your frontend, and use Zod to validate inputs/API responses.

Once you have your data, it's on to the frontend! When you hit save in your IDE after writing these operations (whichever method you've chosen), the WunderNode will build the types required for the queries and mutations you've defined, and generate a custom Next.js client with type-safe hooks you can use in your frontend to call those operations and return data.

3. The Frontend - Next.js + WunderGraph's Data Fetching Hooks

Essentially, our frontend is just two pages - one to show all the orders, and another to show the Product Catalog. You can lay this out in any way you like; I'm just using a really basic Sidebar/Active Tab pattern to switch between two display components - <ProductList> and <OrderList> - selectively rendering each.

./pages/index.tsx

import { NextPage } from "next";
import React, { useState } from "react";
/* WG stuff */
import { useQuery, withWunderGraph } from "../components/generated/nextjs";
/* my components */
import ProductList from "../components/ProductList";
import OrderList from "../components/OrderList";
import Sidebar from "../components/Sidebar";
/* my types*/
import { Product } from "types/Product";
import { Order } from "types/Order";
const Home: NextPage = () => {
  // add more tabs as and when you require them
  const [activeTab, setActiveTab] = useState<"Product Catalog" | "Orders">(
    "Product Catalog"
  );
  const handleTabClick = (tab: "Product Catalog" | "Orders") => {
    setActiveTab(tab);
  };
  // Using WunderGraph's auto-generated data fetching hooks
  const { data: productsData } = useQuery({
    operationName: "products/getAll",
  });
  // …and again.
  const { data: ordersData } = useQuery({
    operationName: "orders/getAll",
  });
  return (
    <div className="flex">
      <Sidebar activeTab={activeTab} handleTabClick={handleTabClick} />
      <main className="flex-grow p-8">
        {activeTab === "Product Catalog" ? (
          <ProductList products={productsData?.products as Product[]} />
        ) : activeTab === "Orders" ? (
          <OrderList orders={ordersData?.orders as Order[]} />
        ) : (
          // account for any other tab, if present. Placeholder for now.
          <div className="text-white"> Under Construction</div>
        )}
      </main>
    </div>
  );
};
export default withWunderGraph(Home);

WunderGraph generates typesafe React hooks for your frontend, every time you define an operation (whether using GraphQL or TypeScript) and hit save. Here, we're using useQuery, specifying the name of the operation (see how our Operations being namespaced helps?). After that, it's all home stretch. We just feed the data each hook returns into our components, to render out the data however we want.

With that out of the way, let's look at those display components (and the basic card-style components they use for rendering individual items)

./components/ProductCard.tsx

import { Product } from "../types/Product";
import Link from "next/link";

type Props = {
  product: Product;
};

const ProductCard: React.FC<Props> = ({ product }) => {
  return (
    <Link
      href={{
        pathname: `/products/${product.id}`,
      }}
    >
      <div className="h-64 bg-white transform transition-transform duration-50 hover:translate-y-1 rounded-lg shadow-lg p-4 cursor-pointer relative">
        <h2 className="text-lg font-medium border-b-2 h-16 line-clamp-2">
          {product.name}
        </h2>
        <p className="text-gray-500 line-clamp-4 my-2">{product.description}</p>
        <p
          className="text-lg font-bold text-gray-800 p-4 "
          style={{ position: "absolute", bottom: 0, left: 0 }}
        >
          ${product.price.toFixed(2)}
        </p>
      </div>
    </Link>
  );
};

export default ProductCard;

./components/ProductList.tsx

import { Product } from "../types/Product";
import ProductCard from "./ProductCard";

type Props = {
  products: Product[];
};

const ProductList: React.FC<Props> = ({ products }) => {
  return (
    <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
      {products?.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
};

export default ProductList;

./components/OrderCard.tsx

import { Order } from "../types/Order";
import Link from "next/link";

type Props = {
  order: Order;
};

const OrderCard: React.FC<Props> = ({ order }) => {
  return (
    <Link
      href={{
        pathname: `/orders/${order.id}`,
      }}
    >
      <div className="bg-white  transform transition-transform duration-50 hover:translate-x-2 rounded-lg shadow-lg p-4 mb-4 cursor-pointer h-60">
        <h2 className="text-lg font-medium">Order #{order.id}</h2>
        <p className="text-lg font-semibold my-2 text-gray-500">
          {order.status}
        </p>
        <ul className="border-y mb-2">
          {order.items.map((item) => (
            <li key={item.productId}>
              {item.quantity} x Product ID {item.productId}
            </li>
          ))}
        </ul>
        <p className="text-gray-500">
          Shipping Address: {order.shippingAddress.address},{" "}
          {order.shippingAddress.city}, {order.shippingAddress.state}{" "}
          {order.shippingAddress.zip}
        </p>
        <p className="text-gray-500">Delivery Date: {order.deliveryDate}</p>
      </div>
    </Link>
  );
};

export default OrderCard;

./components/OrderList.tsx

import { Order } from "../types/Order";
import OrderCard from "./OrderCard";

type Props = {
  orders: Order[];
};

const OrderList: React.FC<Props> = ({ orders }) => {
  return (
    <div>
      {orders.map((order) => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  );
};

export default OrderList;

And here's the Sidebar component.

./components/Sidebar.tsx

type SidebarProps = {
  activeTab: "Product Catalog" | "Orders";
  handleTabClick: (tab: "Product Catalog" | "Orders") => void;
};
const Sidebar: React.FC<SidebarProps> = ({ activeTab, handleTabClick }) => {
  return (
    <aside className="lg:w-1/8 bg-gradient-to-b from-gray-700 via-gray-900 to-black p-8 flex flex-col justify-between h-screen">
      <div>
        <h2 className="text-xl font-bold mb-4 text-slate-400 tracking-wider font-mono">
          Navigation
        </h2>
        <nav>
          <ul className="tracking-tight text-zinc-200">
            <li
              className={`${
                activeTab === "Product Catalog" ? "font-bold text-white " : ""
              } mb-4 cursor-pointer hover:underline`}
              onClick={() => handleTabClick("Product Catalog")}
            >
              Product Catalog
            </li>
            <li
              className={`${
                activeTab === "Orders" ? "font-bold text-white " : ""
              } mb-4 cursor-pointer hover:underline`}
              onClick={() => handleTabClick("Orders")}
            >
              Orders
            </li>
          </ul>
        </nav>
      </div>
    </aside>
  );
};
export default Sidebar;

Finally, here are the individual Product/Order pages, as Next.js dynamic routes.

./pages/products/[productID].tsx

import { useRouter } from "next/router";
import { useQuery, withWunderGraph } from "../../components/generated/nextjs";
import BackButton from "components/BackButton";

const Order = () => {
  const router = useRouter();
  const { productID } = router.query;
  const { data: productData } = useQuery({
    operationName: "products/getByID",
    input: {
      productID: parseInt(productID as string),
    },
  });

  const product = productData?.product;

  if (!product) {
    return (
      <div className="w-screen h-screen flex items-center justify-center">
        <BackButton />
        <div className="max-w-4xl bg-white rounded-lg shadow-lg overflow-hidden">
          <div className="p-8 flex flex-col justify-between items-center">
            <h2 className="text-xl font-bold"> Product not found!</h2>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="w-screen h-screen flex items-center justify-center">
      <BackButton />
      <div className="max-w-4xl bg-white rounded-lg shadow-lg overflow-hidden">
        <img
          className="w-full h-56 object-cover object-center"
          src="https://dummyimage.com/720x400"
          alt={product.name}
        />
        <div className="p-6">
          <h3 className="text-lg font-semibold text-gray-800">
            {product.name}
          </h3>
          <p className="mt-2 text-gray-600 text-sm">{product.description}</p>
          <div className="mt-4 flex items-center justify-between">
            <span className="text-lg font-bold text-gray-800">
              ${product.price.toFixed(2)}
            </span>
          </div>
        </div>
      </div>
    </div>
  );
};

export default withWunderGraph(Order);

./pages/orders/[orderID].tsx

import { useRouter } from "next/router";
import { useQuery, withWunderGraph } from "../../components/generated/nextjs";
import BackButton from "components/BackButton";

type ItemInOrder = {
  productId: number;
  quantity: number;
};

const Order = () => {
  const router = useRouter();
  const { orderID } = router.query;
  const { data: orderData } = useQuery({
    operationName: "orders/getByID",
    input: {
      orderID: parseInt(orderID as string),
    },
  });

  const order = orderData?.order;

  if (!order) {
    return (
      <div className="">
        <BackButton />
        <div className="min-h-screen flex flex-col items-center justify-center text-white">
          <div className="bg-white text-sm md:text-lg text-black p-6 md:p-12 rounded-lg mt-12">
            <div className="flex flex-col justify-between items-center">
              <h2 className="text-xl font-bold"> Order not found!</h2>
            </div>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="flex">
      <BackButton />
      <div className="min-h-screen w-screen flex items-center justify-center text-white">
        <div className="bg-white text-sm md:text-lg text-black p-6 md:p-12 rounded-lg mt-12 shadow-xl">
          <div className="flex flex-col justify-between items-center">
            <h2 className="text-xl font-bold">Order #{order.id}</h2>
            <span
              className={`${
                order.status === "Processing"
                  ? "text-yellow-500"
                  : order.status === "Shipped"
                  ? "text-green-500"
                  : "text-red-500"
              } font-bold`}
            >
              {order.status}
            </span>
          </div>
          <div className="mt-4">
            <h3 className="text-lg font-bold">Items</h3>
            <ul className="list-disc list-inside">
              {order.items.map((item: ItemInOrder) => (
                <li key={item.productId}>
                  {item.quantity} x Product #{item.productId}
                </li>
              ))}
            </ul>
          </div>
          <div className="mt-4">
            <h3 className="text-lg font-bold">Shipping Address</h3>
            <p>{order.shippingAddress.name}</p>
            <p>{order.shippingAddress.address}</p>
            <p>
              {order.shippingAddress.city}, {order.shippingAddress.state}{" "}
              {order.shippingAddress.zip}
            </p>
          </div>
          <div className="mt-4">
            <h3 className="text-lg font-bold">Delivery Date</h3>
            <p>{new Date(order.deliveryDate).toDateString()}</p>
          </div>
        </div>
      </div>
    </div>
  );
};

export default withWunderGraph(Order);

That's everything! Point your browser to http://localhost:3000 if you hadn't already, and you should see the data from our microservices rendered out into cards, based on which tab on the Sidebar you're on.

image

image

The Orders pages, using the Orders microservice for their data.

In Summary...

The Backend-for-Frontend (BFF) pattern can be an incredibly useful solution when your backend becomes a complex, all-for-one monolithic API, and when you start including business logic in your different frontends to compensate, it matches your backend in complexity and lack of maintainability.

But when you get down to actual development, building BFFs can be difficult. It's possible, certainly, but there's a massive amount of boilerplate to write every single time, and orchestrating calls between the frontend and the backend services is a pain if rolling your own solution.

As an analogy...if you want to get into React, the go-to recommendation is to just pick up Next.js as a framework. But nothing like that existed for BFFs...until now.

With WunderGraph, building typesafe BFFs with either GraphQL or TypeScript becomes a cakewalk. You cut through all the boilerplate by explicitly defining your data sources - OpenAPI REST/GraphQL APIs, databases like PostgreSQL, MySQL, MongoDB, and even gRPC - and letting WunderGraph's powerful code generation templates generate a typesafe client for you. All with top notch developer experience from start to finish.

Enjoyed this article?

Share it with your network to help others discover it

Continue Learning

Discover more articles on similar topics