Building a React Next.JS Todo Application with REST API and Prisma

Introduction

Building a modern todo application with Next.js, REST API, and Prisma provides an excellent opportunity to create a full-stack application with powerful features and optimal performance. This guide will walk you through the process of creating a robust todo application using these technologies.

Project Setup and Architecture

The application is structured using Next.js, a powerful React framework that provides server-side rendering capabilities out of the box. The project uses SQLite for data persistence and implements a REST API for backend communication.

Key Technologies:

  • Next.js for the frontend framework
  • SQLite for database management
  • Prisma as the ORM
  • REST API for backend communication

Getting Started with Next.js

Let’s begin by setting up our project using the official Next.js starter. This provides a solid foundation with optimal defaults and configuration.

  1. Create a new Next.js simple Next.js project. We’ll use a more robust one below to initialize our project:
npx create-next-app@latest todo-app

The starter project provides several advantages:

  • Pre-configured ESLint for code quality
  • Optimized build settings
  • TypeScript support (if selected during setup)
  • CSS modules or Tailwind CSS support
  • Testing configuration

You can customize the setup by selecting options during project creation (This is the starter command we’ll be using in this tutorial):

npx create-next-app@latest todo-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd todo-app

This command creates a project with TypeScript, Tailwind CSS, ESLint, App Router, places files in a src directory, and configures import aliases for cleaner imports.

  1. Install required dependencies:
npm install @prisma/client
npm install prisma --save-dev
  1. Initialize Prisma:
npx prisma init
  1. Configure additional development tools:
npm install eslint @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom --save-dev

Project Structure

After setup, your project will have the following structure:

todo-app/
├── prisma/
│   └── schema.prisma
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── todos/
│   │   │       └── route.ts
│   │   ├── page.tsx
│   │   └── layout.tsx
│   ├── components/
│   │   ├── TodoList.tsx
│   │   ├── TodoItem.tsx
│   │   └── TodoForm.tsx
│   └── lib/
│       └── prisma.ts
├── public/
└── .env

This structure follows Next.js App Router conventions, separating API routes, components, and library code.

Database Configuration

The application uses SQLite with Prisma ORM for database management, offering a lightweight yet powerful database solution. Let’s set up our database configuration:

  1. First, update the prisma/schema.prisma file:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

The schema above defines:

  • Generator: Specifies that we’re using the official Prisma JavaScript client.
  • Datasource: Configures SQLite as our database with a connection string from environment variables.
  • Todo Model: Defines our data structure with:
    • id: Auto-incrementing primary key
    • title: Required text field for the todo content
    • completed: Boolean flag with a default of false
    • createdAt: Timestamp that defaults to the creation time
    • updatedAt: Timestamp that automatically updates when the record changes
  1. Create a .env file in your project root to store the database connection URL:
DATABASE_URL="file:./dev.db"

This configures Prisma to use a local SQLite database file named dev.db in the prisma directory.

  1. Run Prisma migration to create the database schema:
npx prisma migrate dev --name init

This command:

  • Creates a migration file in prisma/migrations documenting schema changes
  • Applies the migration to your database, creating all necessary tables
  • Generates the Prisma client tailored to your schema
  • The --name init flag gives this initial migration a descriptive name
  1. Create the src/lib/prisma.ts file to establish a singleton pattern for your Prisma client:
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'

// Prevent multiple instances of Prisma Client in development
declare global {
  var prisma: PrismaClient | undefined
}

// Create a singleton instance of PrismaClient
export const prisma = global.prisma || new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})

// Prevent multiple instances during hot reloading in development
if (process.env.NODE_ENV !== 'production') {
  global.prisma = prisma
}

This singleton implementation solves several key issues:

  • Hot Module Reloading (HMR) Problem: During development, Next.js will reload modules when files change, which can create multiple unnecessary Prisma Client instances.
  • Connection Management: Prevents exceeding database connection limits by reusing the same instance.
  • Logging Configuration: Configures detailed logging in development mode while limiting to errors in production.
  • TypeScript Integration: The declare global statement extends the global namespace type to include our Prisma instance.

You’ll import this file in your API routes and server-side code when you need database access.

API Implementation

The REST API is implemented using Next.js API routes, providing endpoints for CRUD operations. With the App Router, API routes are created in the app/api directory.

Route Handler for All Todos

The first route handler manages collection-level operations – retrieving all todos and creating new ones src/app/api/todos/route.ts:

// src/app/api/todos/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'

// GET /api/todos - Retrieve all todos
export async function GET() {
  try {
    const todos = await prisma.todo.findMany({
      orderBy: { createdAt: 'desc' }
    })
    return NextResponse.json(todos)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch todos' }, { status: 500 })
  }
}

// POST /api/todos - Create a new todo
export async function POST(request: Request) {
  try {
    const body = await request.json()
    
    if (!body.title || typeof body.title !== 'string') {
      return NextResponse.json({ error: 'Title is required' }, { status: 400 })
    }
    
    const todo = await prisma.todo.create({
      data: {
        title: body.title,
        completed: body.completed || false
      }
    })
    
    return NextResponse.json(todo, { status: 201 })
  } catch (error) {
    return NextResponse.json({ error: 'Failed to create todo' }, { status: 500 })
  }
}

GET Function Explained:

  • This handler processes requests to GET /api/todos endpoint
  • Uses a try/catch block to handle errors gracefully
  • Inside the try block, it:
    • Calls Prisma to fetch all todos from the database
    • Orders results with newest todos first (by createdAt timestamp)
    • Returns the todo list as JSON with the default 200 OK status
  • If any error occurs during processing, it:
    • Returns a standardized error message
    • Sets HTTP status to 500 (Internal Server Error)
    • Prevents exposing internal error details to clients

POST Function Explained:

  • This handler processes requests to POST /api/todos endpoint for creating new todos
  • The request body is parsed with await request.json()
  • Input validation ensures:
    • The title field exists
    • The title is a string value
    • Returns a 400 Bad Request with clear error message if validation fails
  • For valid input, it:
    • Uses Prisma to create a new todo record
    • Sets default completed: false if not provided
    • Returns the newly created todo with 201 Created status
    • The returned todo includes all fields including the auto-generated ID
  • Error handling captures database or parsing errors with a clear 500 response

Route Handler for Individual Todos

The second route handler manages operations on specific todos by ID src/app/api/todos/[id]/route.ts:

// src/app/api/todos/[id]/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'

// GET /api/todos/[id] - Get a specific todo
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const id = parseInt(params.id)
    
    if (isNaN(id)) {
      return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
    }
    
    const todo = await prisma.todo.findUnique({
      where: { id }
    })
    
    if (!todo) {
      return NextResponse.json({ error: 'Todo not found' }, { status: 404 })
    }
    
    return NextResponse.json(todo)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch todo' }, { status: 500 })
  }
}

// PUT /api/todos/[id] - Update a todo
export async function PUT(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const id = parseInt(params.id)
    
    if (isNaN(id)) {
      return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
    }
    
    const body = await request.json()
    
    const updatedTodo = await prisma.todo.update({
      where: { id },
      data: {
        title: body.title !== undefined ? body.title : undefined,
        completed: body.completed !== undefined ? body.completed : undefined
      }
    })
    
    return NextResponse.json(updatedTodo)
  } catch (error) {
    // Check for Prisma error for records that don't exist
    if (error.code === 'P2025') {
      return NextResponse.json({ error: 'Todo not found' }, { status: 404 })
    }
    
    return NextResponse.json({ error: 'Failed to update todo' }, { status: 500 })
  }
}

// DELETE /api/todos/[id] - Delete a todo
export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const id = parseInt(params.id)
    
    if (isNaN(id)) {
      return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
    }
    
    await prisma.todo.delete({
      where: { id }
    })
    
    return new NextResponse(null, { status: 204 })
  } catch (error) {
    if (error.code === 'P2025') {
      return NextResponse.json({ error: 'Todo not found' }, { status: 404 })
    }
    
    return NextResponse.json({ error: 'Failed to delete todo' }, { status: 500 })
  }
}

GET Function Explained:

  • Processes requests to GET /api/todos/[id] to fetch a specific todo
  • Receives two parameters:
    • request: The incoming HTTP request
    • params: An object containing route parameters, including the todo id
  • ID validation:
    • Converts the string ID to a number with parseInt()
    • Checks if the conversion produced a valid number with isNaN()
    • Returns a 400 Bad Request if the ID is invalid
  • Database interaction:
    • Uses Prisma’s findUnique to locate a todo by ID
    • Returns 404 Not Found if no todo exists with that ID
    • Returns the todo as JSON if found
  • Includes comprehensive error handling for all failure cases

PUT Function Explained:

  • Processes requests to PUT /api/todos/[id] to update a specific todo
  • ID validation works the same as in the GET handler
  • Parses the request body to extract update fields
  • The update operation:
    • Uses Prisma’s update method to modify the todo
    • Only updates fields that were provided in the request
    • Returns the updated todo as JSON
  • Advanced error handling:
    • Specifically checks for Prisma’s P2025 error code (record not found)
    • Returns a helpful 404 response when the todo doesn’t exist
    • Returns a general 500 error for other failures

DELETE Function Explained:

  • Processes requests to DELETE /api/todos/[id] to remove a specific todo
  • ID validation works the same as other handlers
  • For valid IDs:
    • Uses Prisma’s delete method to remove the todo
    • Returns a 204 No Content response on success (proper REST practice)
    • No body is returned as indicated by new NextResponse(null)
  • Error handling:
    • Returns 404 when attempting to delete a non-existent todo
    • Returns 500 for other database errors

These API routes implement RESTful best practices with proper status codes, error handling, and validation, providing a complete backend for the todo application.

Frontend Components

The React components we’ll build provide the user interface for interacting with our todo data. They’re designed with TypeScript for type safety and Tailwind CSS for styling.

1. Todo Type Interface

First, we define a TypeScript interface for our Todo items to ensure consistent typing across the application:

// src/types/todo.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}

This interface:

  • Mirrors our Prisma schema structure
  • Uses string type for dates since they’ll be serialized when sent over the API
  • Provides type safety throughout our React components

2. TodoItem Component

The TodoItem component renders individual todo entries with checkbox toggling and delete functionality:

// src/components/TodoItem.tsx
import React from 'react';
import { Todo } from '@/types/todo';

interface TodoItemProps {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}

export default function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
  return (
    <div className="flex items-center justify-between p-4 border-b border-gray-200">
      <div className="flex items-center">
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => onToggle(todo.id)}
          className="h-4 w-4 mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
        />
        <span className={todo.completed ? 'line-through text-gray-500' : ''}>
          {todo.title}
        </span>
      </div>
      <button
        onClick={() => onDelete(todo.id)}
        className="text-red-500 hover:text-red-700"
      >
        Delete
      </button>
    </div>
  );
}

Key features of this component:

  • Props Interface: Defines strong typing for the component props
  • Event Delegation: Passes todo ID to parent handlers rather than handling logic internally
  • Visual Feedback: Uses conditional styling to show completed todos with strikethrough text
  • Accessibility: Uses semantic HTML elements with appropriate ARIA attributes
  • Tailwind Styling: Implements responsive layout with flexbox and proper spacing

3. TodoForm Component

This component provides a form for adding new todos:

// src/components/TodoForm.tsx
'use client'
import React, { useState } from 'react';

interface TodoFormProps {
  onAdd: (title: string) => void;
}

export default function TodoForm({ onAdd }: TodoFormProps) {
  const [title, setTitle] = useState('');
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) {
      onAdd(title);
      setTitle('');
    }
  };
  
  return (
    <form onSubmit={handleSubmit} className="mb-6">
      <div className="flex gap-2">
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Add a new todo..."
          className="flex-grow p-2 border border-gray-300 rounded"
        />
        <button
          type="submit"
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          Add
        </button>
      </div>
    </form>
  );
}

Key features of this component:

  • Controlled Input: Uses React state to control the input field
  • Form Validation: Prevents submission of empty todos by checking trimmed value
  • UX Enhancement: Clears input field after successful submission
  • Proper Form Handling: Uses form submission event with preventDefault()
  • Responsive Design: Employs flex layout for input and button positioning

4. TodoList Component

This is our container component that manages todo state and API interactions:

// src/components/TodoList.tsx
'use client'
import { useState, useEffect } from 'react';
import TodoItem from './TodoItem';
import TodoForm from './TodoForm';
import { Todo } from '@/types/todo';

interface TodoListProps {
  initialTodos?: Todo[];
}

export default function TodoList({ initialTodos = [] }: TodoListProps) {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState<boolean>(!initialTodos.length);

  useEffect(() => {
    if (!initialTodos.length) {
      fetchTodos();
    }
  }, [initialTodos.length]);

  const fetchTodos = async (): Promise<void> => {
    setLoading(true);
    try {
      const response = await fetch('/api/todos');
      if (!response.ok) throw new Error('Failed to fetch todos');
      const data: Todo[] = await response.json();
      setTodos(data);
      setError(null);
    } catch (err) {
      setError('Failed to load todos. Please try again.');
      console.error('Error fetching todos:', err);
    } finally {
      setLoading(false);
    }
  };

  const addTodo = async (title: string): Promise<void> => {
    try {
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ title }),
      });
      
      if (!response.ok) throw new Error('Failed to add todo');
      
      const newTodo: Todo = await response.json();
      setTodos([newTodo, ...todos]);
      setError(null);
    } catch (err) {
      setError('Failed to add todo. Please try again.');
      console.error('Error adding todo:', err);
    }
  };

  const toggleTodo = async (id: number): Promise<void> => {
    // Find the todo we're updating
    const todoToUpdate = todos.find(todo => todo.id === id);
    if (!todoToUpdate) return;
    
    // Save original state for potential rollback
    const originalTodos = [...todos];
    
    // Apply optimistic update
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
    
    try {
      const response = await fetch(`/api/todos/${id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ completed: !todoToUpdate.completed })
      });
      
      if (!response.ok) throw new Error('Failed to update todo');
      
    } catch (error) {
      // Revert on failure
      setTodos(originalTodos);
      setError('Failed to update todo');
      console.error('Error toggling todo:', error);
    }
  };

  const deleteTodo = async (id: number): Promise<void> => {
    // Save original state for potential rollback
    const originalTodos = [...todos];
    
    // Optimistic UI update
    setTodos(todos.filter(todo => todo.id !== id));
    
    try {
      const response = await fetch(`/api/todos/${id}`, {
        method: 'DELETE',
      });
      
      if (!response.ok) throw new Error('Failed to delete todo');
      
    } catch (error) {
      // Revert on failure
      setTodos(originalTodos);
      setError('Failed to delete todo');
      console.error('Error deleting todo:', error);
    }
  };

  if (loading) return <div className="text-center p-4">Loading todos...</div>;

  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">Todo List</h2>
      
      {error && (
        <div className="bg-red-100 text-red-700 p-3 rounded mb-4">
          {error}
        </div>
      )}
      
      <TodoForm onAdd={addTodo} />
      
      <div className="bg-white rounded-lg shadow overflow-hidden">
        {todos.length === 0 ? (
          <p className="p-4 text-gray-500 text-center">No todos yet. Add one above!</p>
        ) : (
          todos.map(todo => (
            <TodoItem 
              key={todo.id} 
              todo={todo} 
              onToggle={toggleTodo}
              onDelete={deleteTodo}
            />
          ))
        )}
      </div>
    </div>
  );
}

Key features of this component:

  • State Management: Maintains todos, loading state, and error handling
  • Optimistic UI Updates: Applies changes immediately before API confirmation
  • Error Handling: Provides user feedback and rolls back on failures
  • Loading States: Shows loading indicator while fetching data
  • Empty State Handling: Displays friendly message when no todos exist

The TodoList component uses modern React patterns:

  • Conditional Rendering: Shows different UI based on loading/error states
  • Component Composition: Combines smaller components (TodoForm, TodoItem)
  • Prop Drilling: Passes event handlers down to child components
  • API Integration: Communicates with backend endpoints for data operations

State Management and Data Fetching

Let’s integrate our components with Next.js data fetching capabilities to create a seamless user experience with server-side rendering for initial data load and client-side interactions.

Update your src/app/page.tsx file to render the TodoList component:

// src/app/page.tsx
import { prisma } from '@/lib/prisma'
import TodoList from '@/components/TodoList'
import { Todo } from '@/types/todo'

// Opt into Client-side rendering for this component
export const dynamic = 'force-dynamic';

async function getTodos(): Promise<Todo[]> {
  // This function runs on the server
  const todos = await prisma.todo.findMany({
    orderBy: { createdAt: 'desc' }
  })
  
  // Need to serialize Date objects to strings
  return todos.map(todo => ({
    ...todo,
    createdAt: todo.createdAt.toISOString(),
    updatedAt: todo.updatedAt.toISOString()
  }))
}

export default async function Home() {
  const initialTodos = await getTodos()
  
  return (
    <main className="max-w-4xl mx-auto p-4">
      <h1 className="text-3xl font-bold text-center mb-8">Next.js Todo App</h1>
      <TodoList initialTodos={initialTodos} />
    </main>
  )
}

This approach leverages several key Next.js App Router features:

  • Server Components for Initial Data Loading: The Home component is a Server Component that fetches data directly from the database using Prisma, eliminating the need for an API call on initial page load.
  • Date Serialization: The server component handles converting Prisma’s Date objects to ISO strings before passing them to client components, preventing serialization errors.
  • Dynamic Rendering: The dynamic = 'force-dynamic' directive ensures the page is re-rendered on each request, providing fresh data without client-side cache.
  • Hydration Strategy: By passing initialTodos to the TodoList component, we avoid the “hydration mismatch” problem that can occur when server and client rendering produce different results.
  • Progressive Enhancement: The application works even without JavaScript enabled for the initial page load, as the todos are rendered on the server.

The getTodos() function implements proper error handling practices:

  • Uses a typed Promise return value for better TypeScript integration
  • Handles the date serialization that’s required when passing data from server to client
  • Maintains the same sorting order as the API for consistency

This hybrid approach gives us the best of both worlds:

  1. Fast Initial Page Load: Server-side rendering provides a complete HTML page with todos already populated
  2. Interactive UI: Client-side JavaScript takes over after hydration for a responsive user experience
  3. SEO Benefits: Search engines see fully populated content
  4. Reduced API Calls: No need for an additional API request on initial page load

For subsequent data operations (adding, toggling, and deleting todos), the TodoList component still communicates with our REST API endpoints, maintaining separation of concerns while providing optimistic UI updates.

Testing and Quality Assurance

A robust testing strategy ensures your application remains stable and functions correctly. Let’s set up a comprehensive testing environment for our Next.js Todo application.

Setting Up Testing Tools

  1. First, install the required testing packages:
# Install Jest and React Testing Library
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

# Install additional tools for API testing
npm install --save-dev node-mocks-http
  1. Create a Jest configuration file in your project root:
// jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: './',
})

// Add any custom config to be passed to Jest
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    // Handle module aliases (if you're using them in your project)
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

The Jest configuration:

  • Uses next/jest to handle Next.js-specific configuration
  • Sets up module aliases that match our import paths
  • Configures the proper test environment for React components
  • References a setup file for additional configuration
  1. Create a Jest setup file to include common testing utilities:
// jest.setup.js
import '@testing-library/jest-dom';

// Mock fetch globally
global.fetch = jest.fn(() => 
  Promise.resolve({
    json: () => Promise.resolve({}),
    ok: true
  })
);

// Set up TextEncoder/TextDecoder for Node.js environment
if (typeof TextEncoder === 'undefined') {
  const { TextEncoder, TextDecoder } = require('util');
  global.TextEncoder = TextEncoder;
  global.TextDecoder = TextDecoder;
}

This setup file:

  • Imports Jest DOM extensions for enhanced assertions
  • Creates a global mock for fetch to avoid actual API calls during tests
  • Sets up polyfills for TextEncoder/TextDecoder needed by certain Next.js features
  1. Update your package.json to include test scripts:
// In package.json
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint",
  "test": "jest",
  "test:watch": "jest --watch"
}

Component Tests

Let’s create tests for our main components:

  1. Create a test for the TodoItem component:
// src/components/__tests__/TodoItem.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoItem from '../TodoItem';
import { Todo } from '@/types/todo';

describe('TodoItem', () => {
  const mockTodo: Todo = { 
    id: 1, 
    title: 'Test Todo', 
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  };
  const mockToggle = jest.fn();
  const mockDelete = jest.fn();

  it('renders todo item correctly', () => {
    render(<TodoItem todo={mockTodo} onToggle={mockToggle} onDelete={mockDelete} />);
    
    expect(screen.getByText('Test Todo')).toBeInTheDocument();
    expect(screen.getByRole('checkbox')).not.toBeChecked();
  });

  it('calls onToggle when checkbox is clicked', () => {
    render(<TodoItem todo={mockTodo} onToggle={mockToggle} onDelete={mockDelete} />);
    
    fireEvent.click(screen.getByRole('checkbox'));
    expect(mockToggle).toHaveBeenCalledWith(1);
  });

  it('calls onDelete when delete button is clicked', () => {
    render(<TodoItem todo={mockTodo} onToggle={mockToggle} onDelete={mockDelete} />);
    
    fireEvent.click(screen.getByText('Delete'));
    expect(mockDelete).toHaveBeenCalledWith(1);
  });

  it('displays completed todos with strikethrough', () => {
    const completedTodo = { ...mockTodo, completed: true };
    render(<TodoItem todo={completedTodo} onToggle={mockToggle} onDelete={mockDelete} />);
    
    const todoText = screen.getByText('Test Todo');
    expect(todoText).toHaveClass('line-through');
  });
});

This test suite:

  • Creates a mock todo item and mock handler functions
  • Tests that the component renders with the correct initial state
  • Verifies that clicking the checkbox calls the toggle handler with the correct ID
  • Confirms that the delete button works as expected
  • Validates the visual styling for completed todos
  1. Create a test for the TodoForm component:
// src/components/__tests__/TodoForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoForm from '../TodoForm';

describe('TodoForm', () => {
  const mockAddTodo = jest.fn();

  beforeEach(() => {
    mockAddTodo.mockClear();
  });

  it('renders form correctly', () => {
    render(<TodoForm onAdd={mockAddTodo} />);
    
    expect(screen.getByPlaceholderText('Add a new todo...')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
  });

  it('updates input value when typing', () => {
    render(<TodoForm onAdd={mockAddTodo} />);
    
    const input = screen.getByPlaceholderText('Add a new todo...');
    fireEvent.change(input, { target: { value: 'New test todo' } });
    
    expect(input).toHaveValue('New test todo');
  });

  it('calls onAdd with input value when form is submitted', () => {
    render(<TodoForm onAdd={mockAddTodo} />);
    
    const input = screen.getByPlaceholderText('Add a new todo...');
    fireEvent.change(input, { target: { value: 'New test todo' } });
    
    const button = screen.getByRole('button', { name: 'Add' });
    fireEvent.click(button);
    
    expect(mockAddTodo).toHaveBeenCalledWith('New test todo');
    expect(input).toHaveValue(''); // Input should be cleared
  });

  it('does not call onAdd when input is empty', () => {
    render(<TodoForm onAdd={mockAddTodo} />);
    
    const button = screen.getByRole('button', { name: 'Add' });
    fireEvent.click(button);
    
    expect(mockAddTodo).not.toHaveBeenCalled();
  });
});

This test suite:

  • Tests the initial rendering of the form
  • Verifies that the input field updates correctly as users type
  • Confirms that submitting the form passes the input value to the handler function
  • Validates that empty inputs are not submitted
  • Checks that the input field is cleared after submission

API Route Tests

Create tests for your API endpoints:

// src/app/api/todos/__tests__/route.test.ts
import { GET, POST } from '../route';
import { prisma } from '@/lib/prisma';

// Mock Next.js modules before importing route handlers
jest.mock('next/server', () => ({
  NextResponse: {
    json: jest.fn().mockImplementation((data, options = {}) => ({
      status: options.status || 200,
      json: async () => data,
    })),
  },
}), { virtual: true });

// Mock Prisma client
jest.mock('@/lib/prisma', () => ({
  prisma: {
    todo: {
      findMany: jest.fn(),
      create: jest.fn()
    }
  }
}));

describe('Todos API', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  
  describe('GET handler', () => {
    it('returns todos successfully', async () => {
      const mockTodos = [
        { id: 1, title: 'Test Todo', completed: false, createdAt: new Date(), updatedAt: new Date() }
      ];
      
      (prisma.todo.findMany as jest.Mock).mockResolvedValue(mockTodos);
      
      const response = await GET();
      const data = await response.json();
      
      expect(response.status).toBe(200);
      expect(data).toEqual(mockTodos);
      expect(prisma.todo.findMany).toHaveBeenCalledWith({ orderBy: { createdAt: 'desc' } });
    });
    
    it('handles errors gracefully', async () => {
      (prisma.todo.findMany as jest.Mock).mockRejectedValue(new Error('Database error'));
      
      const response = await GET();
      const data = await response.json();
      
      expect(response.status).toBe(500);
      expect(data).toEqual({ error: 'Failed to fetch todos' });
    });
  });
  
  describe('POST handler', () => {
    it('creates a new todo', async () => {
      const mockTodo = { id: 1, title: 'New Todo', completed: false, createdAt: new Date(), updatedAt: new Date() };
      (prisma.todo.create as jest.Mock).mockResolvedValue(mockTodo);
      
      // Mock request object with just the methods we need
      const mockRequest = {
        json: jest.fn().mockResolvedValue({ title: 'New Todo' })
      };
      
      const response = await POST(mockRequest as any);
      const data = await response.json();
      
      expect(response.status).toBe(201);
      expect(data).toEqual(mockTodo);
      expect(prisma.todo.create).toHaveBeenCalledWith({
        data: { title: 'New Todo', completed: false }
      });
    });
    
    it('validates request body', async () => {
      // Mock request object with json method returning body without title
      const mockRequest = {
        json: jest.fn().mockResolvedValue({ completed: false })
      };
      
      const response = await POST(mockRequest as any);
      const data = await response.json();
      
      expect(response.status).toBe(400);
      expect(data).toEqual({ error: 'Title is required' });
      expect(prisma.todo.create).not.toHaveBeenCalled();
    });
  });
});

This API test suite:

  • Creates mocks for Next.js server responses and Prisma database calls
  • Tests successful retrieval of todos via the GET handler
  • Verifies error handling when database operations fail
  • Tests todo creation with valid input data
  • Confirms input validation for the POST handler

Testing Dynamic API Routes

Let’s also test the individual todo routes:

// src/app/api/todos/[id]/__tests__/route.test.ts
jest.mock('next/server', () => ({
  NextResponse: {
    json: jest.fn().mockImplementation((data, options = {}) => ({
      status: options.status || 200,
      json: async () => data,
    })),
  },
}), { virtual: true });

// Mock Prisma client
jest.mock('@/lib/prisma', () => ({
  prisma: {
    todo: {
      findUnique: jest.fn(),
      update: jest.fn(),
      delete: jest.fn()
    }
  }
}));

// Import route handlers AFTER mocking dependencies
import { GET, PUT, DELETE } from '../route';
import { prisma } from '@/lib/prisma';

describe('Todo Item API', () => {
  // Parameters to pass to route handlers
  const params = { params: { id: '1' } };
  
  beforeEach(() => {
    jest.clearAllMocks();
  });
  
  describe('GET handler', () => {
    it('returns a specific todo successfully', async () => {
      const mockTodo = { 
        id: 1, 
        title: 'Test Todo', 
        completed: false, 
        createdAt: new Date(), 
        updatedAt: new Date() 
      };
      
      (prisma.todo.findUnique as jest.Mock).mockResolvedValue(mockTodo);
      
      // Simple mock request object
      const mockRequest = {};
      
      const response = await GET(mockRequest as any, params);
      const data = await response.json();
      
      expect(response.status).toBe(200);
      expect(data).toEqual(mockTodo);
      expect(prisma.todo.findUnique).toHaveBeenCalledWith({
        where: { id: 1 }
      });
    });
    
    it('returns 404 when todo is not found', async () => {
      (prisma.todo.findUnique as jest.Mock).mockResolvedValue(null);
      
      const mockRequest = {};
      const response = await GET(mockRequest as any, params);
      const data = await response.json();
      
      expect(response.status).toBe(404);
      expect(data).toEqual({ error: 'Todo not found' });
    });
  });
  
  describe('PUT handler', () => {
    it('updates a todo successfully', async () => {
      const mockUpdatedTodo = { 
        id: 1, 
        title: 'Updated Todo', 
        completed: true, 
        createdAt: new Date(), 
        updatedAt: new Date() 
      };
      
      (prisma.todo.update as jest.Mock).mockResolvedValue(mockUpdatedTodo);
      
      // Create a mock request with json method
      const mockRequest = {
        json: jest.fn().mockResolvedValue({ title: 'Updated Todo', completed: true })
      };
      
      const response = await PUT(mockRequest as any, params);
      const data = await response.json();
      
      expect(response.status).toBe(200);
      expect(data).toEqual(mockUpdatedTodo);
      expect(prisma.todo.update).toHaveBeenCalledWith({
        where: { id: 1 },
        data: { title: 'Updated Todo', completed: true }
      });
    });
  });
  
  describe('DELETE handler', () => {
    it('deletes a todo successfully', async () => {
      (prisma.todo.delete as jest.Mock).mockResolvedValue({});
      
      const mockRequest = {};
      const response = await DELETE(mockRequest as any, params);
      
      expect(response.status).toBe(204);
      expect(prisma.todo.delete).toHaveBeenCalledWith({
        where: { id: 1 }
      });
    });
  });
});

This test suite for dynamic routes:

  • Tests fetching individual todo items by ID
  • Verifies 404 responses when items don’t exist
  • Tests updating todo items with the PUT handler
  • Confirms deletion works correctly with the DELETE handler
  • Validates that route parameters are properly parsed and used

Running Tests

Run your tests using the following commands:

# Run all tests
npm test

# Run tests in watch mode (for development)
npm run test:watch

These commands execute the Jest test runner with different options:

  • npm test performs a single run of all tests, ideal for CI/CD pipelines
  • npm run test:watch runs Jest in watch mode, which automatically re-runs tests when files change

By implementing these testing strategies, you’ll ensure that your Todo application remains stable and functions correctly, even as you add new features or refactor existing code.

Conclusion

Building a Todo application with Next.js, REST API, and SQLite provides a solid foundation for creating modern web applications. By following the practices and implementations outlined in this guide, you can create a robust, maintainable, and user-friendly application that can be extended with additional features as needed.

More From Author

You May Also Like