ProNextJS
    Loading
    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