ProNextJS
    Professional Next.js Course
    Loading price
    30-Day Money-Back Guarantee
    lesson

    Proxying External Systems with Next.js

    Jack HerringtonJack Herrington

    Let's dive into proxying external system interactions in a Next.js application. This approach uses an external proxy for client-side requests, keeping our application code blissfully unaware of the proxying.

    external proxied architecture

    The code for this example can be found in the external-proxied directory.

    Configuring the Next.js Proxy

    To start, let's take a look at our next.config.js file. This configuration handles routing requests made to /rest to our external REST API, running locally on port 5001.

    // inside apps/external-proxied/next.config.js
    
    /** @type {import('next').NextConfig} */
    const nextConfig = {
      transpilePackages: ["@repo/ui"],
      rewrites: async () => {
        return [
          {
            source: "/rest/:path*",
            destination: "http://localhost:5001/:path*",
          },
        ];
      },
    };
    
    export default nextConfig;
    

    This configuration makes it so the application code has no idea that requests are being made, so there isn't an opportunity to look at the request.

    Instead, we rely on webhooks to inform us when changes occur.

    Homepage

    Our application homepage fetches a list of to-dos and allows authenticated users to manage them:

    import { headers } from "next/headers";
    
    import { auth } from "@/auth";
    import { Todo } from "@repo/todos";
    
    import AuthButton from "@/components/AuthButton.server";
    
    import TodoList from "./TodoList";
    
    export default async function Home() {
      const session = await auth();
    
      const priorities: string[] = await fetch(
        "http://localhost:5001/priorities"
      ).then((resp) => resp.json());
    
      const todos: Todo[] = session?.user
        ? await fetch("http://localhost:5001/todos", {
            cache: "no-cache",
            headers: headers(),
          }).then((resp) => resp.json())
        : [];
    
      return (
        <main>
          <AuthButton />
          {session?.user && <TodoList todos={todos} priorities={priorities} />}
        </main>
      );
    }
    

    The ToDoList Component

    The ToDoList component uses fetch to interact with our proxied endpoints, which in turn communicate with the external REST API. This direct communication is a key aspect of our architecture.

    // inside TodoList.tsx
    "use client";
    import { useState } from "react";
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from "@/components/ui/select";
    import { Input } from "@/components/ui/input";
    import { Checkbox } from "@/components/ui/checkbox";
    
    import { Todo } from "@repo/todos";
    import { Button } from "@/components/ui/button";
    
    export default function TodoList({
      priorities,
      todos: initialTodos,
    }: {
      priorities: string[];
      todos: Todo[];
    }) {
      const [todos, setTodos] = useState<Todo[]>(initialTodos);
      const [priority, setPriority] = useState<string>(priorities[0]);
      const [title, setTitle] = useState<string>("");
    
      const updateTodos = async () => {
        const res = await fetch("/rest/todos", {
          cache: "no-cache",
        });
        setTodos(await res.json());
      };
    
      const onSubmit = async () => {
        await fetch("/rest/todo", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            title,
            priority,
            completed: false,
          }),
        });
        updateTodos();
      };
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await fetch(`/rest/todo/${id}`, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ completed }),
        });
        updateTodos();
      };
    
      const onDelete = async (id: string) => {
        await fetch(`/rest/todo/${id}`, {
          method: "DELETE",
        });
        updateTodos();
      };
    
      return (
        <div className="mt-5">
          {todos && (
            <>
              <ul>
                {todos?.map((todo) => (
                  <li key={todo.id} className="flex gap-2 items-center mb-3">
                    <Checkbox
                      checked={todo.completed}
                      onClick={() => onSetCompleted(todo.id, !todo.completed)}
                    />
                    <div className="flex-grow">{todo.title}</div>
                    <Button variant="destructive" onClick={() => onDelete(todo.id)}>
                      Delete
                    </Button>
                  </li>
                ))}
              </ul>
            </>
          )}
          <div className="flex gap-2">
            <Select value={priority} onValueChange={(v) => setPriority(v)}>
              <SelectTrigger className="w-[180px]">
                <SelectValue placeholder="Priority" />
              </SelectTrigger>
              <SelectContent>
                {priorities.map((priority) => (
                  <SelectItem value={priority} key={priority}>
                    {priority}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Input
              placeholder="Todo"
              value={title}
              onChange={(evt) => setTitle(evt.target.value)}
            />
            <Button onClick={onSubmit}>Submit</Button>
          </div>
        </div>
      );
    }
    

    Handling Mutations

    When a mutation occurs in our REST API, we need to update our application state. We achieve this through a webhook that triggers revalidation on specific paths. A POST request to api/callback triggers revalidation for the homepage:

    // inside app/api/callback/route.ts
    
    import { revalidatePath } from "next/cache";
    import { NextResponse } from "next/server";
    
    export async function POST(req: Request) {
      revalidatePath("/");
      return NextResponse.json({ success: true });
    }
    

    Inside the API REST server implementation in the api-rest directory, we have a postWebhook function that sends a POST request to the Next.js webhook endpoint:

    // inside apps/api-rest/src/server.ts
    
    import { json, urlencoded } from "body-parser";
    import express, { type Express } from "express";
    import cookieParser from "cookie-parser";
    import cors from "cors";
    
    import { decodeJWT } from "@repo/auth";
    import {
      PRIORITIES,
      getTodos,
      getTodoById,
      addTodo,
      updateTodoCompletion,
      deleteTodo,
    } from "@repo/todos";
    
    function postWebhook(event: string, payload: any) {
      console.log("Posting webhook", process.env.FRONTEND_SERVER, event, payload);
      if (process.env.FRONTEND_SERVER) {
        fetch(`${process.env.FRONTEND_SERVER}/api/callback`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            event,
            payload,
          }),
        });
      }
    }
    
    export const createServer = (): Express => {
      const app = express();
      app
        .disable("x-powered-by")
        .use(cors({ origin: true, credentials: true }))
        .use(cookieParser())
        .use(urlencoded({ extended: true }))
        .use(json())
        .get("/priorities", (_, res) => {
          return res.json(PRIORITIES);
        })
        .get("/todos", async (req, res) => {
          const info = await decodeJWT<{ sub: string }>(
            req.cookies["authjs.session-token"]
          );
          if (!info) {
            return res.status(401).json({ message: "Unauthorized" });
          }
          const { sub } = info;
          const todos = getTodos(sub);
          return res.json(todos);
        })
        .get("/todo/:id", async (req, res) => {
          const info = await decodeJWT<{ sub: string }>(
            req.cookies["authjs.session-token"]
          );
          if (!info) {
            return res.status(401).json({ message: "Unauthorized" });
          }
          const { sub } = info;
          const { id } = req.params;
          const todos = getTodoById(sub, id);
          return res.json(todos);
        })
        .post("/todo", async (req, res) => {
          const info = await decodeJWT<{ sub: string }>(
            req.cookies["authjs.session-token"]
          );
          if (!info) {
            return res.status(401).json({ message: "Unauthorized" });
          }
          const { sub } = info;
          const todo = req.body;
          todo.id = `${sub}-${Date.now()}`;
          const newTodo = addTodo(sub, todo);
    
          postWebhook("todo-added", newTodo);
    
          return res.json(newTodo);
        })
        .put("/todo/:id", async (req, res) => {
          const info = await decodeJWT<{ sub: string }>(
            req.cookies["authjs.session-token"]
          );
          if (!info) {
            return res.status(401).json({ message: "Unauthorized" });
          }
          const { sub } = info;
          const { id } = req.params;
          updateTodoCompletion(sub, id, req.body.completed);
    
          const newTodo = getTodoById(sub, id);
    
          postWebhook("todo-changed", {
            id,
            completed: req.body.completed,
          });
    
          return res.json(newTodo);
        })
        .delete("/todo/:id", async (req, res) => {
          const info = await decodeJWT<{ sub: string }>(
            req.cookies["authjs.session-token"]
          );
          if (!info) {
            return res.status(401).json({ message: "Unauthorized" });
          }
          const { sub } = info;
          const { id } = req.params;
          const todo = getTodoById(sub, id);
          if (!todo) {
            return res.status(404).json({ message: "Not Found" });
          }
          deleteTodo(sub, id);
    
          postWebhook("todo-deleted", {
            id,
          });
    
          return res.json(todo);
        })
        .get("/status", (_, res) => {
          return res.json({ ok: true });
        });
    
      return app;
    };
    

    When the API REST server makes a mutation, it calls back to the API callback. Then the API callback unconditionally revalidates the path, including all event details.

    Advantages and Limitations

    This approach provides client components with simple access to the backend, but it means that you do not get the BFF ability to actually look at what the requests are.

    Transcript

    This is a proxied variant of the external systems architectures. So the idea in this case is that the external proxy Next.js application is going to make its initial RSC request directly to the API REST to go get the list of to-dos. Any mutation to the list of to-dos is going to fire a webhook back to the Next.js server. But the really interesting part comes when the data is sent to the client. The client is going to talk directly to the API REST endpoint, but it's going to be proxied through the Next.js server.

    So you're going to see how to do that rewriting proxy when it comes to Next.js. In fact, let's take a look at that to start out. So over here in our external proxy directory, first thing we're going to want to look at is our next config. So our next config is going to tell us how we are routing our slash rest request to the destination of localhost 5001, which is the REST API. And this is a basic proxy.

    The interesting thing here is that the application code has no idea that these requests are being made. So there is no opportunity to look at the request, understand what's happening, and potentially revalidate our local cache. Instead, we are depending on webhooks to tell us that things have changed. All right, let's go take a look at our homepage code. Go over here in app and then page.

    We're getting our session using next auth auth. Then we're doing our unauthenticated priorities request and our authenticated to-dos request if we are logged in. Down in our JSX section, we are rendering our off button as well as a list of to-dos and also sending along a list of priorities that are going to be used when you're creating a new to-do. Let's go take a look at our to-do list client component. It takes in those priorities and an initial set of to-dos as its properties.

    It then creates a local state for to-dos using that initial set of to-dos as well as local state for the priority and the title for when you're going to create a new to-do. One thing you need to do is update your to-do's when you made a mutation. So we have a update to-do's function for that that uses slash rest slash to-do. Again, that's a proxied slash rest set of endpoints. So that's actually going through the Next.js server, including cookies and all the rest of it, to that backend service, but unbeknownst to any Next.js application code.

    So that's really important. Then we've got our mutation handlers, including adding a new to-do. This is essentially just talking directly to that API REST endpoint, which is using the cookie to find out who you are, and then sending back some data. And then adding the to-do, and then once we've got that, we then update our to-dos, which goes back to that endpoint. So when API REST makes a mutation, it calls back to API Callback.

    API Callback, it takes a POST request, and in this case all we're going to do is just unconditionally revalidate the path, but it includes all of the event details. So let's go take a look at how that happens. Over here in our API REST in our server implementation, we've got a post webhook function. That post webhook takes an event name as well as any additional payload you want to send along with it. It then looks to see if we've got a front-end server defined.

    So that's actually defined over in our package JSON. Whenever you launch one of these, say external proxied, we set the front end server to whatever the front end server currently is running, which is 3003. So back over here on our server code, we look to see if that's defined, and if it's defined, then we call that slash API callback route with a post and all of the information about the event. That in turn on the Next.js side then does that revalidation. This is a decent way of giving your client components access to the back end in a very simple manner, but it does mean that you don't get the BFF ability to actually look at what the requests are coming off the client before you send them to the back end.

    The client has direct access to the back end.