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:
Feature | React | Next.js |
---|---|---|
Type | UI Framework | Full-stack framework built on React |
Routing | Manual via react-router | Built-in file-based routing |
Server-Side Rendering (SSR) | After v19 | Built-in |
Static Site Generation | Not built-in | Supported via getStaticProps & getStaticPaths |
SEO Support | Needs manual setup | Support with SSR/SSG |
Setup & Config | Manual | Mostly automatic |
Deployment | Requires custom setup | Optimized 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
andtailwind-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.