ReactNext.jsServer ComponentsCRUDAPI Routes

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.

45 min read10 sections
01

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.

02

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.

Architecture Flowtext
BrowserrequestNext.js Server
Server Component fetches data
Server ComponentfetchAPI Route (/api/v1/todo)
Returns HTML with data
ServerHTML + propsClient Component
User interacts
Client Componentfetch (POST/PUT/DELETE) → API Route
03

Folder Structure

A well-organized folder structure is critical in interviews. It shows you think about maintainability and separation of concerns.

Project Structuretext
├── app/
│   ├── (solution-apps)/
│   │   └── questions/
│   │       └── todo/
│   │           └── page.tsx          # Server Componentfetches initial data
│   ├── api/
│   │   └── v1/
│   │       └── todo/
│   │           └── route.ts          # API Routehandles all CRUD operations
│   └── layout.tsx                    # Root layout with fonts & metadata

├── modules/
│   └── questions/
│       └── todo/
│           ├── components/
│           │   └── todo.tsx          # Client Componentall 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.

04

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

MethodEndpointDescription
GET/api/v1/todoFetch all todos
POST/api/v1/todoCreate a new todo
PUT/api/v1/todoUpdate todo (toggle or edit text)
DELETE/api/v1/todo?id={id}Delete a specific todo
DELETE/api/v1/todo?clearCompleted=trueClear all completed todos

Todo Data Shape

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

app/api/v1/todo/route.tstypescript
// 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.

05

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.

app/(solution-apps)/questions/todo/page.tsxtypescript
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.

modules/questions/todo/services/todoService.tstypescript
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

utils/helper.tstypescript
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.

modules/questions/todo/components/todo.tsxtypescript
"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>
  );
}
06

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:

1

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.

2

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

3

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.

4

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.

07

State Management

This app uses plain useState hooks — no external libraries. Here's the state breakdown:

StateTypePurpose
todosTodo[]The full list of todos
inputstringControlled input for new todo text
filterFilterTypeCurrent filter: all, active, or completed
editingIdstring | nullID of the todo currently being edited
editTextstringTemporary text while editing a todo

Derived State (No Extra useState Needed)

Computed valuestypescript
// 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.

08

Error Handling

Robust error handling separates junior from senior implementations. Here's how errors are handled at each layer:

API Layer (Server)

Error responses from the APItypescript
// 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

utils/helper.ts — error handlingtypescript
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

Component-level error handlingtypescript
// 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.

09

Performance Considerations

Performance questions are common follow-ups. Here are the key optimizations in this architecture and what you could add:

✓ Done

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.

✓ Done

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.

✓ Done

Functional State Updates

Using setTodos(prev => ...) ensures state updates are based on the latest value, avoiding stale closure bugs in async operations.

→ Could add

Optimistic Updates

Update the UI immediately before the API responds, then roll back on error. This makes the app feel instant.

→ Could add

Debounced Auto-Save

For inline editing, debounce the save call so it doesn't fire on every keystroke. Use a 300ms delay.

→ Could add

Virtual Scrolling

For very long lists (1000+ items), use react-window or react-virtuoso to only render visible items in the DOM.

→ Could add

React.memo & useCallback

Wrap individual todo items in React.memo and memoize handlers with useCallback to prevent unnecessary re-renders.

Example: Optimistic update patterntypescript
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);
  }
};
10

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! 🚀