ProNextJS
    Loading
    lesson

    Server Architecture with an External API Domain

    Jack HerringtonJack Herrington

    This demonstration illustrates a Next.js application communicating directly with an external API using REST.

    Imagine your Next.js server, hosted on mycompany.com, needs to interact with an external REST API. That's pretty standard stuff.

    Here's the interesting part. If the API changes, it triggers a webhook to tell the Next.js server to update its cache. This ensures we always have the latest data.

    After the Next.js server fetches data, it passes it to a client-side component. When the client component needs to modify data, it talks directly to the API using api.mycompany.com. Again, a webhook notifies the Next.js server to revalidate the cache.

    Think of it this way: the server fetches the initial data, and the client handles updates, both talking to the same API.

    api architecture diagram

    Setting Up Proxyman

    Since we're working locally, we'll use Proxyman to map our domains:

    • api.mycompany.com will point to our local API running on localhost:5001.
    • web.mycompany.com will point to our Next.js development server on localhost:3005.

    This allows us to mimic the behavior of an external API without actually deploying anything.

    The Application in Action

    When we run our application and interact with it, you'll see that client-side mutations are sent directly to api.mycompany.com. If we add a new to-do item, it persists even after refreshing the page, demonstrating that our updates are working correctly.

    Proxyman setup

    When interacting with the example app in the browser, you'll see requests to api.mycompany.com for mutations and webhooks to mycompany.com for cache invalidation.

    Examining the Code

    Let's dive into the code to understand how the different parts interact.

    Data Fetching on the Server (RSC)

    Inside of the home page componente at apps/external-api-domain/src/app/page.tsx we make requests to the API to fetch our to-do list. Ideally, we'd use api.mycompany.com, but since we're using Proxyman we'll stick with localhost:5001 for now.

    The data fetching process looks similar to before: We retrieve the user's session. We fetch priorities, which doesn't require authentication. Finally, we fetch to-dos, including the user's JWT token from the cookie in the request headers.

    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>
      );
    }
    

    Once we have both priorities and to-dos, we render the page.

    Client-Side TodoList

    The real magic happens in the client-side ToDoList component. It receives the initial to-dos and priorities from the server. We store this initial data in the component's state. When updating the list (adding, completing, or deleting items), the component directly calls api.mycompany.com with the necessary credentials. This works because we include the credentials option in our fetch requests, ensuring the browser sends cookies for the appropriate domain.

    "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("http://api.mycompany.com/todos", {
          credentials: "include",
          cache: "no-cache",
        });
        setTodos(await res.json());
      };
    
      const onSubmit = async () => {
        await fetch("http://api.mycompany.com/todo", {
          method: "POST",
          credentials: "include",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            title,
            priority,
            completed: false,
          }),
        });
        updateTodos();
      };
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await fetch(`http://api.mycompany.com/todo/${id}`, {
          method: "PUT",
          credentials: "include",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ completed }),
        });
        updateTodos();
      };
    
      const onDelete = async (id: string) => {
        await fetch(`http://api.mycompany.com/todo/${id}`, {
          method: "DELETE",
          credentials: "include",
        });
        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>
      );
    }
    

    Cache Invalidation with Webhooks

    Inside of the api/callback/route.ts file is the webhook route that handles cache invalidation. When the API makes a mutation, it sends a POST request to this route with the event type and relevant data. The route then revalidates the cache for the specific page associated with the updated data:

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

    Looking at the Request

    Inside the REST API endpoint at apps/api-rest/src/server.ts we see that when a mutation occurs, a webhook is sent to the Next.js server. This webhook includes the event type and relevant data. The server then checks if a front-end server is defined in the environment variables, and if so, sends a POST request to the webhook route:

    // inside apps/api-rest/src/server.ts
    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,
          }),
        });
      }
    }
    

    Bringing It All Together

    This setup demonstrates a robust way to handle communication between a Next.js application and an external API in a real-world scenario.

    While setting up all the pieces can be tricky, having a template like this makes the process much smoother. This example acts as a template for working in larger company examples.

    Transcript