Build a Server-Side Todo App
Learn how to build a full-stack todo application using Next.js App Router with server-side rendering, API routes, and client-side interactivity. This is one of the most common frontend interview questions.
Table of Contents
Problem Statement
Build a fully functional todo application that supports server-side rendering with the following features:
- Display a list of todos fetched from a server API
- Add new todos via a POST request to the backend
- Toggle completion status with a PUT request
- Edit todo text inline and persist changes
- Delete individual todos or clear all completed
- Filter todos by All, Active, and Completed
- Show active/completed counts
Why this question?
The todo app is a staple in frontend interviews because it tests CRUD operations, state management, API integration, and UI/UX thinking — all in a single, focused problem. The server-side variant adds complexity around data fetching patterns and client-server architecture.
System Design Overview
This app uses the Next.js App Router architecture, which gives us a clean separation between server and client concerns:
Server Component
Fetches initial todos on the server before sending HTML to the client. Zero client-side JS for the initial load.
API Routes
Next.js route handlers act as the backend. They handle GET, POST, PUT, DELETE with in-memory storage.
Client Component
Handles interactivity — adding, editing, toggling, deleting todos. Uses React state + fetch calls.
Browser → request → Next.js Server ↓ Server Component fetches data Server Component → fetch → API Route (/api/v1/todo) ↓ Returns HTML with data Server → HTML + props → Client Component ↓ User interacts Client Component → fetch (POST/PUT/DELETE) → API Route
Folder Structure
A well-organized folder structure is critical in interviews. It shows you think about maintainability and separation of concerns.
├── app/ │ ├── (solution-apps)/ │ │ └── questions/ │ │ └── todo/ │ │ └── page.tsx # Server Component — fetches initial data │ ├── api/ │ │ └── v1/ │ │ └── todo/ │ │ └── route.ts # API Route — handles all CRUD operations │ └── layout.tsx # Root layout with fonts & metadata │ ├── modules/ │ └── questions/ │ └── todo/ │ ├── components/ │ │ └── todo.tsx # Client Component — all UI & interactivity │ └── services/ │ └── todoService.ts # API call abstractions │ └── utils/ └── helper.ts # Shared utilities (getApiURL, makeHttpCall)
Interview tip
Interviewers love seeing a services/ layer that abstracts API calls away from components. It shows you understand separation of concerns and makes the code testable.
Backend API Design
The API follows RESTful conventions using Next.js Route Handlers. A single file handles all HTTP methods for the /api/v1/todo endpoint.
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/todo | Fetch all todos |
| POST | /api/v1/todo | Create a new todo |
| PUT | /api/v1/todo | Update todo (toggle or edit text) |
| DELETE | /api/v1/todo?id={id} | Delete a specific todo |
| DELETE | /api/v1/todo?clearCompleted=true | Clear all completed todos |
Todo Data Shape
interface Todo { id: string; // Unique identifier (crypto.randomUUID()) text: string; // The todo content completed: boolean; // Completion status createdAt: string; // ISO timestamp }
Complete API Route Handler
// In-memory todo storage (resets on server restart) let todos = [ { id: "1", text: "Complete React tutorial", completed: false, createdAt: "2024-01-15T10:00:00.000Z" }, { id: "2", text: "Build todo app for interview prep", completed: true, createdAt: "2024-01-15T11:30:00.000Z" } ]; // GET — Fetch all todos export async function GET() { return Response.json(todos); } // POST — Add new todo export async function POST(request: Request) { const body = await request.json(); const { text } = body; if (!text.trim().length) { return Response.json( { error: "Text is required" }, { status: 400 } ); } const newTodo = { id: crypto.randomUUID(), text: text.trim(), completed: false, createdAt: new Date().toISOString() }; todos.push(newTodo); return Response.json(newTodo, { status: 201 }); } // PUT — Update todo (toggle complete or edit text) export async function PUT(request: Request) { const body = await request.json(); const { id, text, completed } = body; const todoIndex = todos.findIndex(t => t.id === id); if (todoIndex === -1) { return Response.json( { error: "Todo not found" }, { status: 404 } ); } if (text !== undefined) { todos[todoIndex].text = text.trim(); } if (completed !== undefined) { todos[todoIndex].completed = completed; } return Response.json(todos[todoIndex]); } // DELETE — Delete single todo or clear completed export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); const clearCompleted = searchParams.get("clearCompleted"); if (clearCompleted === "true") { todos = todos.filter(t => !t.completed); return Response.json({ message: "Completed todos cleared" }); } if (id) { const todoIndex = todos.findIndex(t => t.id === id); if (todoIndex === -1) { return Response.json( { error: "Todo not found" }, { status: 404 } ); } todos.splice(todoIndex, 1); return Response.json({ message: "Todo deleted" }); } return Response.json( { error: "Invalid request" }, { status: 400 } ); }
Production note
In-memory storage is fine for interviews and demos. In production, you'd use a database like PostgreSQL, MongoDB, or even a simple SQLite file. The API contract stays the same — only the storage layer changes.
Frontend Implementation
The frontend is split into two parts: a Server Component that fetches initial data, and a Client Component that handles all interactivity.
Step 1: Server Component (Data Fetching)
The page component runs on the server. It fetches todos before the page is sent to the browser, giving us instant content with no loading spinners.
import Todo from "@/modules/questions/todo/components/todo"; import { getApiURL, makeHttpCall } from "@/utils/helper"; interface TodoItem { id: string; text: string; completed: boolean; createdAt: string; } export default async function TodoPage() { // This runs on the server — no useEffect needed! const response = await makeHttpCall(getApiURL("todo")); const initialTodos: TodoItem[] = await response.json(); // Pass server-fetched data as props to the client component return <Todo initialTodos={initialTodos} />; }
Key insight
Notice the async keyword on the component. Server Components in Next.js can be async functions — they await data before rendering. This is impossible in traditional Client Components.
Step 2: Service Layer (API Abstractions)
Keep API calls in a dedicated service file. This keeps components clean and makes the code easier to test and refactor.
import { getApiURL, makeHttpCall } from "@/utils/helper"; export async function addTodoService(text: string) { const res = await makeHttpCall(getApiURL("todo"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); return await res.json(); } export async function toggleTodoService( id: string, completed: boolean ) { const res = await makeHttpCall(getApiURL("todo"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, completed }), }); return await res.json(); } export async function deleteTodoService(id: string) { const res = await makeHttpCall(getApiURL(`todo?id=${id}`), { method: "DELETE", }); return await res.json(); } export async function saveEditService(id: string, text: string) { const res = await makeHttpCall(getApiURL("todo"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, text }), }); return await res.json(); } export async function clearCompletedService() { const res = await makeHttpCall( getApiURL("todo?clearCompleted=true"), { method: "DELETE" } ); return await res.json(); }
Step 3: Shared Utilities
export function getApiURL(relativeUrl: string) { return `${process.env.NEXT_PUBLIC_API_URL}/api${ process.env.NEXT_PUBLIC_API_VERSION }/${relativeUrl}`; } export async function makeHttpCall( url: string, config?: RequestInit ): Promise<Response> { const init = config ?? { method: "get" }; let response; try { response = await fetch(url, init); } catch (error) { console.error("Failed to make httpCall with error", error); } if (!response) { // Return a pending promise so .then/.catch won't fire return new Promise(() => {}); } if (!response.ok) { console.error("Received not success code", response.status); throw new Error("Something went wrong"); } return response; }
Step 4: Client Component (Full Interactive UI)
This is the main component. It receives server-fetched todos as props and manages all user interactions with local state + API calls.
"use client"; import { useState } from "react"; import { addTodoService, clearCompletedService, deleteTodoService, saveEditService, toggleTodoService, } from "@/modules/questions/todo/services/todoService"; interface Todo { id: string; text: string; completed: boolean; createdAt: string; } type FilterType = "all" | "active" | "completed"; interface TodoProps { initialTodos: Todo[]; } export default function Todo({ initialTodos }: TodoProps) { const [todos, setTodos] = useState<Todo[]>(initialTodos); const [input, setInput] = useState(""); const [filter, setFilter] = useState<FilterType>("all"); const [editingId, setEditingId] = useState<string | null>(null); const [editText, setEditText] = useState(""); const addTodo = async () => { if (!input.trim()) return; addTodoService(input) .then((newTodo) => { setTodos((prev) => [...prev, newTodo]); setInput(""); }) .catch((error) => console.error(error)); }; const toggleTodo = async (id: string) => { const todo = todos.find((t) => t.id === id); if (!todo) return; toggleTodoService(id, !todo.completed) .then(() => setTodos((prev) => prev.map((t) => t.id === id ? { ...t, completed: !t.completed } : t ) ) ) .catch((error) => console.error(error)); }; const deleteTodo = async (id: string) => { deleteTodoService(id) .then(() => setTodos((prev) => prev.filter((t) => t.id !== id)) ) .catch((error) => console.error(error)); }; const startEdit = (id: string, text: string) => { setEditingId(id); setEditText(text); }; const saveEdit = async (id: string) => { if (!editText.trim()) return; saveEditService(id, editText) .then(() => { setTodos((prev) => prev.map((t) => t.id === id ? { ...t, text: editText.trim() } : t ) ); setEditingId(null); setEditText(""); }) .catch((error) => console.error(error)); }; const clearCompleted = async () => { clearCompletedService() .then(() => setTodos((prev) => prev.filter((t) => !t.completed)) ) .catch((error) => console.error(error)); }; const getFilteredTodos = (): Todo[] => { switch (filter) { case "active": return todos.filter((t) => !t.completed); case "completed": return todos.filter((t) => t.completed); default: return todos; } }; const filteredTodos = getFilteredTodos(); const completedCount = todos.filter((t) => t.completed).length; const activeCount = todos.length - completedCount; return ( <div> {/* Input form */} <form onSubmit={(e) => { e.preventDefault(); addTodo(); }}> <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="What needs to be done?" /> <button type="submit" disabled={!input.trim()}> Add </button> </form> {/* Todo list */} {filteredTodos.map((todo) => ( <div key={todo.id}> {editingId === todo.id ? ( <> <input value={editText} onChange={(e) => setEditText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveEdit(todo.id); if (e.key === "Escape") { setEditingId(null); setEditText(""); } }} /> <button onClick={() => saveEdit(todo.id)}>Save</button> <button onClick={() => { setEditingId(null); setEditText(""); }}>Cancel</button> </> ) : ( <> <button onClick={() => toggleTodo(todo.id)}> {todo.completed ? "✓" : "○"} </button> <span>{todo.text}</span> <button onClick={() => startEdit(todo.id, todo.text)}> Edit </button> <button onClick={() => deleteTodo(todo.id)}> Delete </button> </> )} </div> ))} {/* Filters & stats */} <div> <span>{activeCount} items left</span> {(["all", "active", "completed"] as FilterType[]).map( (f) => ( <button key={f} onClick={() => setFilter(f)}> {f} </button> ) )} {completedCount > 0 && ( <button onClick={clearCompleted}> Clear completed </button> )} </div> </div> ); }
Data Flow Between Client and Server
Understanding the data flow is crucial for interviews. Here's exactly how data moves through the application at each stage:
Initial Page Load (Server-Side)
The browser requests the page. Next.js runs the Server Component, which calls the API route internally. The API returns todos from memory. The Server Component passes them as props to the Client Component. The browser receives fully rendered HTML.
Adding a Todo (Client-Side)
User types text and submits the form. The Client Component calls addTodoService(), which sends a POST request to /api/v1/todo. The API creates a new todo with crypto.randomUUID() and returns it. The client adds the new todo to local state with setTodos().
Toggling / Editing (Client-Side)
User clicks the toggle or edit button. The service layer sends a PUT request with the updated fields. The API finds the todo by ID, updates it, and returns the updated object. The client updates local state to reflect the change.
Deleting (Client-Side)
User clicks delete. The service sends a DELETE request with the todo ID as a query param. The API removes it from the array. The client filters it out of local state.
Optimistic vs. Pessimistic Updates
This implementation uses pessimistic updates — it waits for the server to confirm before updating the UI. In production, you might use optimistic updates (update UI immediately, roll back on error) for a snappier feel. Be ready to discuss both approaches in interviews.
State Management
This app uses plain useState hooks — no external libraries. Here's the state breakdown:
| State | Type | Purpose |
|---|---|---|
| todos | Todo[] | The full list of todos |
| input | string | Controlled input for new todo text |
| filter | FilterType | Current filter: all, active, or completed |
| editingId | string | null | ID of the todo currently being edited |
| editText | string | Temporary text while editing a todo |
Derived State (No Extra useState Needed)
// These are computed from `todos` — no need for separate state const filteredTodos = getFilteredTodos(); const completedCount = todos.filter((t) => t.completed).length; const activeCount = todos.length - completedCount;
Interview tip
Interviewers often ask: "When would you reach for useReducer or a state library?" Good answer: when state transitions become complex (undo/redo, dependent updates) or when multiple components need the same state. For a simple todo app, useState is the right call.
Error Handling
Robust error handling separates junior from senior implementations. Here's how errors are handled at each layer:
API Layer (Server)
// Validation error — empty text if (!text.trim().length) { return Response.json( { error: "Text is required" }, { status: 400 } ); } // Not found error const todoIndex = todos.findIndex(t => t.id === id); if (todoIndex === -1) { return Response.json( { error: "Todo not found" }, { status: 404 } ); } // Invalid request return Response.json( { error: "Invalid request" }, { status: 400 } );
HTTP Utility Layer
export async function makeHttpCall( url: string, config?: RequestInit ): Promise<Response> { let response; try { response = await fetch(url, config ?? { method: "get" }); } catch (error) { // Network errors (offline, DNS failure, etc.) console.error("Failed to make httpCall with error", error); } if (!response) { // Return a never-resolving promise to prevent // downstream .then/.catch from firing return new Promise(() => {}); } if (!response.ok) { // HTTP errors (4xx, 5xx) console.error("Received not success code", response.status); throw new Error("Something went wrong"); } return response; }
Client Layer
// Each operation catches errors gracefully addTodoService(input) .then((newTodo) => { setTodos((prev) => [...prev, newTodo]); setInput(""); }) .catch((error) => console.error(error)); // Input validation before making API calls const addTodo = async () => { if (!input.trim()) return; // Guard clause // ... make API call };
How to improve this in production
Add a toast notification system for user-facing errors, implement retry logic for transient failures, and use error boundaries for unexpected React errors. Mention these improvements proactively in interviews.
Performance Considerations
Performance questions are common follow-ups. Here are the key optimizations in this architecture and what you could add:
Server-Side Rendering
Initial todos are fetched on the server. The browser gets pre-rendered HTML with data — no loading spinner, no layout shift, better SEO.
Minimal Client JavaScript
Only the interactive Client Component ships JS to the browser. The Server Component and its imports stay on the server — zero bundle cost.
Functional State Updates
Using setTodos(prev => ...) ensures state updates are based on the latest value, avoiding stale closure bugs in async operations.
Optimistic Updates
Update the UI immediately before the API responds, then roll back on error. This makes the app feel instant.
Debounced Auto-Save
For inline editing, debounce the save call so it doesn't fire on every keystroke. Use a 300ms delay.
Virtual Scrolling
For very long lists (1000+ items), use react-window or react-virtuoso to only render visible items in the DOM.
React.memo & useCallback
Wrap individual todo items in React.memo and memoize handlers with useCallback to prevent unnecessary re-renders.
const toggleTodo = async (id: string) => { const todo = todos.find((t) => t.id === id); if (!todo) return; // 1. Optimistically update UI setTodos((prev) => prev.map((t) => t.id === id ? { ...t, completed: !t.completed } : t ) ); try { // 2. Send request to server await toggleTodoService(id, !todo.completed); } catch (error) { // 3. Roll back on failure setTodos((prev) => prev.map((t) => t.id === id ? { ...t, completed: todo.completed } : t ) ); console.error("Failed to toggle todo:", error); } };
Common Interview Follow-up Questions
After building the todo app, interviewers often ask deeper questions. Here are the most common ones with strong answers:
Q:How would you persist data across server restarts?
A: Replace the in-memory array with a database. For a simple app, SQLite or a JSON file works. For production, use PostgreSQL with an ORM like Prisma or Drizzle. The API handlers stay the same — only the data access layer changes.
Q:How would you handle concurrent edits from multiple users?
A: Use optimistic concurrency control. Add a `version` or `updatedAt` field to each todo. On PUT, check if the version matches. If not, return a 409 Conflict and let the client resolve it. For real-time sync, consider WebSockets or Server-Sent Events.
Q:How would you add authentication?
A: Add a middleware that checks for a JWT or session cookie on API routes. Each todo gets a `userId` field. Filter queries by the authenticated user. Use Next.js middleware or a library like NextAuth for the auth layer.
Q:What if the todo list has 10,000 items?
A: Implement pagination or infinite scroll on the API (limit/offset or cursor-based). On the frontend, use virtual scrolling (react-window) to only render visible items. Add server-side filtering so the client doesn't download everything.
Q:How would you test this application?
A: Unit tests for service functions (mock fetch). Integration tests for API routes (test each HTTP method). Component tests with React Testing Library (render, interact, assert). E2E tests with Playwright or Cypress for full user flows.
Q:Why use Server Components for the initial fetch?
A: Server Components eliminate the loading state waterfall. The user sees content immediately instead of a spinner. It also reduces client-side JavaScript since the fetch logic never ships to the browser. Better for SEO and Core Web Vitals.
Q:How would you implement undo/redo?
A: Use a state history pattern: maintain an array of past states and a pointer. On each action, push the current state to history. Undo moves the pointer back, redo moves it forward. useReducer is a better fit than useState for this pattern.
Q:What about accessibility?
A: Use semantic HTML (form, button, input). Add aria-labels for icon-only buttons. Ensure keyboard navigation works (Tab, Enter, Escape). Use role='list' and role='listitem'. Announce state changes with aria-live regions. Test with a screen reader.
Ready to build it yourself?
Put your knowledge to the test. Build the todo app from scratch with our interactive challenge.
Built for developers, by developers. Happy coding! 🚀