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

    Let's take a look at the external API domain example. So in this example, we are going to have the Next.js server. This is hosted on, say, mycompany.com. Talk directly to the API REST endpoint using REST services. Not a big deal there.

    If the API REST endpoint changes anything, then it uses a webhook to call back to the Next.js server to tell it to invalidate any caching. The big twist here is that once those RSC requests are completed, then the data is sent to the client component and the client component, in turn, when it wants to make any mutations, calls to api.mycompany.com to make those mutation requests. Then there's a webhook that goes back to the Next.js to revalidate any caches. But in general, the client is kind of on its own talking directly to the API endpoint. If that's what your setup looks like, then this demonstration should help you figure out how all those parts and pieces work in that setup.

    It is not trivial though. It does require some setup of local software. I use Proxyman. In Proxyman, you can map remotes. In this case, I'm mapping the remote, api.mycompany.com, to localhost 5001, which is the API REST.

    I'm mapping web on mycompany.com to localhost 3005. I'll take a look over here on Safari in this case. And we can see I'm on mycompany.com and I make mutations to it. Now we take a look at our network. You can see that as we make changes, we are going to api.mycompany.com directly.

    But if I add a new to-do here, hit submit. Again, we're going to api.mycompany.com. If I hit refresh, I see that same add new to-do, so it has been persisted. Let's go take a look at how all this works in the code. So in our page, we're hitting the API endpoint directly.

    We're not going to api.mycompany.com. In reality, we probably should. ProxyMan is not going to handle that. So in this case, I'm just going directly to localhost 5001. But you can use an environment variable here to tell it where to go.

    At the start of the RSC, we're going to get our session. We're going to make our unauthenticated request to get the priorities. And then we're going to make our authenticated request by proxying through the headers from the request. That's going to have the JWT from the client in the cookie. We're going to pass that on to the API REST endpoint, and that's going to be decoded by the API REST endpoint to figure out who is actually making that to-dos request.

    Once you've got our priorities and our to-dos straight, we're going to go and render out our pages and include our auth button, as well as our to-do list. Now let's go take a look at our client to-do list because that's where the fun really happens. All right, over on our to-do list, we take our priorities as well as our initial set of to-dos. We store those initial to-dos in a to-dos local state. We also have the state for the priorities as well as the title.

    Now when we want to update our list of to-dos, we call our API mycompany.com directly and we include our credentials and then we get back from that our to-dos. So again, api.mycompany.com is still that same API REST endpoint. The reason we have to include the credentials is because we're on a different domain. So that's how we're telling it to go and send along the cookies for this domain to the subdomain. Then when we want to mutate, we simply do a fetch against that API my company.com to do with a post.

    Again we include the credentials and then we update the to do's. The other mutations are the same way. When you click on the completed or you delete, the rest of it is just JSX formatting. Let's talk a little bit about the Next.js webhook that's called when the API makes a mutation. So if I look over here in the API directory, we're gonna have a callback route and all it's gonna do is just revalidate the path.

    I'm not even gonna look at the request And if we look over at our API REST endpoint, anytime it makes a mutation, for example here, like when we're adding a to-do, .post to-do, we post webhook with the event type to do added as well as the information we want in the event. That post webhook simply looks to see if there's a front-end server defined and if there is then we just simply make a post request against that webhook that was created and environment variable wise over here in our original package.json way over the top. Anytime we have one of these external variants we also define with a front-end server that we're talking to. So that's how the API REST endpoint talks back to the front-end server. Now you could do that lots of different ways.

    You do that like a webhook like we're doing here, or you could do that with an Event Bust. Really up to you. Again This is one of those big company examples. You'll know when you need something like this, but if you need it, here is a decent template that you can use to get started that helps you with some of the nasty little bits that happen when it comes to credential sharing and cookies and all the rest of it. It's not actually that difficult but getting it set up the first time can be difficult so it's good to have a template that like this that you can refer to.