Dev Dissection — Week 3: Building NextJS Frontend & Connecting with Your API

Welcome back to Dev Dissection! In Week 2, we wired up our TODO API to MongoDB, making our data persistent. This week, we’ll bring that data to life by building a frontend using React. Our goal? A clean UI where users can view and create TODOs.

By the end of this lesson, your browser will show real data from your backend.


Prerequisites

Make sure you have:

  • Your Week 2 TODO API running
  • A modern browser (Chrome, Edge, etc.)
  • Basic familiarity with JavaScript and HTML

React vs Next.js — What’s the Difference?

If you’re new to frontend development, it’s easy to confuse React and Next.js — they often get mentioned together. But they serve different roles:

FeatureReactNext.js
TypeUI FrameworkFull-stack framework built on React
RoutingManual via react-routerBuilt-in file-based routing
Server-Side Rendering (SSR)After v19Built-in
Static Site GenerationNot built-inSupported via getStaticProps & getStaticPaths
SEO SupportNeeds manual setupSupport with SSR/SSG
Setup & ConfigManualMostly automatic
DeploymentRequires custom setupOptimized for Vercel but works elsewhere too

TL;DR:

  • React is perfect when you want full control and are building a frontend-only app.
  • Next.js is better for full-stack apps, SEO-focused sites.

In this session plan, we’re using Next.js because it simplifies setup, works beautifully with APIs, and scales well with your growing app.

Let’s Set Up NextJS

Before we write any code, we need to scaffold a Next.js project and install some helpful libraries for UI and styling.

Step 1: Initialize Your Next.js App

Open your terminal and run:

npx create-next-app@latest frontend --typescript
cd frontend

Choose:

  • App Router when prompted (not the pages directory)
  • Yes to Tailwind CSS
  • Yes to ESLint
  • Yes to TypeScript

Step 2: Replace package.json with the Following

We’re adding a few libraries like:

  • Radix UI components for consistent, accessible design
  • Lucide for icons
  • Tailwind utilities like clsx and tailwind-merge
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@radix-ui/react-checkbox": "^1.3.2",
    "@radix-ui/react-slot": "^1.2.3",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "lucide-react": "^0.515.0",
    "next": "15.2.4",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwind-merge": "^3.3.1"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3",
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "15.2.4",
    "tailwindcss": "^4",
    "tw-animate-css": "^1.3.4",
    "typescript": "^5"
  }
}

Now install the dependencies with:

npm install

Let’s start coding

UI Components we need for todo list: under /components/ui folder

  • Button ( for adding, deleting, updating our todos )
  • Input ( A html element that let’s us insert text )
  • Checkbox ( To mark our todo as completed or not )

/components/ui/button.tsx

import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import * as React from 'react';

import { cn } from '@/lib/utils';

// Define button styles using class-variance-authority
const buttonVariants = cva(
  // Base styles for all buttons
  'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      // Different button styles
      variant: {
        default: 'bg-primary text-white hover:bg-primary/90',
        destructive: 'bg-red-500 text-white hover:bg-red-600',
        outline: 'border border-gray-300 bg-white hover:bg-gray-100',
        secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
        ghost: 'hover:bg-gray-100',
        link: 'text-blue-600 underline-offset-4 hover:underline',
      },
      // Different button sizes
      size: {
        default: 'h-9 px-4 py-2',
        sm: 'h-8 px-3 text-xs',
        lg: 'h-10 px-6 text-base',
        icon: 'h-9 w-9',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

// Button component props interface
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  // Optional props
  variant?:
    | 'default'
    | 'destructive'
    | 'outline'
    | 'secondary'
    | 'ghost'
    | 'link';
  size?: 'default' | 'sm' | 'lg' | 'icon';
  isLoading?: boolean;
  asChild?: boolean;
}

function Button({
  className,
  variant,
  size,
  isLoading = false,
  asChild = false,
  children,
  disabled,
  ...props
}: ButtonProps) {
  // Use Slot component if asChild is true, otherwise use regular button
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      className={cn(buttonVariants({ variant, size, className }))}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
      {children}
    </Comp>
  );
}

export { Button, buttonVariants };

/components/ui/checkbox.tsx

'use client';

import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';

import { cn } from '@/lib/utils';

function Checkbox({
  className,
  ...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
  return (
    <CheckboxPrimitive.Root
      data-slot="checkbox"
      className={cn(
        'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
        className
      )}
      {...props}
    >
      <CheckboxPrimitive.Indicator
        data-slot="checkbox-indicator"
        className="flex items-center justify-center text-current transition-none"
      >
        <CheckIcon className="size-3.5" />
      </CheckboxPrimitive.Indicator>
    </CheckboxPrimitive.Root>
  );
}

export { Checkbox };

/components/ui/input.tsx

import * as React from 'react';

import { cn } from '@/lib/utils';

function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
        'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
        'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
        className
      )}
      {...props}
    />
  );
}

export { Input };

Helpers we need for our app: Under /lib folder

  • API information ( Which apis route to consume for CURD Ops )
  • Types definition ( The structure of our Todos, same as backend and API data structure )
  • Utilities ( Extra things needed for app to work properly )

/lib/api.ts

import type { Todo, CreateTodoRequest, UpdateTodoRequest } from './types';

const API_URL = 'http://localhost:4000';

export class TodoAPI {
  // Get all todos
  static async getTodos(): Promise<Todo[]> {
    const response = await fetch(`${API_URL}/todos`);
    if (!response.ok) throw new Error('Failed to fetch todos');
    return response.json();
  }

  // Create new todo
  static async createTodo(data: CreateTodoRequest): Promise<Todo> {
    const response = await fetch(`${API_URL}/todos`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error('Failed to create todo');
    return response.json();
  }

  // Toggle todo completion
  static async toggleTodo(id: string, data: UpdateTodoRequest): Promise<Todo> {
    const response = await fetch(`${API_URL}/todos/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        completed: data.completed,
        task: data.task,
      }),
    });
    if (!response.ok) throw new Error('Failed to update todo');
    return response.json();
  }

  // Delete todo
  static async deleteTodo(id: string): Promise<void> {
    const response = await fetch(`${API_URL}/todos/${id}`, {
      method: 'DELETE',
    });
    if (!response.ok) throw new Error('Failed to delete todo');
  }
}

/lib/types.ts

export interface Todo {
  _id: string;
  task: string;
  completed: boolean;
}

export interface CreateTodoRequest {
  task: string;
}

export interface UpdateTodoRequest {
  completed?: boolean;
  task?: string;
}

lib/utils.ts

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Building the app:

Let’s start by updating our app entry point:

page.tsx

import { TodoList } from '@/components/todo-list';

export default function Home() {
  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="container mx-auto px-4">
        <div className="text-center mb-8">
          <h1 className="text-3xl font-bold text-gray-900 mb-2">
            Simple Todo List
          </h1>
          <p className="text-gray-600">Keep track of your tasks</p>
        </div>

        <TodoList />
      </div>
    </div>
  );
}

Then we’ll create a TodoList component that would render our todo component:

/components/todo-list.tsx

'use client';

import { useState, useEffect, useCallback } from 'react';
import type { Todo } from '@/lib/types';
import { TodoAPI } from '@/lib/api';
import { TodoItem } from './todo-item';
import { Loader2 } from 'lucide-react';
import AddTodoForm from './add-todo-form';

export function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // Load todos on component mount
  useEffect(() => {
    loadTodos();
  }, []);

  const loadTodos = async () => {
    try {
      setLoading(true);
      const data = await TodoAPI.getTodos();
      setTodos(data);
      setError(null);
    } catch (err) {
      setError('Failed to load todos');
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  const handleAddTodo = async (task: string) => {
    try {
      const newTodo = await TodoAPI.createTodo({ task });
      setTodos((prev) => [newTodo, ...prev]);
    } catch (err) {
      setError('Failed to add todo');
      console.error(err);
    }
  });

  const handleToggleTodo = async (id: string, completed: boolean) => {
    try {
      const updatedTodo = await TodoAPI.toggleTodo(id, { completed });
      setTodos(todos.map((todo) => (todo._id === id ? updatedTodo : todo)));
    } catch (err) {
      setError('Failed to update todo');
      console.error(err);
    }
  };

  const handleDeleteTodo = async (id: string) => {
    try {
      await TodoAPI.deleteTodo(id);
      setTodos(todos.filter((todo) => todo._id !== id));
    } catch (err) {
      setError('Failed to delete todo');
      console.error(err);
    }
  };

  const handleUpdateTodo = async (id: string, task: string) => {
    try {
      const updatedTodo = await TodoAPI.toggleTodo(id, { task });
      setTodos(todos.map((todo) => (todo._id === id ? updatedTodo : todo)));
    } catch (err) {
      setError('Failed to update todo');
      console.error(err);
    }
  };

  if (loading) {
    return (
      <div className="flex justify-center py-8">
        <Loader2 className="h-6 w-6 animate-spin" />
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto">
      <AddTodoForm onAdd={handleAddTodo} />

      {error && (
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
          {error}
        </div>
      )}

      <div className="space-y-2">
        {todos.length === 0 ? (
          <div className="text-center py-8 text-gray-500">
            <p>No todos yet. Add one above!</p>
          </div>
        ) : (
          todos.map((todo) => (
            <TodoItem
              key={todo._id}
              todo={todo}
              onToggle={handleToggleTodo}
              onDelete={handleDeleteTodo}
              onUpdate={handleUpdateTodo}
            />
          ))
        )}
      </div>

      {todos.length > 0 && (
        <div className="mt-6 text-center text-sm text-gray-500">
          {todos.filter((t) => !t.completed).length} of {todos.length} tasks
          remaining
        </div>
      )}
    </div>
  );
}

Let’s break it down:

  • useEffect loads all our todos from API by calling the function inside api.ts
  • We have Create, update, delete function that call their functions inside api.ts for those opeations
  • We have two components, AddTodoForm renders the form where we’ll input the task, and Todo items list which will show todo items.
  • Other functions other than read and create, are mapped to todo items using callback, basically we want to either update or delete a todo.

Now adding our TodoForm & Todo items

/components/add-todo-form.tsx

'use client';

import type React from 'react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus } from 'lucide-react';
import { useState } from 'react';

interface AddTodoFormProps {
  onAdd: (task: string) => Promise<void>;
}

function AddTodoForm({ onAdd }: AddTodoFormProps) {
  const [task, setTask] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (task.trim()) {
      try {
        setIsLoading(true);
        await onAdd(task.trim());
        setTask('');
      } finally {
        setIsLoading(false);
      }
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2 mb-6">
      <Input
        type="text"
        placeholder="Add a new task..."
        value={task}
        onChange={(e) => setTask(e.target.value)}
        className="flex-1"
        disabled={isLoading}
      />
      <Button
        type="submit"
        disabled={!task.trim() || isLoading}
        isLoading={isLoading}
      >
        <Plus className="h-4 w-4" />
      </Button>
    </form>
  );
}

export default AddTodoForm;

Breaking it down:

  • Add form contains button and form with input
  • When clicked on button, it makes sure that input has some value and then call function onAdd coming from parent. The parent onAdd actually makes API call and adds the todo. After that is successful, it resets the field.

/components/todo-item.tsx

'use client';

import type React from 'react';

import { useState } from 'react';
import type { Todo } from '@/lib/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Edit2, Trash2, Check, X } from 'lucide-react';

interface TodoItemProps {
  todo: Todo;
  onToggle: (id: string, completed: boolean) => Promise<void>;
  onDelete: (id: string) => Promise<void>;
  onUpdate: (id: string, task: string) => Promise<void>;
}

export function TodoItem({
  todo,
  onToggle,
  onDelete,
  onUpdate,
}: TodoItemProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [editTask, setEditTask] = useState(todo.task);
  const [isUpdating, setIsUpdating] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);
  const [isToggling, setIsToggling] = useState(false);

  const handleToggleComplete = async () => {
    try {
      setIsToggling(true);
      await onToggle(todo._id, !todo.completed);
    } finally {
      setIsToggling(false);
    }
  };

  const handleSaveEdit = async () => {
    if (!editTask.trim() || editTask === todo.task) {
      setIsEditing(false);
      setEditTask(todo.task);
      return;
    }

    try {
      setIsUpdating(true);
      await onUpdate(todo._id, editTask.trim());
      setIsEditing(false);
    } catch (error) {
      console.error(error);
      setEditTask(todo.task);
    } finally {
      setIsUpdating(false);
    }
  };

  const handleCancelEdit = () => {
    setIsEditing(false);
    setEditTask(todo.task);
  };

  const handleDelete = async () => {
    try {
      setIsDeleting(true);
      await onDelete(todo._id);
    } catch (error) {
      console.error(error);
    } finally {
      setIsDeleting(false);
    }
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      handleSaveEdit();
    } else if (e.key === 'Escape') {
      handleCancelEdit();
    }
  };

  const isActionInProgress = isUpdating || isDeleting || isToggling;

  return (
    <div
      className={`flex items-center gap-3 p-4 bg-white rounded-lg border shadow-sm ${
        todo.completed ? 'opacity-75' : ''
      }`}
    >
      <div className="flex items-center">
        <Checkbox
          checked={todo.completed}
          onCheckedChange={handleToggleComplete}
          disabled={isActionInProgress}
          aria-label={`Mark "${todo.task}" as ${
            todo.completed ? 'incomplete' : 'complete'
          }`}
        />
      </div>

      <div className="flex-1 min-w-0">
        {isEditing ? (
          <Input
            value={editTask}
            onChange={(e) => setEditTask(e.target.value)}
            onKeyDown={handleKeyPress}
            onBlur={handleSaveEdit}
            disabled={isUpdating}
            className="w-full"
            autoFocus
            aria-label="Edit task"
          />
        ) : (
          <span
            className={`text-sm break-words ${
              todo.completed ? 'line-through text-gray-500' : 'text-foreground'
            }`}
          >
            {todo.task}
          </span>
        )}
      </div>

      <div className="flex items-center gap-1">
        {isEditing ? (
          <>
            <Button
              size="sm"
              variant="ghost"
              onClick={handleSaveEdit}
              isLoading={isUpdating}
              disabled={isActionInProgress}
              aria-label="Save changes"
            >
              <Check className="h-4 w-4 text-green-600" />
            </Button>
            <Button
              size="sm"
              variant="ghost"
              onClick={handleCancelEdit}
              disabled={isActionInProgress}
              aria-label="Cancel editing"
            >
              <X className="h-4 w-4 text-gray-500" />
            </Button>
          </>
        ) : (
          <>
            <Button
              size="sm"
              variant="ghost"
              onClick={() => setIsEditing(true)}
              disabled={isActionInProgress}
              aria-label={`Edit "${todo.task}"`}
            >
              <Edit2 className="h-4 w-4 text-blue-600" />
            </Button>
            <Button
              size="sm"
              variant="ghost"
              onClick={handleDelete}
              isLoading={isDeleting}
              disabled={isActionInProgress}
              className="text-red-500 hover:text-red-700"
              aria-label={`Delete "${todo.task}"`}
            >
              <Trash2 className="h-4 w-4" />
            </Button>
          </>
        )}
      </div>
    </div>
  );
}

Breaking it down:

  • Todo item shows the task and it’s status (status on UI is rendered as checkbox) along with buttons edit and delete.
  • The input field and buttons are conditionally rendered, depending isEditing state. If it’s true we show Input field to edit task name, along with tick button and cancel button. This state is set to true when Edit icon is clicked.
  • When delete icon is clicked, it calls the parent function onDelete ( via handleDelete ) to delete a todo, The parent ( todolist ) calls the API to delete the todo.

At the end, your frontend app structure would look something like this:

.
├── app/                        # Next.js App Router entry — contains routes and layouts
│   ├── favicon.ico             # Favicon used in the browser tab
│   ├── globals.css             # Global styles applied across the app
│   ├── layout.tsx              # Root layout shared across all routes
│   └── page.tsx                # Home page of the app (i.e., `/`)
│
├── components/                # Reusable UI components
│   ├── add-todo-form.tsx      # Form to create a new TODO
│   ├── todo-item.tsx          # UI for a single TODO item
│   ├── todo-list.tsx          # Displays a list of TODO items
│   └── ui/                    # Atomic design system components
│       ├── button.tsx         # Styled button component
│       ├── checkbox.tsx       # Styled checkbox component
│       └── input.tsx          # Styled input component
│
├── lib/                       # Utility logic and types
│   ├── api.ts                 # API helper functions for fetching/creating TODOs
│   ├── types.ts               # Shared TypeScript types (e.g. `Todo`)
│   └── utils.ts               # Utility/helper functions used throughout the app
│
├── public/                    # Static files served at root path (`/`)
│   ├── file.svg               # Icon assets
│   ├── globe.svg              # "
│   ├── next.svg               # "
│   ├── vercel.svg             # "
│   └── window.svg             # "
│
├── next.config.ts             # Custom Next.js configuration
├── tsconfig.json              # TypeScript configuration
├── postcss.config.mjs         # PostCSS config used by Tailwind
├── eslint.config.mjs          # ESLint config for code quality checks
├── next-env.d.ts              # TypeScript environment declarations for Next.js
├── package.json               # Project metadata and scripts
├── package-lock.json          # Exact versions of installed dependencies
└── README.md                  # Project documentation and setup notes

Try it Out

Let’s test the complete flow — frontend and backend working together.

1. Make Sure Your API is Running

If you followed Week 2, you should already have your Express + MongoDB backend. Make sure it’s running:

npm run dev

This should start your backend on:

http://localhost:4000

You can verify it’s working by opening that URL in your browser or using a tool like Hoppscotch or Postman to send a GET request to:

http://localhost:4000/todos

You should get back an array of TODOs (even if it’s empty at first).


2. Start the Frontend

In a new terminal window/tab, move to your frontend folder and run:

npm run dev

Next.js will start the frontend at:

http://localhost:3000

Now open that URL in your browser. You should see:

  • A form to add a TODO
  • A list of current TODOs fetched from the API

Add a new task and watch it show up instantly


What You Learned

This week, you:

✅ Learned the difference between React and Next.js, and why Next.js is often a better choice for fullstack apps
✅ Set up a Next.js project with TypeScript and Tailwind CSS
✅ Created a clean, modern UI to display and add TODOs
✅ Fetched data from your Express + MongoDB API using fetch()


Coming Up: Authentication

In Week 4, we’ll dive into user authentication — a must-have for most real-world apps.

We’ll cover:

  • The basics of authentication and why it matters
  • Cookies vs JWTs — which one to use and when
  • Overview of auth providers like Auth0, Clerk, and custom strategies
  • How to protect API routes and show user-specific TODOs

By the end, your TODO app will be ready for real users with secure login and personalized data.