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.
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.