How to Create a Next.js MongoDB Todo Project: A Complete Guide
Introduction In today’s fast-paced development world, building a full-stack app doesn’t have to be complicated. With the power of Next.js and MongoDB, developers can quickly create scalable applications. In this tutorial, you will learn how to build a simple, full-stack To-Do App that leverages the App Router in Next.js and MongoDB for data persistence. Moreover, this app will be built using pure JavaScript (no TypeScript), making it perfect for beginners. By the end of this guide, you’ll understand how to set up a database, connect it using Mongoose, and build a frontend that interacts seamlessly with your backend. Prerequisites Before we begin, make sure you have: Project Overview We’ll build a simple but powerful To-Do App that includes: Choose: I have a Node.js version v22.17.0 npm Version 10.3.0 mongoose version v7.0.11 Step 1: Setting Up the Next.js Project First, let’s create a new Next.js project with JavaScript support: npx create-next-app@latest todo-project cd todo-project While running this command, it will ask you some questions about the dependency so that you can choose the option “No” for TypeScript and TailwindCSS Install additional dependencies we’ll need: Step 2: Setting Up MongoDB Connection Install MongoDB Community Edition locally and run the MongoDB service. Option A: MongoDB Atlas (Cloud) Option B: Local MongoDB Install MongoDB Community Edition locally and run the MongoDB service. Creating the Database Connection Utility Create a new file lib/mongodb.js: npm install mongoose I have created lib/mongob.js inside the src folder, and now paste this code import mongoose from ‘mongoose’ const MONGODB_URI = process.env.MONGODB_URI if (!MONGODB_URI) throw new Error(‘MONGODB_URI not defined in .env.local’) let cached = global.mongoose || { conn: null, promise: null } export async function connectDB() { if (cached.conn) return cached.conn if (!cached.promise) { cached.promise = mongoose.connect(MONGODB_URI, { dbName: ‘todo-app’, bufferCommands: false, }).then((mongoose) => { return mongoose }) } cached.conn = await cached.promise return cached.conn } Create a .env.local file in your src folder: MONGODB_URI=mongodb://<username>:<password>@localhost:27017/nextjsdb?authSource=admin Step 3: Creating the Todo Model Create a new directory models Inside the src and add Todo.js: import mongoose from ‘mongoose’ const TodoSchema = new mongoose.Schema({ text: { type: String, required: true }, completed: { type: Boolean, default: false } }, { timestamps: true }) export default mongoose.models.Todo || mongoose.model(‘Todo’, TodoSchema) Step 4: Creating API Routes For GET and POST requests, we use a single route.js file. For DELETE and PUT requests (which require dynamic parameters like an id We create a separate folder structure. This is because Next.js follows a file-based routing system, and each endpoint must have its own file or dynamic folder to handle different HTTP methods and routes correctly. GET & POST → /app/api/todos/route.js import { connectDB } from ‘@/lib/mongodb’ import Todo from ‘@/models/Todo’ export async function GET() { await connectDB() const todos = await Todo.find().sort({ createdAt: -1 }) return Response.json(todos) } export async function POST(req) { const { text } = await req.json() await connectDB() const newTodo = await Todo.create({ text }) return Response.json(newTodo) } Create the Folder path below. I have added PUT & DELETE → /app/api/todos/[id]/route.js Note: This would be a folder name [id] Do not confuse. import { connectDB } from ‘@/lib/mongodb’ import Todo from ‘@/models/Todo’ export async function PUT(req, { params }) { const { id } = params const { completed } = await req.json() await connectDB() const updated = await Todo.findByIdAndUpdate(id, { completed }, { new: true }) return Response.json(updated) } export async function DELETE(req, { params }) { const { id } = params await connectDB() await Todo.findByIdAndDelete(id) return Response.json({ message: ‘Deleted’ }) } Step 6: Build the UI — /app/page.js Now that our API is ready, let’s shift our focus to the front end. To begin with, we’ll build the user interface using React (via Next.js). This will include a task input field, a task list, and buttons to complete or delete a task. First, we define two state variables: task to hold the current input, and todos to store the fetched task list. After the component mounts, we use useEffect to fetch tasks from the API and display them on the screen. When a user adds a task, it is sent to the backend via a POST request. Then, the new task is added to the state and shown immediately in the list. In contrast, when a task is toggled or deleted, we use PUT and DELETE requests to update the backend accordingly. As a result, the interface remains synced with the database in real time. The file /app/page.js is the main UI page of your application — it’s where users: ‘use client’ What’s Happening in /app/page.js: This line tells Next.js that this file uses client-side rendering (since we use React hooks like useState and useEffect). React State const [task, setTask] = useState(”) const [todos, setTodos] = useState([]) Add a New Task const addTodo = async () => { if (!task.trim()) return const res = await fetch(‘/api/todos’, { method: ‘POST’, body: JSON.stringify({ text: task }), }) const newTodo = await res.json() setTodos([newTodo, …todos]) setTask(”) } Full code ‘use client’import { useEffect, useState } from ‘react’export default function Home() { const [task, setTask] = useState(”) const [todos, setTodos] = useState([]) useEffect(() => { fetchTodos() }, []) const fetchTodos = async () => { const res = await fetch(‘/api/todos’) const data = await res.json() setTodos(data) } const addTodo = async () => { if (!task.trim()) return const res = await fetch(‘/api/todos’, { method: ‘POST’, body: JSON.stringify({ text: task }), }) const newTodo = await res.json() setTodos([newTodo, …todos]) setTask(”) } const toggleComplete = async (id, completed) => { const res = await fetch(`/api/todos/${id}`, { method: ‘PUT’, body: JSON.stringify({ completed: !completed }) }) const updated = await res.json() setTodos(todos.map(todo => todo._id === id ? updated : todo)) } const deleteTodo = async (id) => { await fetch(`/api/todos/${id}`, { method: ‘DELETE’ }) setTodos(todos.filter(todo => todo._id !== id)) } return ( <main className=”min-h-screen p-6 bg-gray-100 flex flex-col items-center”> <h1 className=”text-2xl font-bold mb-4″>To-Do App</h1> <div className=”flex gap-2 mb-4″> <input type=”text” value={task} onChange={(e) => setTask(e.target.value)} placeholder=”Enter task…” className=”border p-2 rounded w-64″ /> <button onClick={addTodo} className=”bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600″ > Add </button> </div>
How to Create a Next.js MongoDB Todo Project: A Complete Guide Read More »

