Let's look at the variation of the local systems architecture where our Next.js application communicates with a local API route instead of relying on server actions.
This approach is particularly useful when you have multiple clients, such as a web browser, mobile app, or desktop app, that need to access the same API.
The to-do data is stored in an in-memory database, and we'll use Next.js API routes to handle client requests.
Client-Side Implementation
Let's start by examining the client-side code for our to-do list inside of the local-api
directory.
page.tsx
In the homepage component, page.tsx
, we use React Server Components (RSC) to fetch data. First, we check if the user is logged in using getAuthorization
. We then retrieve a hardcoded list of priorities. If the user is logged in, we fetch the to-dos:
// inside of app/page.tsx
import { auth } from "@/auth";
import { Todo, PRIORITIES, getTodos } 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[] = PRIORITIES;
const todos: Todo[] = session?.user?.id
? await getTodos(session?.user?.id)
: [];
return (
<main>
<AuthButton />
{session?.user && <TodoList todos={todos} priorities={priorities} />}
</main>
);
}
The homepage then renders the TodoList
component, passing in the fetched to-dos and priorities:
TodoList.tsx
The ToDoList
component handles displaying and managing the to-dos. It retrieves the todos
and priorities
from its props and maintains a local copy of the to-dos.
"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("/api/todos", {
cache: "no-cache",
});
setTodos(await res.json());
};
const onSubmit = async () => {
await fetch("/api/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(`/api/todo/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ completed }),
});
updateTodos();
};
const onDelete = async (id: string) => {
await fetch(`/api/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>
);
}
The TodoList
component interacts with the various API routes using fetch
to add, update, and delete to-dos. After each mutation, it calls updateTodos
to re-fetch the to-dos and update the UI.
APIs
Now, let's shift our attention to the API routes responsible for handling these requests. These routes are located in the app/api
directory.
Getting All To-Dos
The /api/todos
route handles retrieving the list of to-dos. It first checks if the user is authenticated. If authenticated, it returns the list of to-dos. If not, it returns an error:
// inside of app/api/todos/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import { getTodos } from "@repo/todos";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.error();
return NextResponse.json(await getTodos(session?.user?.id));
}
Creating a New To-Do
The /api/todo
route with a POST request creates a new to-do. First, it verifies if the user is logged in. If so, it adds the new to-do to the database, revalidates the /
route to reflect the changes, and returns the newly created to-do.
// inside of app/api/todo/route.ts
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getTodoById, deleteTodo, updateTodoCompletion } from "@repo/todos";
export async function GET(
req: Request,
{ params: { id } }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.error();
return NextResponse.json(await getTodoById(session?.user?.id, id));
}
export async function PUT(
req: Request,
{ params: { id } }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.error();
const body = await req.json();
const out = await updateTodoCompletion(session?.user?.id, id, body.completed);
revalidatePath("/");
return NextResponse.json(out);
}
export async function DELETE(
req: Request,
{ params: { id } }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.error();
const out = await deleteTodo(session?.user?.id, id);
revalidatePath("/");
return NextResponse.json(out);
}
Similarly, we have routes for getting a single to-do (/api/todo/:id
with GET), updating a to-do (/api/todo/:id
with PUT), and deleting a to-do (/api/todo/:id
with DELETE).
When to Use This Architecture
By separating the client-side logic from the API endpoints, this approach provides flexibility and scalability, especially when dealing with multiple client applications.
While server actions excel in scenarios with a single client (the web browser), the API route approach shines when you need to share your API with other clients or platforms.