How to Build a Flashcard Study App with Next.js and MongoDB — Opportunihub
Course Remote

How to Build a Flashcard Study App with Next.js and MongoDB

David Aniebo · Remote

At a glance

Type
Course
Organisation
David Aniebo
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
26 Aug 2026

About this course

<p>If you've ever crammed for an exam the night before, you know how hard it is to remember everything.</p> <p>Flashcards are one of the most effective study tools because they use <strong>active recall</strong>: you actively try to remember the answer instead of passively reading notes. Research shows this strengthens memory and helps information stick.</p> <p>In this tutorial, you'll build a full-stack flashcard app that lets students:</p> <ul> <li><p><strong>Create subjects</strong> (like "Biology 101" or "Calculus")</p> </li> <li><p><strong>Add flashcards</strong> with a question on the front and answer on the back</p> </li> <li><p><strong>Study</strong> by flipping cards and marking them correct or wrong</p> </li> <li><p><strong>Track progress</strong> to see how well they're doing</p> </li> </ul> <p>By the end, you'll have a working app that stores data in MongoDB and runs on Next.js. No prior experience with these tools is required. We'll explain everything as we go.</p> <h2 id="heading-table-of-contents">Table of Contents</h2> <ul> <li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p> </li> <li><p><a href="#heading-tech-stack-overview">Tech Stack Overview</a></p> </li> <li><p><a href="#heading-project-setup">Project Setup</a></p> <ul> <li><p><a href="#heading-step-1-create-the-nextjs-project">Step 1: Create the Next.js Project</a></p> </li> <li><p><a href="#heading-step-2-install-mongoose">Step 2: Install Mongoose</a></p> </li> <li><p><a href="#heading-step-3-set-up-mongodb">Step 3: Set Up MongoDB</a></p> </li> <li><p><a href="#heading-step-4-create-the-environment-file">Step 4: Create the Environment File</a></p> </li> <li><p><a href="#heading-step-5-understand-the-folder-structure">Step 5: Understand the Folder Structure</a></p> </li> </ul> </li> <li><p><a href="#heading-building-the-features">Building the Features</a></p> <ul> <li><p><a href="#heading-part-1-connecting-to-mongodb">Part 1: Connecting to MongoDB</a></p> </li> <li><p><a href="#heading-part-2-defining-the-data-models">Part 2: Defining the Data Models</a></p> <ul> <li><p><a href="#heading-subject-model">Subject Model</a></p> </li> <li><p><a href="#heading-flashcard-model">Flashcard Model</a></p> </li> <li><p><a href="#heading-progress-model">Progress Model</a></p> </li> </ul> </li> <li><p><a href="#heading-part-3-creating-subjects-and-flashcards-api-routes">Part 3: Creating Subjects and Flashcards (API Routes)</a></p> <ul> <li><p><a href="#heading-subjects-api-list-and-create">Subjects API – List and Create</a></p> </li> <li><p><a href="#heading-flashcards-api-list-and-create">Flashcards API – List and Create</a></p> </li> </ul> </li> <li><p><a href="#heading-part-4-editing-and-deleting-dynamic-api-routes">Part 4: Editing and Deleting (Dynamic API Routes)</a></p> <ul> <li><p><a href="#heading-subject-by-id-get-update-delete">Subject by ID – Get, Update, Delete</a></p> </li> <li><p><a href="#heading-flashcard-by-id-get-update-delete">Flashcard by ID – Get, Update, Delete</a></p> </li> </ul> </li> <li><p><a href="#heading-part-5-progress-tracking-api">Part 5: Progress Tracking API</a></p> </li> </ul> </li> <li><p><a href="#heading-ui-implementation">UI Implementation</a></p> <ul> <li><p><a href="#heading-the-subjects-page">The Subjects Page</a></p> </li> <li><p><a href="#heading-the-subject-detail-page-creating-and-editing-flashcards">The Subject Detail Page (Creating and Editing Flashcards)</a></p> </li> <li><p><a href="#heading-the-study-page-flipping-cards">The Study Page – Flipping Cards</a></p> <ul> <li><p><a href="#heading-the-flip-animation">The Flip Animation</a></p> </li> <li><p><a href="#heading-recording-progress">Recording Progress</a></p> </li> </ul> </li> <li><p><a href="#heading-the-progress-page">The Progress Page</a></p> </li> </ul> </li> <li><p><a href="#heading-best-practices">Best Practices</a></p> <ul> <li><p><a href="#heading-code-organization">Code Organization</a></p> </li> <li><p><a href="#heading-error-handling">Error Handling</a></p> </li> <li><p><a href="#performance-tips">Performance Tips</a></p> </li> </ul> </li> <li><p><a href="#heading-conclusion">Conclusion</a></p> </li> </ul> <h2 id="heading-what-youll-learn">What You'll Learn</h2> <ul> <li><p>How to set up a Next.js project with TypeScript</p> </li> <li><p>How to connect to MongoDB and store data</p> </li> <li><p>How to build API routes for creating, reading, updating, and deleting data</p> </li> <li><p>How to build a React UI with forms, lists, and interactive flashcards</p> </li> <li><p>How to add a flip animation and progress tracking</p> </li> </ul> <h2 id="heading-tech-stack-overview">Tech Stack Overview</h2> <p>Before we start coding, here's what we're using and why.</p> <h3 id="heading-nextjs">Next.js</h3> <p>Next.js is a React framework for building web applications. It handles routing, server-side rendering, and API routes out of the box.</p> <p>Instead of building a separate frontend and backend, Next.js lets us put both in one project. We can create API routes (like <code>/api/flashcards</code>) that talk to the database, and pages that display the UI, all in the same codebase.</p> <h3 id="heading-mongodb">MongoDB</h3> <p>MongoDB is a NoSQL database that stores data as JSON-like documents. Unlike traditional tables with rows and columns, you store flexible "documents" in "collections."</p> <p>MongoDB is beginner-friendly, works well with JavaScript/TypeScript, and has a generous free tier (MongoDB Atlas) or can run locally with Docker.</p> <h3 id="heading-mongoose">Mongoose</h3> <p>Mongoose is a library that lets you define schemas and models for MongoDB. It adds structure and validation so you don't accidentally save invalid data.</p> <p>Without Mongoose, you'd write raw MongoDB queries. With Mongoose, you define a "Flashcard" model once and use simple methods like <code>Flashcard.create()</code> or <code>Flashcard.find()</code>.</p> <h3 id="heading-tailwind-css">Tailwind CSS</h3> <p>Tailwind is a utility-first CSS framework. Instead of writing custom CSS, you add classes like <code>rounded-xl</code> or <code>bg-blue-500</code> directly in your HTML.</p> <p>Tailwind speeds up styling and keeps the design consistent. Next.js supports it out of the box.</p> <h2 id="heading-project-setup">Project Setup</h2> <h3 id="heading-step-1-create-the-nextjs-project">Step 1: Create the Next.js Project</h3> <p>Open your terminal and run:</p> <pre><code class="language-bash">npx create-next-app@latest flash-cards --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm </code></pre> <p>When prompted, choose:</p> <ul> <li><p>TypeScript: <strong>Yes</strong></p> </li> <li><p>ESLint: <strong>Yes</strong></p> </li> <li><p>Tailwind CSS: <strong>Yes</strong></p> </li> <li><p><code>src/</code> directory: <strong>Yes</strong></p> </li> <li><p>App Router: <strong>Yes</strong></p> </li> <li><p>Import alias: <strong>@/</strong>*</p> </li> </ul> <p>This creates a new folder called <code>flash-cards</code> with a basic Next.js app.</p> <h3 id="heading-step-2-install-mongoose">Step 2: Install Mongoose</h3> <p>Mongoose is not included by default. Run the commands below to Install it.</p> <pre><code class="language-bash">cd flash-cards npm install mongoose </code></pre> <h3 id="heading-step-3-set-up-mongodb">Step 3: Set Up MongoDB</h3> <p>You have two options:</p> <h4 id="heading-option-a-docker-recommended-for-local-development">Option A: Docker (recommended for local development)</h4> <p>Create a file called <code>docker-compose.yml</code> in your project root:</p> <pre><code class="language-yaml">services: mongodb: image: mongo:7 container_name: flashstudy-mongodb ports: - "27017:27017" volumes: - mongodb_data:/data/db volumes: mongodb_data: </code></pre> <p>Then run:</p> <pre><code class="language-bash">docker compose up -d </code></pre> <p>This starts MongoDB in the background. Your data is stored in a Docker volume, so it persists even if you stop the container.</p> <h4 id="heading-option-b-mongodb-atlas-cloud">Option B: MongoDB Atlas (cloud)</h4> <ol> <li><p>Go to <a href="https://www.mongodb.com/cloud/atlas">mongodb.com/cloud/atlas</a></p> </li> <li><p>Create a free account and cluster</p> </li> <li><p>Create a database user and get your connection string</p> </li> <li><p>Add your IP to the network access list</p> </li> </ol> <h3 id="heading-step-4-create-the-environment-file">Step 4: Create the Environment File</h3> <p>Create a file named <code>.env.local</code> in your project root (this file is ignored by Git for security):</p> <pre><code class="language-env">MONGODB_URI=mongodb://localhost:27017/flashcards </code></pre> <p>If you're using Atlas, replace this with your connection string, for example:</p> <pre><code class="language-env">MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/flashcards?retryWrites=true&amp;w=majority </code></pre> <h3 id="heading-step-5-understand-the-folder-structure">Step 5: Understand the Folder Structure</h3> <p>After setup, your project looks like this:</p> <pre><code class="language-plaintext">flash-cards/ ├── src/ │ ├── app/ # Pages and API routes │ │ ├── api/ # Backend API endpoints │ │ ├── subjects/ # Subject list and detail pages │ │ ├── study/ # Study mode page │ │ └── progress/ # Progress tracking page │ ├── components/ # Reusable UI components │ └── lib/ # Utilities and database code │ ├── db.ts # MongoDB connection │ └── models/ # Mongoose schemas ├── .env.local # Environment variables (you create this) ├── docker-compose.yml # Docker config for MongoDB └── package.json </code></pre> <p>The <code>app</code> folder uses Next.js App Router: each folder can have a <code>page.tsx</code> (the UI) and <code>route.ts</code> (API endpoints). We'll build these step by step.</p> <h2 id="heading-building-the-features">Building the Features</h2> <h3 id="heading-part-1-connecting-to-mongodb">Part 1: Connecting to MongoDB</h3> <p>Before we can store or retrieve flashcards, we need to connect our application to MongoDB.</p> <p>We'll create a small database utility that handles this connection for us. Because Next.js can handle multiple requests and reload modules during development, we don't want to create a new MongoDB connection every time an API route runs. Instead, we'll cache the connection and reuse it whenever possible.</p> <p>Let's start by creating a <code>db.ts</code> file inside the <code>src/lib</code> directory.</p> <pre><code class="language-typescript">import mongoose from "mongoose"; const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/flashcards"; interface MongooseCache { conn: typeof mongoose | null; promise: Promise&lt;typeof mongoose&gt; | null; } declare global { var mongoose: MongooseCache | undefined; } let cached: MongooseCache = global.mongoose || { conn: null, promise: null }; if (!global.mongoose) { global.mongoose = cached; } async function dbConnect(): Promise&lt;typeof mongoose&gt; { if (cached.conn) return cached.conn; if (!cached.promise) { cached.promise = mongoose.connect(MONGODB_URI, { bufferCommands: false, }); } cached.conn = await cached.promise; return cached.conn; } export default dbConnect; </code></pre> <p>Here's a line-by-line explanation of this code:</p> <ul> <li><p><code>MONGODB_URI</code>: Reads the connection string from <code>.env.local</code>. Falls back to local MongoDB if not set.</p> </li> <li><p><code>MongooseCache</code>: A TypeScript interface describing our cache: we store either a connection (<code>conn</code>) or a promise that will eventually give us one.</p> </li> <li><p><code>global.mongoose</code>: In development, Next.js may reload modules. Using <code>global</code> keeps our cache across reloads so we don't create duplicate connections.</p> </li> <li><p><code>dbConnect()</code>: If we already have a connection, return it. Otherwise, create one, cache it, and return it. Every API route will call <code>await dbConnect()</code> before touching the database.</p> </li> </ul> <h3 id="heading-part-2-defining-the-data-models">Part 2: Defining the Data Models</h3> <p>Now that our application can connect to MongoDB, let's define the data we'll store in the database.</p> <p>Our flashcard app needs three types of data:</p> <ul> <li><p><strong>Subjects</strong>: Categories such as Biology 101 or Calculus.</p> </li> <li><p><strong>Flashcards</strong>: Questions and answers that belong to a subject.</p> </li> <li><p><strong>Progress</strong>: Records of how well the user performs when studying.</p> </li> </ul> <p>We'll use Mongoose schemas to define the structure of each type of data. A schema describes the fields a document can have and the type of data each field should contain.</p> <p>Let's start with the <code>Subject</code> model.</p> <h4 id="heading-subject-model">Subject Model</h4> <p>A subject represents a category of flashcards. For example, a student might create a subject called <strong>Biology 101</strong> and use it to organize their biology flashcards.</p> <p>Each subject will have a name, an optional description, and a color that we'll use when displaying the subject in the UI.</p> <p>First, create a <code>models</code> directory inside <code>src/lib</code> if you haven't already. Then create a file named <code>Subject.ts</code> inside it.</p> <pre><code class="language-typescript">import mongoose, { Schema, model, models } from "mongoose"; export interface ISubject { _id: string; name: string; description?: string; color: string; createdAt: Date; updatedAt: Date; } const SubjectSchema = new Schema( { name: { type: String, required: true }, description: { type: String }, color: { type: String, default: "#6366f1" }, }, { timestamps: true } ); export default models.Subject || model&lt;ISubject&gt;("Subject", SubjectSchema); </code></pre> <p>In this code:</p> <ul> <li><p><code>ISubject</code>: TypeScript interface. Describes what a subject object looks like in our app.</p> </li> <li><p><code>SubjectSchema</code> – Mongoose schema. <code>name</code> is required, while <code>description</code> and <code>color</code> are optional. <code>color</code> defaults to a purple hex.</p> </li> <li><p><code>timestamps: true</code>: Mongoose automatically adds <code>createdAt</code> and <code>updatedAt</code> to every document.</p> </li> <li><p><code>models.Subject || model(...)</code>: In development, modules can reload. This prevents "model already defined" errors by reusing the existing model if it exists.</p> </li> </ul> <h4 id="heading-flashcard-model">Flashcard Model</h4> <p>A flashcard belongs to a subject and contains a question on the front and an answer on the back.</p> <p>Next, let's create the <code>Flashcard</code> model. Inside <code>src/lib/models</code>, create a file named <code>Flashcard.ts</code> and add the code below:</p> <pre><code class="language-typescript">import mongoose, { Schema, model, models } from "mongoose"; export interface IFlashcard { _id: string; subjectId: string; front: string; back: string; createdAt: Date; updatedAt: Date; } const FlashcardSchema = new Schema( { subjectId: { type: Schema.Types.ObjectId, ref: "Subject", required: true }, front: { type: String, required: true }, back: { type: String, required: true }, }, { timestamps: true } ); export default models.Flashcard || model&lt;IFlashcard&gt;("Flashcard", FlashcardSchema); </code></pre> <p>In this code:</p> <ul> <li><p><code>subjectId</code>: References a Subject by its <code>_id</code>. <code>ref: "Subject"</code> lets Mongoose populate this field (replace the ID with the full subject object when we fetch).</p> </li> <li><p><code>front</code> and <code>back</code>: The question and answer text.</p> </li> </ul> <h4 id="heading-progress-model">Progress Model</h4> <p>When a user studies, we need to record whether they answered each flashcard correctly or incorrectly. We'll use this information to display their progress on the dashboard.</p> <p>Next, let's create the <code>Progress</code> model. Inside <code>src/lib/models</code>, create a file named <code>Progress.ts</code> and add the code below:</p> <pre><code class="language-typescript">import mongoose, { Schema, model, models } from "mongoose"; export interface IProgress { _id: string; flashcardId: string; subjectId: string; correct: boolean; reviewedAt: Date; } const ProgressSchema = new Schema( { flashcardId: { type: Schema.Types.ObjectId, ref: "Flashcard", required: true }, subjectId: { type: Schema.Types.ObjectId, ref: "Subject", required: true }, correct: { type: Boolean, required: true }, reviewedAt: { type: Date, default: Date.now }, }, { timestamps: true } ); export default models.Progress || model&lt;IProgress&gt;("Progress", ProgressSchema); </code></pre> <p>In this code:</p> <ul> <li><p><code>correct</code>: <code>true</code> if the user knew the answer, <code>false</code> if not.</p> </li> <li><p><code>reviewedAt</code>: When the review happened. We use this for sorting and future features like spaced repetition.</p> </li> </ul> <h3 id="heading-subjects-api-list-and-create">Subjects API – List and Create</h3> <p>Now that we've defined our data models, let's create the API routes that will allow the application to work with that data.</p> <p>API routes handle requests from the frontend and communicate with MongoDB. In this section, we'll create routes for creating and retrieving subjects and flashcards.</p> <p>We'll start with the subjects API. This route will support two operations:</p> <ul> <li><p><strong>GET</strong>: Retrieve all subjects.</p> </li> <li><p><strong>POST</strong>: Create a new subject.</p> </li> </ul> <p>Inside <code>src/app/api/subjects</code>, create a file named <code>route.ts</code> and add the code below:</p> <pre><code class="language-typescript">import { NextResponse } from "next/server"; import dbConnect from "@/lib/db"; import Subject from "@/lib/models/Subject"; export async function GET() { try { await dbConnect(); const subjects = await Subject.find({}).sort({ createdAt: -1 }); return NextResponse.json(subjects); } catch (error) { console.error("Error fetching subjects:", error); return NextResponse.json( { error: "Failed to fetch subjects" }, { status: 500 } ); } } export async function POST(request: Request) { try { await dbConnect(); const body = await request.json(); const { name, description, color } = body; if (!name) { return NextResponse.json( { error: "Subject name is required" }, { status: 400 } ); } const subject = await Subject.create({ name, description: description || "", color: color || "#6366f1", }); return NextResponse.json(subject); } catch (error) { console.error("Error creating subject:", error); return NextResponse.json( { error: "Failed to create subject" }, { status: 500 } ); } } </code></pre> <p>In this code:</p> <ul> <li><p><code>GET</code>: Fetches all subjects, sorted by newest first. <code>find({})</code> means "find all." Returns them as JSON.</p> </li> <li><p><code>POST</code>: Creates a new subject. Reads <code>name</code>, <code>description</code>, and <code>color</code> from the request body. Validates that <code>name</code> exists. Uses <code>Subject.create()</code> to save to MongoDB. Returns the created subject.</p> </li> <li><p><code>status: 400</code>: Bad request (missing data). <code>status: 500</code>: Server error (for example, database failure).</p> </li> </ul> <h4 id="heading-flashcards-api-list-and-create">Flashcards API – List and Create</h4> <p>Next, let's create the API route for working with flashcards. This route will let us retrieve existing flashcards and create new ones.</p> <p>Inside <code>src/app/api/flashcards</code>, create a file named <code>route.ts</code> and add the code block:</p> <pre><code class="language-typescript">import { NextResponse } from "next/server"; import dbConnect from "@/lib/db"; import Flashcard from "@/lib/models/Flashcard"; export async function GET(request: Request) { try { await dbConnect(); const { searchParams } = new URL(request.url); const subjectId = searchParams.get("subjectId"); const query = subjectId ? { subjectId } : {}; const flashcards = await Flashcard.find(query) .populate("subjectId", "name color") .sort({ createdAt: -1 }); return NextResponse.json(flashcards); } catch (error) { console.error("Error fetching flashcards:", error); return NextResponse.json( { error: "Failed to fetch flashcards" }, { status: 500 } ); } } export async function POST(request: Request) { try { await dbConnect(); const body = await request.json(); const { subjectId, front, back } = body; if (!subjectId || !front || !back) { return NextResponse.json( { error: "Subject, front, and back are required" }, { status: 400 } ); } const flashcard = await Flashcard.create({ subjectId, front, back, }); const populated = await Flashcard.findById(flashcard._id).populate( "subjectId", "name color" ); return NextResponse.json(populated); } catch (error) { console.error("Error creating flashcard:", error); return NextResponse.json( { error: "Failed to create flashcard" }, { status: 500 } ); } } </code></pre> <p>In this code:</p> <ul> <li><p><code>searchParams.get("subjectId")</code>: For <code>GET /api/flashcards?subjectId=abc123</code>, we filter by that subject. If no <code>subjectId</code>, we return all flashcards.</p> </li> <li><p><code>.populate("subjectId", "name color")</code>: Replaces the raw ID with the subject object, but only includes <code>name</code> and <code>color</code>. Makes it easy to display the subject name in the UI.</p> </li> <li><p><code>POST</code>: Requires <code>subjectId</code>, <code>front</code>, and <code>back</code>. After creating, we fetch the flashcard again with <code>populate</code> so the response includes the subject details.</p> </li> </ul> <img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/f15cd9c8-87d1-461b-92ac-4ec091481338.jpg" alt="Flashcard-create-study-form" style="display:block;margin:0 auto" width="600" height="400" loading="lazy"> <h3 id="heading-part-4-editing-and-deleting-dynamic-api-routes">Part 4: Editing and Deleting (Dynamic API Routes)</h3> <p>For individual subjects and flashcards, we'll use <strong>dynamic routes</strong>. In Next.js, placing <code>[id]</code> in a folder name creates a route that can handle different IDs. For example, <code>/api/subjects/123</code> and <code>/api/subjects/456</code> can use the same route.</p> <h4 id="heading-subject-api-route">Subject API Route</h4> <p>Let's start by creating the dynamic route for individual subjects. This route will let us retrieve, update, or delete a subject.</p> <p>Inside <code>src/app/api/subjects/[id]</code>, create a file named <code>route.ts</code> and add the code below:</p> <pre><code class="language-typescript">import { NextResponse } from "next/server"; import dbConnect from "@/lib/db"; import Subject from "@/lib/models/Subject"; import Flashcard from "@/lib/models/Flashcard"; import Progress from "@/lib/models/Progress"; export async function GET( _request: Request, { params }: { params: Promise&lt;{ id: string }&gt; } ) { try { await dbConnect(); const { id } = await params; const subject = await Subject.findById(id); if (!subject) { return NextResponse.json({ error: "Subject not found" }, { status: 404 }); } return NextResponse.json(subject); } catch (error) { console.error("Error fetching subject:", error); return NextResponse.json( { error: "Failed to fetch subject" }, { status: 500 } ); } } export async function PUT( request: Request, { params }: { params: Promise&lt;{ id: string }&gt; } ) { try { await dbConnect(); const { id } = await params; const body = await request.json(); const { name, description, color } = body; const subject = await Subject.findByIdAndUpdate( id, { name, description, color }, { new: true } ); if (!subject) { return NextResponse.json({ error: "Subject not found" }, { status: 404 }); } return NextResponse.json(subject); } catch (error) { console.error("Error updating subject:", error); return NextResponse.json( { error: "Failed to update subject" }, { status: 500 } ); } } export async function DELETE( _request: Request, { params }: { params: Promise&lt;{ id: string }&gt; } ) { try { await dbConnect(); const { id } = await params; await Flashcard.deleteMany({ subjectId: id }); await Progress.deleteMany({ subjectId: id }); const subject = await Subject.findByIdAndDelete(id); if (!subject) { return NextResponse.json({ error: "Subject not found" }, { status: 404 }); } return NextResponse.json({ message: "Subject deleted" }); } catch (error) { console.error("Error deleting subject:", error); return NextResponse.json( { error: "Failed to delete subject" }, { status: 500 } ); } } </code></pre> <p>In this code:</p> <ul> <li><p><code>params</code>: In Next.js 15+, <code>params</code> is a Promise. We <code>await</code> it to get <code>{ id: "abc123" }</code>.</p> </li> <li><p><code>findByIdAndUpdate(id, updates, { new: true })</code>: Updates the document and returns the updated version. <code>{ new: true }</code> means "return the new document, not the old one."</p> </li> <li><p><code>DELETE</code>: When we delete a subject, we also delete its flashcards and progress records. Otherwise we'd have orphaned data.</p> </li> </ul> <h4 id="heading-flashcard-by-id-get-update-delete">Flashcard by ID – Get, Update, Delete</h4> <p>Now, let's create the dynamic route for individual flashcards. This route will let us retrieve, update, or delete a flashcard.</p> <p>Inside <code>src/app/api/flashcards/[id]</code>, create a file named <code>route.ts</code> and add the code below:</p> <pre><code class="language-typescript">import { NextResponse } from "next/server"; import dbConnect from "@/lib/db"; import Flashcard from "@/lib/models/Flashcard"; export async function GET( _request: Request, { params }: { params: Promise&lt;{ id: string }&gt; } ) { try { await dbConnect(); const { id } = await params; const flashcard = await Flashcard.findById(id).populate( "subjectId", "name color" ); if (!flashcard) { return NextResponse.json( { error: "Flashcard not found" }, { status: 404 } ); } return NextResponse.json(flashcard); } catch (error) { console.error("Error fetching flashcard:", error); return NextResponse.json( { error: "Failed to fetch flashcard" }, { status: 500 } ); } } export async function PUT( request: Request, { params }: { params: Promise&lt;{ id: string }&gt; } ) { try { await dbConnect(); const { id } = await params; const body = await request.json(); const { front, back } = body; const flashcard = await Flashcard.findByIdAndUpdate( id, { front, back }, { new: true } ).populate("subjectId", "name color"); if (!flashcard) { return NextResponse.json( { error: "Flashcard not found" }, { status: 404 } ); } return NextResponse.json(flashcard); } catch (error) { console.error("Error updating flashcard:", error); return NextResponse.json( { error: "Failed to update flashcard" }, { status: 500 } ); } } export async function DELETE( _request: Request, { params }: { params: Promise&lt;{ id: string }&gt; } ) { try { await dbConnect(); const { id } = await params; const flashcard = await Flashcard.findByIdAndDelete(id); if (!flashcard) { return NextResponse.json( { error: "Flashcard not found" }, { status: 404 } ); } return NextResponse.json({ message: "Flashcard deleted" }); } catch (error) { console.error("Error deleting flashcard:", error); return NextResponse.json( { error: "Failed to delete flashcard" }, { status: 500 } ); } } </code></pre> <h3 id="heading-part-5-progress-tracking-api">Part 5: Progress Tracking API</h3> <p>When a user marks a flashcard as correct or incorrect during a study session, we need to save that result. We'll also use this data to calculate progress statistics, such as the percentage of correct answers for each subject.</p> <p>Next, let's create the API route for tracking progress. Inside <code>src/app/api/progress</code>, create a file named <code>route.ts</code> and add the code below:</p> <pre><code class="language-typescript">import { NextResponse } from "next/server"; import dbConnect from "@/lib/db"; import Progress from "@/lib/models/Progress"; export async function GET(request: Request) { try { await dbConnect(); const { searchParams } = new URL(request.url); const subjectId = searchParams.get("subjectId"); const query = subjectId ? { subjectId } : {}; const progress = await Progress.find(query).sort({ reviewedAt: -1 }); const stats = await Progress.aggregate([ { $match: query }, { $group: { _id: "$subjectId", total: { $sum: 1 }, correct: { $sum: { $cond: ["$correct", 1, 0] } }, }, }, ]); return NextResponse.json({ progress, stats }); } catch (error) { console.error("Error fetching progress:", error); return NextResponse.json( { error: "Failed to fetch progress" }, { status: 500 } ); } } export async function POST(request: Request) { try { await dbConnect(); const body = await request.json(); const { flashcardId, subjectId, correct } = body; if (!flashcardId || !subjectId || typeof correct !== "boolean") { return NextResponse.json( { error: "flashcardId, subjectId, and correct are required" }, { status: 400 } ); } const progress = await Progress.create({ flashcardId, subjectId, correct, }); return NextResponse.json(progress); } catch (error) { console.error("Error recording progress:", error); return NextResponse.json( { error: "Failed to record progress" }, { status: 500 } ); } } </code></pre> <p>In this code:</p> <ul> <li><p><code>aggregate</code>: MongoDB's aggregation pipeline. We group by <code>subjectId</code> and count total reviews and correct answers. <code>$cond: ["$correct", 1, 0]</code> means "if correct is true, add 1, else add 0."</p> </li> <li><p><code>stats</code>: Returns something like <code>[{ _id: "subjectId123", total: 20, correct: 16 }]</code>. The frontend uses this to show "80% accuracy" per subject.</p> </li> </ul> <h2 id="heading-ui-implementation">UI Implementation</h2> <p>Now we'll build the pages users see. We'll use React hooks (<code>useState</code>, <code>useEffect</code>) to manage data and <code>fetch</code> to call our API.</p> <h3 id="heading-the-subjects-page">The Subjects Page</h3> <p>On load, we fetch subjects from the API. We show a form to create new subjects. Each subject is a card that links to its detail page.</p> <p>Key logic:</p> <ol> <li><p><code>useEffect</code> runs once on mount and calls <code>fetch("/api/subjects")</code>.</p> </li> <li><p>The form's <code>onSubmit</code> calls <code>fetch("/api/subjects", { method: "POST", ... })</code>.</p> </li> <li><p>After a successful create, we clear the form and call <code>fetchSubjects()</code> again to refresh the list.</p> </li> </ol> <pre><code class="language-tsx">// Simplified structure - see full code in src/app/subjects/page.tsx const [subjects, setSubjects] = useState&lt;Subject[]&gt;([]); const [showForm, setShowForm] = useState(false); useEffect(() =&gt; { fetch("/api/subjects") .then((res) =&gt; res.json()) .then((data) =&gt; setSubjects(data)); }, []); const handleSubmit = async (e) =&gt; { e.preventDefault(); await fetch("/api/subjects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description, color }), }); fetchSubjects(); }; </code></pre> <img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/30d83725-4d91-4642-b8ee-f3a89726843f.jpg" alt="Flashcard-study-list" style="display:block;margin:0 auto" width="600" height="400" loading="lazy"> <h3 id="heading-the-subject-detail-page-creating-and-editing-flashcards">The Subject Detail Page (Creating and Editing Flashcards)</h3> <p>This page shows one subject and its flashcards. Users can add new cards or edit/delete existing ones. The URL is <code>/subjects/[id]</code>, so we use <code>useParams()</code> to get the subject ID.</p> <p>Key logic:</p> <ol> <li><p><code>useParams()</code> gives us the <code>id</code> from the URL.</p> </li> <li><p>We fetch the subject and its flashcards on mount.</p> </li> <li><p>"Add Flashcard" shows a form. On submit, we POST to <code>/api/flashcards</code> with <code>subjectId</code>, <code>front</code>, and <code>back</code>.</p> </li> <li><p>Each card has Edit and Delete buttons. Edit switches to an inline form, while Delete calls <code>DELETE /api/flashcards/[id]</code>.</p> </li> </ol> <pre><code class="language-tsx">// Creating a flashcard const handleCreate = async (e) =&gt; { e.preventDefault(); await fetch("/api/flashcards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subjectId: id, front, back }), }); fetchFlashcards(); // Refresh }; // Updating a flashcard const handleUpdate = async (e) =&gt; { e.preventDefault(); await fetch(`/api/flashcards/${editingId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ front: editFront, back: editBack }), }); setEditingId(null); fetchFlashcards(); }; </code></pre> <img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/e60ba077-b22b-4f71-895f-5fce4f9e66d3.jpg" alt="Study-details-page" style="display:block;margin:0 auto" width="600" height="400" loading="lazy"> <h3 id="heading-the-study-page-flipping-cards">The Study Page – Flipping Cards</h3> <p>The study page has three main states:</p> <ol> <li><p><strong>Subject selection</strong>: User picks which subject to study.</p> </li> <li><p><strong>Ready to start</strong>: Shows "Start Studying" with the card count.</p> </li> <li><p><strong>Studying</strong>: Shows one card at a time. User clicks to flip, then marks correct or wrong. We advance to the next card and record progress.</p> </li> </ol> <h4 id="heading-the-flip-animation">The Flip Animation</h4> <p>We use CSS 3D transforms to create the flip animation. The flashcard has two faces: a front for the question and a back for the answer. When <code>flipped</code> is <code>true</code>, we rotate the card container 180 degrees. We also use <code>backface-visibility: hidden</code> so that only the appropriate face is visible during the rotation.</p> <p>To add the styles for the flip animation, open <code>src/app/globals.css</code> and add the following code:</p> <pre><code class="language-css">/* Flashcard flip animation */ .perspective-1000 { perspective: 1000px; } .preserve-3d { transform-style: preserve-3d; } .backface-hidden { backface-visibility: hidden; } /* Lined paper effect for the card background */ .lined-paper { background-image: repeating-linear-gradient( transparent, transparent 27px, #e5e7eb 27px, #e5e7eb 28px ); } </code></pre> <p>The <code>lined-paper</code> class creates horizontal grey lines (like notebook paper) using a repeating gradient. This gives the flashcard a familiar, study-friendly look.</p> <p>The card structure:</p> <pre><code class="language-tsx">&lt;div className={`preserve-3d transition-transform duration-500 ${ flipped ? "[transform:rotateY(180deg)]" : "" }`} &gt; {/* Front face - Question */} &lt;div className="backface-hidden [transform:rotateY(0deg)]"&gt; {currentCard.front} &lt;/div&gt; {/* Back face - Answer */} &lt;div className="backface-hidden [transform:rotateY(180deg)]"&gt; {currentCard.back} &lt;/div&gt; &lt;/div&gt; </code></pre> <p>When the user clicks the card, we toggle <code>flipped</code>. The parent rotates, and the correct face becomes visible.</p> <img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/a92a03c7-c562-4600-ba2c-ecd6e85ab00c.jpg" alt="a92a03c7-c562-4600-ba2c-ecd6e85ab00c" style="display:block;margin:0 auto" width="600" height="400" loading="lazy"> <h4 id="heading-recording-progress">Recording Progress</h4> <p>When the user clicks "Got it!" or "Didn't know", we:</p> <ol> <li><p>POST to <code>/api/progress</code> with <code>flashcardId</code>, <code>subjectId</code>, and <code>correct</code>.</p> </li> <li><p>Update local state (<code>sessionCorrect</code> or <code>sessionWrong</code>) for the live stats.</p> </li> <li><p>Move to the next card. If we've finished all cards, we show the "Start Studying" screen again.</p> </li> </ol> <pre><code class="language-tsx">const handleKnow = () =&gt; { recordProgress(true); setFlipped(false); if (currentIndex &lt; flashcards.length - 1) { setCurrentIndex((i) =&gt; i + 1); } else { setStudyStarted(false); setCurrentIndex(0); } }; </code></pre> <h3 id="heading-the-progress-page">The Progress Page</h3> <p>Here, we fetch subjects and progress stats. For each subject, we look up its stats (total reviews, correct count) and compute the percentage. We display overall stats at the top and per-subject breakdown below.</p> <pre><code class="language-tsx">const getSubjectStats = (subjectId) =&gt; { const stat = stats.find((s) =&gt; s._id === subjectId); return stat ? { total: stat.total, correct: stat.correct, pct: Math.round((stat.correct / stat.total) * 100), } : null; }; </code></pre> <h3 id="heading-optional-lined-paper-and-paperclip-icon">Optional: Lined Paper and Paperclip Icon</h3> <p>The app includes a lined-paper effect and a paperclip icon to make the flashcard feel more tactile. The paperclip is a simple SVG component:</p> <pre><code class="language-tsx">// src/components/PaperclipIcon.tsx export default function PaperclipIcon({ className }: { className?: string }) { return ( &lt;svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"&gt; &lt;path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /&gt; &lt;/svg&gt; ); } </code></pre> <p>Place it at the top center of the flashcard. The <code>lined-paper</code> class is applied to the card content area for the notebook effect.</p> <h2 id="heading-best-practices">Best Practices</h2> <h3 id="heading-code-organization">Code Organization</h3> <ul> <li><p><strong>Models</strong> in <code>lib/models/</code>: One file per model. Keeps schemas in one place.</p> </li> <li><p><strong>API routes</strong> in <code>app/api/</code>: Group by resource (subjects, flashcards, progress). Use <code>[id]</code> for dynamic routes.</p> </li> <li><p><strong>Reusable components</strong>: The <code>PaperclipIcon</code> is in <code>components/</code>. Use this pattern for any UI you repeat.</p> </li> </ul> <h3 id="heading-error-handling">Error Handling</h3> <ul> <li><p><strong>API routes</strong>: Always wrap logic in <code>try/catch</code>. Return appropriate status codes (400 for bad input, 404 for not found, 500 for server errors).</p> </li> <li><p><strong>Frontend</strong>: Check <code>res.ok</code> before using <code>res.json()</code>. Show loading and error states to the user.</p> </li> </ul> <h3 id="heading-performance-tips">Performance Tips</h3> <ul> <li><p><strong>Database connection</strong>: Reuse the connection (our <code>dbConnect</code> does this). Don't connect on every request.</p> </li> <li><p><strong>Populate sparingly</strong>: Only <code>.populate()</code> fields you need. Specify which fields: <code>.populate("subjectId", "name color")</code>.</p> </li> <li><p><strong>Loading states</strong>: Show a spinner while fetching. Prevents layout shift and gives feedback.</p> </li> </ul> <h2 id="heading-conclusion">Conclusion</h2> <p>You've built a full-stack flashcard app with:</p> <ul> <li><p><strong>Next.js</strong> for the app and API routes</p> </li> <li><p><strong>MongoDB + Mongoose</strong> for storing subjects, flashcards, and progress</p> </li> <li><p><strong>React</strong> for the UI with forms, lists, and a flip animation</p> </li> <li><p><strong>Tailwind CSS</strong> for styling</p> </li> </ul> <h3 id="heading-possible-improvements">Possible Improvements</h3> <p>There are a few features you could build to improve this app.</p> <p>First, you could add authentication. Add login so each user has their own subjects and cards. Consider NextAuth.js or Clerk.</p> <p>Second, you could add a spaced repetition feature. Use the progress data to show cards at optimal intervals (for example, cards you got wrong more often).</p> <p>Next, you could add some animations, like transitions between cards or a confetti effect when a session is complete.</p> <p>You could also build in mobile responsiveness. The current layout works on desktop, but you could optimize the study view for phones.</p> <p>And finally, an export/import feature could be useful: let users export their flashcards as JSON or CSV for backup.</p> <h3 id="heading-next-steps">Next Steps</h3> <p>To take this further, run <code>npm run dev</code> and explore the app. You can add a few subjects and flashcards, then try the study mode.</p> <p>After that, open MongoDB Compass or Atlas to inspect your data. Experiment with the code: change colors, add fields, or tweak the flip animation.</p> <p>Happy studying!</p>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on David Aniebo’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Build a Flashcard Study App with Next.js and MongoDB?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on David Aniebo’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Build a Flashcard Study App with Next.js and MongoDB free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.