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&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<typeof mongoose> | 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<typeof mongoose> {
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<ISubject>("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<IFlashcard>("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<IProgress>("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<{ id: string }> }
) {
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<{ id: string }> }
) {
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<{ id: string }> }
) {
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<{ id: string }> }
) {
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<{ id: string }> }
) {
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<{ id: string }> }
) {
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<Subject[]>([]);
const [showForm, setShowForm] = useState(false);
useEffect(() => {
fetch("/api/subjects")
.then((res) => res.json())
.then((data) => setSubjects(data));
}, []);
const handleSubmit = async (e) => {
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) => {
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) => {
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"><div
className={`preserve-3d transition-transform duration-500 ${
flipped ? "[transform:rotateY(180deg)]" : ""
}`}
>
{/* Front face - Question */}
<div className="backface-hidden [transform:rotateY(0deg)]">
{currentCard.front}
</div>
{/* Back face - Answer */}
<div className="backface-hidden [transform:rotateY(180deg)]">
{currentCard.back}
</div>
</div>
</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 = () => {
recordProgress(true);
setFlipped(false);
if (currentIndex < flashcards.length - 1) {
setCurrentIndex((i) => 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) => {
const stat = stats.find((s) => 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 (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<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" />
</svg>
);
}
</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>