This approach uses a service token to handle authentication between a Next.js client and an external API. We'll set up a proxy in Next.js and use a bearer token strategy to make authenticated requests.
Setting up the Next.js Proxy
First, we'll look at the apps/external-token/next.config.mjs
file:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@repo/ui"],
rewrites: async () => {
return [
{
source: "/rest/:path*",
destination: "http://localhost:5002/:path*",
},
];
},
};
export default nextConfig;
This configures a rewrite rule in the Next.js server to proxy requests made to /rest
to the external API.
In this setup, any request hitting /rest
on the Next.js server will be forwarded to http://localhost:5002
, effectively acting as a proxy to our external API.
Homepage
The homepage uses next-auth
to manage user sessions and store the service token. Upon successful authentication, we'll fetch a token from our authentication logic.
// inside page.tsx
import { auth } from "@/auth";
import { Todo } from "@repo/todos";
import AuthButton from "@/components/AuthButton.server";
import TodoList from "./TodoList";
import { SessionProvider } from "next-auth/react";
const REST_API = process.env.REST_API;
export default async function Home() {
const session = await auth();
const priorities: string[] = await fetch(`${REST_API}/priorities`).then(
(resp) => resp.json()
);
const todos: Todo[] = session?.user
? await fetch(`${REST_API}/todos`, {
cache: "no-cache",
headers: {
Authorization: `Bearer ${session.user.token}`,
},
}).then((resp) => resp.json())
: [];
return (
<main>
<AuthButton />
{session?.user && (
<SessionProvider session={session}>
<TodoList todos={todos} priorities={priorities} />
</SessionProvider>
)}
</main>
);
}
The auth
Package
Here's what the auth
package looks like:
// inside packages/auth/index.ts
import { decodeJwt, SignJWT } from "jose";
export const SECRET = "simple-secret";
export function getUserToken(user: string) {
return `token:${user}`;
}
export function getUserFromUserToken(token: string) {
return token.replace("token:", "");
}
export async function encodeJWT(token: Record<string, any>) {
return await new SignJWT(token)
.setProtectedHeader({ alg: "HS256" })
.sign(new TextEncoder().encode(SECRET.toString()));
}
export async function decodeJWT<Payload>(
token?: string
): Promise<Payload | null> {
return token ? decodeJwt(token?.toString()) : null;
}
export function validateUser(credentials: {
username: string;
password: string;
}) {
const users = [
{
id: "test-user-1",
userName: "test1",
name: "Test 1",
password: "pass",
email: "test1@donotreply.com",
},
{
id: "test-user-2",
userName: "test2",
name: "Test 2",
password: "pass",
email: "test2@donotreply.com",
},
];
const user = users.find(
(user) =>
user.userName === credentials.username &&
user.password === credentials.password
);
return user ? { id: user.id, name: user.name, email: user.email } : null;
}
In addition to setting the user id on the session, we also set a token that is given to us from getUserToken
. This token is used to authenticate requests to the external API.
Again, note that the token is not secure and is only used for demonstration purposes!
The TodoList
Component
Similar to before, the TodoList
component fetches the to-do list and priorities from the external API. It uses the Authorization
header with the bearer token to authenticate the request.
// inside TodoList.tsx
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Todo } from "@repo/todos";
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 { data: session } = useSession();
const updateTodos = async () => {
const res = await fetch("/rest/todos", {
cache: "no-cache",
headers: {
Authorization: `Bearer ${session?.user?.token}`,
},
});
setTodos(await res.json());
};
const onSubmit = async () => {
await fetch("/rest/todo", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.user?.token}`,
},
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",
Authorization: `Bearer ${session?.user?.token}`,
},
body: JSON.stringify({ completed }),
});
updateTodos();
};
const onDelete = async (id: string) => {
await fetch(`/rest/todo/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${session?.user?.token}`,
},
});
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>
);
}
After each mutation (like adding or completing a to-do), we should revalidate the Next.js page to reflect the changes. To do this, we can use a webhook to trigger revalidation on the server.
Webhook for Revalidation
Over in api/callback/route.ts
on our Next.js server, we define a route to handle the webhook:
// inside 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 });
}
When our external API receives a change, it can trigger this webhook.
This ensures the Next.js app always reflects the latest state from the API.
Security Considerations
It is important to note that this architecture method exposes the access token to the client-side JavaScript. This is in contrast to other examples we've looked at that use HTTP-only cookies that are not accessible to the client.
A more secure approach would be to handle token-based communication entirely on the server side. In this setup, the client would talk to the Next.js server, then the Next.js server would have access to the Bearer token on the server. It would make the request, then send the data back to the client.