Build a Full-Stack AI Study Assistant
Build a full-stack app that turns uploaded notes into AI quizzes and flashcards.
Introduction
30 Second Summary
Studying takes time. Creating flashcards, writing practice quizzes, and summarizing notes takes even more time. What if you could upload your notes and have AI do the heavy lifting for you?
In this project, you will build a full-stack AI Study Assistant web application. Users can upload PDFs, receive AI-generated summaries, quizzes, and flashcards, and track their study progress through an analytics dashboard.
What You'll Build
A production-ready web application with a React frontend, Node.js/Express backend, MongoDB Atlas database, and OpenAI-powered AI features deployed live on Vercel and Render.
By the end of this project, you'll have:
- A complete full-stack application deployed live with user authentication, PDF uploads, and AI-powered study tools.
- A documented system architecture, database schema, and API specification ready for your portfolio.
- A working analytics dashboard that tracks learning progress across quizzes, flashcards, and summaries.
- Secret Mission: A spaced repetition algorithm that optimizes flashcard review intervals based on user performance.
Are there any prerequisites?
This project assumes intermediate JavaScript and React knowledge. You should be comfortable writing functions, managing state, and making HTTP requests.
A basic understanding of REST APIs and databases is helpful but not required. The project walks you through every step.
Before We Start
You're about to build a full-stack AI Study Assistant from scratch. It will let users upload notes, generate AI-powered summaries and quizzes, and track their learning progress over time.
Before writing any code, you need the right tools installed and accounts created. This step gets your development environment, database, AI service, and version control all ready to go.
In this step, get ready to:
- Install VS Code, Node.js, and Git on your machine.
- Create a MongoDB Atlas account and set up a free database cluster.
- Create an OpenAI account, generate an API key, and set up a GitHub repository.
Install your development tools
You need three core tools on your machine: a code editor, a JavaScript runtime, and version control software.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
✔️ VS Code opens
You're all set. VS Code is your code editor for this project.
ⓧ I don't have VS Code installed
- Download VS Code from https://code.visualstudio.com.
- Run the installer and follow the prompts.
- Once installed, open VS Code using the search bar instructions above.
Next, check that Node.js is installed. This project uses Vite to scaffold the React frontend, which requires Node.js version 20.19 or higher.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Check your Node.js version by running:
node --version
✔️ I see v20.19 or higher
Your Node.js version is compatible. Move on to checking Git below.
ⓧ I see an older version
You have Node.js installed, but it's an older version that won't work with Vite. You need version 20.19 or higher.
- Go to https://nodejs.org and download the latest LTS version.
- Run the installer and follow the prompts to upgrade your existing installation.
- Close and reopen your terminal, then run node --version again to confirm you see v20.19 or higher.
macOS users with an old system Node
If your system still shows an old version after installing, use the full path to the new binary or install a version manager like nvm. Run nvm install 20 followed by nvm use 20 to switch.
ⓧ Command not found
Node.js is not installed on your machine.
- Go to https://nodejs.org and download the latest LTS version (20 or higher).
- Run the installer and accept the default settings.
- Close and reopen your terminal, then run node --version to confirm it installed correctly.
Now check that Git is installed for version control.
- Check your Git version by running:
git --version
✔️ I see a version number
Git is ready. You'll use it to push code to GitHub throughout this project.
ⓧ Command not found
- Download Git from https://git-scm.com/downloads.
- Run the installer and accept the default settings.
- Close and reopen your terminal, then run git --version to confirm it installed correctly.
Set up your MongoDB Atlas database
Your app needs a database to store user accounts, notes, quizzes, and flashcards. MongoDB Atlas is a cloud-hosted database service with a free tier that gives you 512 MB of storage.
- Open https://www.mongodb.com/cloud/atlas/register in your browser.
- Create an account using your email or sign in with Google.
- When prompted to deploy your first cluster, select the M0 Free tier.
- Choose a cloud provider (AWS is the default) and a region close to you.
- Click Create Deployment.
What is an M0 cluster?
M0 is MongoDB Atlas's free-forever tier. It gives you 512 MB of storage, up to 100 operations per second, and auto-pauses after 30 days of zero connections. It's perfect for learning and development projects.
After the cluster is created, you need to set up access so your app can connect to it.
- Atlas will prompt you to create a database user. Enter a username and password you'll remember.
- Click Create Database User.
- For the IP access list, select Allow Access from Anywhere (you can restrict this later for production).
- Click Choose a connection method, then select Drivers.
- Copy the connection string that starts with mongodb+srv:// and save it somewhere safe. You'll use it later to connect your Express server.
Keep your connection string handy
The connection string contains a placeholder <password> that you'll need to replace with the database user password you just created. Save both somewhere you can find them easily.
Create your OpenAI and GitHub accounts
The AI features in your app are powered by OpenAI's GPT-4o mini model. You also need a GitHub repository to store your code and deploy later.
- Open https://platform.openai.com/signup in your browser and create an account (or sign in if you already have one).
- Once signed in, navigate to API keys in the left sidebar.
- Click Create new secret key.
- Give it a name like ai-study-assistant and click Create secret key.
- Copy the key immediately and store it in a secure location (password manager, secure notes). You will not be able to see it again after closing the dialog.
OpenAI API costs
GPT-4o mini costs $0.15 per 1 million input tokens and $0.60 per 1 million output tokens. For a development project, this means roughly $1-3 per month of light usage.
You'll need to add a payment method and purchase at least $5 in credits to access the API. The free tier does not support GPT-4o mini.
Can't find the API keys page?
After signing in to platform.openai.com, look for the API keys link in the left sidebar under your organization settings. If you're on the ChatGPT interface instead, make sure you're at platform.openai.com, not chat.openai.com.
help me find the OpenAI API keys page
Now create a GitHub repository for your project code.
- Open https://github.com/new in your browser (create a GitHub account first if you don't have one).
- Set the repository name to ai-study-assistant.
- Leave it as Public.
- Do not initialize with a README, .gitignore, or license (you'll add these from your local project later).
- Click Create repository.
Why an empty repository?
You'll initialize Git locally in a later step and push your code up. Starting with an empty remote repository avoids merge conflicts when you make your first push.
Your development environment is fully set up. Next up, you'll create the project folder structure and scaffold both the React frontend and Express backend.
Set Up the Project Structure
With all your accounts and tools ready, it is time to build the foundation of your AI Study Assistant. A full-stack application needs a clear separation between its frontend and backend code.
In this step, you will create a monorepo structure with a React frontend (powered by Vite) and a Node.js/Express backend, install all dependencies, and wire up environment variables so both servers can run locally.
In this step, get ready to:
- Scaffold the React frontend with Vite and install client dependencies.
- Initialize the Express backend and install server dependencies.
- Configure environment variables and verify both servers start locally.
Scaffold the React frontend with Vite
The frontend is what your users interact with. You will use Vite to scaffold a React project because it provides fast builds and hot module reloading during development.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project root folder and move into it by running these commands:
mkdir ai-study-assistant
cd ai-study-assistant
- Scaffold the React frontend inside a client folder by running:
npm create vite@latest client -- --template react
What does this command do?
The npm create vite@latest command runs the create-vite scaffolding tool.
The client argument names the folder it creates. The -- --template react flag tells Vite to use the React JavaScript template.
- Move into the client folder and install the base dependencies by running:
cd client
npm install
- Install the additional frontend packages your project needs by running:
npm install react-router-dom axios recharts
What are these packages?
- react-router-dom handles client-side page navigation without full page reloads.
- axios makes HTTP requests from the browser to your backend API.
- recharts renders charts and graphs for the analytics dashboard.
Now you need to configure Vite to proxy API requests to the backend during development.
- Open vite.config.js in the client folder and replace its contents with:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Vite configuration for the AI Study Assistant frontend
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Forward API requests to the Express backend during development
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
});
Why set up a proxy?
During development, your React app runs on one port (5173) and your Express server runs on another (5000). The proxy tells Vite to forward any request starting with /api to the backend automatically, avoiding cross-origin issues.
- Open src/App.jsx and replace its contents with a basic router setup:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Main App component with client-side routing
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<h1>AI Study Assistant</h1>} />
</Routes>
</BrowserRouter>
);
}
export default App;
- Open src/main.jsx and make sure it looks like this:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// Mount the App component into the DOM
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
- Save all three files.
- Verify the frontend starts by running:
npm run dev
You should see output showing the Vite dev server running on http://localhost:5173.
- Open http://localhost:5173 in your browser. You should see "AI Study Assistant" displayed on the page.
- Stop the dev server by pressing Ctrl+C in your terminal.
Not seeing the page?
- Make sure you saved vite.config.js, App.jsx, and main.jsx before running the dev server.
- Check that you ran npm install inside the client folder. Missing dependencies will cause errors.
- If port 5173 is in use, Vite will pick another port. Check the terminal output for the correct URL.
Help me debug why my Vite React app isn't starting.
Initialize the Express backend
The backend handles your API logic, database connections, and AI integrations. You will create a separate server folder alongside client so both projects stay cleanly separated.
- Navigate back to the project root by running:
cd ~/Desktop/ai-study-assistant
- Create the server directory and initialize a new Node.js project by running:
mkdir server
cd server
npm init -y
- Install all the backend dependencies by running:
npm install express mongoose bcryptjs jsonwebtoken multer pdf-parse openai cors dotenv express-validator express-rate-limit helmet
What are these packages?
- express is the web framework for handling HTTP requests and routing.
- mongoose connects to MongoDB and provides schema-based data modeling.
- bcryptjs hashes passwords securely before storing them.
- jsonwebtoken generates and verifies JWT tokens for authentication.
- multer handles file uploads (PDF files in this project).
- pdf-parse extracts text content from uploaded PDF files.
- openai is the official OpenAI SDK for calling GPT models.
- cors, helmet, and express-rate-limit add security protections (cross-origin, headers, rate limiting).
- Install nodemon as a development dependency so the server restarts automatically when you change code:
npm install --save-dev nodemon
Now create the server entry point.
- Create a file called server.js in the server folder and add the following code:
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const connectDB = require('./config/db');
const app = express();
// Connect to MongoDB
connectDB();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173'
}));
app.use(express.json());
// Rate limiting - 100 requests per 15 minutes per IP
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100
});
app.use(limiter);
// Placeholder route mounts
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- helmet() adds secure HTTP headers to every response.
- cors() allows requests from your React frontend domain only.
- rateLimit() blocks clients that send more than 100 requests in 15 minutes.
- The /api/health endpoint lets you quickly verify the server is alive.
- Create a config folder inside server and add a db.js file for the database connection:
mkdir config
- Create config/db.js with the following code:
const mongoose = require('mongoose');
// Connect to MongoDB Atlas using the connection string from .env
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
What does this code do?
The connectDB function uses Mongoose to open a connection to your MongoDB Atlas cluster. If the connection fails, process.exit(1) stops the server immediately so you notice the problem right away.
- Save both server.js and config/db.js.
Seeing a module not found error?
- Make sure you ran the npm install command from inside the server folder, not from the project root.
- Check that package.json exists in your server folder and lists express, mongoose, and the other packages under dependencies.
Help me fix a module not found error in my Express server setup.
Configure environment variables and verify the setup
Your server needs credentials to connect to MongoDB and call the OpenAI API. Environment variables keep these secrets out of your code and out of version control.
- Create a file called .env inside the server folder with the following content:
MONGODB_URI=your-mongodb-connection-string
JWT_SECRET=your-random-secret-string-here
OPENAI_API_KEY=your-openai-api-key-here
PORT=5000
CLIENT_URL=http://localhost:5173
How do I fill in these values?
- MONGODB_URI: paste the connection string you copied from MongoDB Atlas earlier. Replace <password> with your database user's password.
- JWT_SECRET: type any random string of 32 or more characters. This signs your authentication tokens.
- OPENAI_API_KEY: paste the API key you generated from your OpenAI account.
- Create an .env.example file in the same server folder. This is a safe template others can reference without seeing your secrets:
MONGODB_URI=your_mongodb_connection_string
JWT_SECRET=your_jwt_secret_key
OPENAI_API_KEY=your_openai_api_key
PORT=5000
CLIENT_URL=http://localhost:5173
- Navigate back to the project root and create the docs directory by running:
cd ~/Desktop/ai-study-assistant
mkdir docs
- Create docs/architecture.md with an initial architecture overview:
# System Architecture
## Overview
AI Study Assistant uses a client-server architecture with a REST API.
## Tech Stack
- **Frontend**: React (Vite) deployed on Vercel
- **Backend**: Node.js/Express deployed on Render
- **Database**: MongoDB Atlas (Free M0 cluster)
- **AI**: OpenAI GPT-4o mini
## Data Flow
1. User uploads PDF or creates note via React frontend
2. Frontend sends request to Express API
3. API processes request (extracts text, calls OpenAI, queries MongoDB)
4. Response sent back to frontend for display
- Create a .gitignore in the project root to prevent secrets and generated files from being committed:
node_modules/
server/.env
.DS_Store
Now verify that the backend server connects to your database.
- Move into the server folder and start the server by running:
cd server
node server.js
You should see two messages in your terminal: MongoDB connected: followed by a hostname, and Server running on port 5000.
- Open a new browser tab and navigate to http://localhost:5000/api/health. You should see {"status":"ok"} in the page.
- Stop the server by pressing Ctrl+C.
MongoDB connection error?
- Double-check that your MONGODB_URI in .env uses the correct password and has no extra spaces.
- In MongoDB Atlas, confirm that your IP address is in the Network Access allow list. You can add 0.0.0.0/0 for development to allow all IPs.
- Make sure the .env file is directly inside the server folder, not nested deeper.
Help me fix my MongoDB Atlas connection error.
- Finally, initialize a Git repository and push to GitHub by running these commands from the project root:
cd ~/Desktop/ai-study-assistant
git init
git add .
git commit -m "Initial project structure with React frontend and Express backend"
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/ai-study-assistant.git"]]
git push -u origin main
Push rejected or branch error?
- If Git says the remote already has commits, run git pull origin main --allow-unrelated-histories first, then push again.
- If your default branch is called master instead of main, run git branch -M main before pushing.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each key file at this point:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Vite configuration for the AI Study Assistant frontend
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Forward API requests to the Express backend during development
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
});
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Main App component with client-side routing
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<h1>AI Study Assistant</h1>} />
</Routes>
</BrowserRouter>
);
}
export default App;
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// Mount the App component into the DOM
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const connectDB = require('./config/db');
const app = express();
// Connect to MongoDB
connectDB();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173'
}));
app.use(express.json());
// Rate limiting - 100 requests per 15 minutes per IP
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100
});
app.use(limiter);
// Placeholder route mounts
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
const mongoose = require('mongoose');
// Connect to MongoDB Atlas using the connection string from .env
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
MONGODB_URI=your_mongodb_connection_string
JWT_SECRET=your_jwt_secret_key
OPENAI_API_KEY=your_openai_api_key
PORT=5000
CLIENT_URL=http://localhost:5173
Your project skeleton is complete. Next up, you will design the system architecture in detail and define the MongoDB database schema that powers every feature of your study assistant.
Design the Database Schema and API Spec
Before writing any feature code, you need a clear picture of how your data is structured and how the frontend will talk to the backend.
In this step, you will define all seven Mongoose models that map to your MongoDB collections, document the relationships between them, and write a full API specification. This upfront design saves hours of refactoring later.
In this step, get ready to:
- Plan the data relationships between Users, Notes, Summaries, Quizzes, Flashcards, and StudySessions.
- Create all seven Mongoose schema files in the server.
- Document the full REST API specification and entity relationships.
Plan your data relationships
A clear entity-relationship diagram helps you see which collections reference each other. Every model you create will link back to one or more related collections using ObjectId references.
- In VS Code, open the docs folder inside your ai-study-assistant project.
- Create a new file called er-diagram.md inside the docs folder by pasting the following:
# Entity Relationship Diagram
## Collection Relationships
```
Users (1) ──────── (N) Notes
│ │
│ ├──── (1) ── (N) Summaries
│ │
│ ├──── (1) ── (N) Quizzes ──── (1) ── (N) QuizAttempts
│ │
│ └──── (1) ── (N) Flashcards
│
└──────────────── (1) ── (N) StudySessions
```
## Relationships Explained
- A **User** has many **Notes** (one-to-many)
- A **Note** has one **Summary** (one-to-one, generated on demand)
- A **Note** has many **Quizzes** (one-to-many, can regenerate)
- A **Quiz** has many **QuizAttempts** (one-to-many)
- A **Note** has many **Flashcard** decks (one-to-many)
- A **User** has many **StudySessions** (one-to-many)
- All resources are scoped to the owning User (multi-tenant by userId)
## Index Strategy
- `userId` indexed on all collections for fast per-user queries
- `noteId` indexed on Summaries, Quizzes, Flashcards for fast lookups by source note
- `email` unique index on Users for login lookups
- `quizId` indexed on QuizAttempts for attempt history
What does this diagram show?
The (1) ── (N) notation means "one-to-many". A single User can own many Notes, and each Note can generate multiple Quizzes or Flashcard decks.
Indexing userId on every collection ensures that per-user queries ("show me MY notes") are fast, even as the database grows.
- Save docs/er-diagram.md.
- Confirm the file appears in your VS Code Explorer sidebar under the docs folder alongside architecture.md.
Next, verify that your MongoDB Atlas cluster can accept connections from your machine.
- Open the MongoDB Atlas dashboard in your browser.
- Click **Network Access** in the left sidebar.
- Confirm your current IP address is listed, or click **Add IP Address** and select **Add Current IP Address**.
- Click **Confirm** to save.
Why whitelist your IP?
MongoDB Atlas blocks all connections by default. Your IP must be on the allowed list before Mongoose can connect from your local machine. Without it, your server will hang or throw a connection timeout error.
Create the Mongoose data models
Each model file defines the shape of documents stored in a MongoDB collection. Mongoose uses these schemas to validate data before it reaches the database.
- Inside server/models/, create a new file called User.js with the following code:
const mongoose = require('mongoose');
// User schema for authentication and profile data
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
index: true
},
password: {
type: String,
required: true
},
name: {
type: String,
required: true
},
studyStreak: {
type: Number,
default: 0
},
lastStudyDate: {
type: Date
}
}, { timestamps: true });
module.exports = mongoose.model('User', userSchema);
What does this schema do?
The email field has unique: true and index: true so MongoDB can quickly look up users by email during login. No two users can share the same email address.
The { timestamps: true } option automatically adds createdAt and updatedAt Date fields. Mongoose manages both for you whenever a document is saved or updated.
- Save User.js.
- Verify the model loads without errors by running this in your terminal from the server directory:
node -e "require('./models/User'); console.log('User model OK')"
You should see User model OK printed without errors.
Seeing a module not found error?
Make sure you are running the command from inside the server folder, not the project root. Run cd server first if needed.
Also check that the file is named exactly User.js (capital U) and saved inside server/models/.
help me debug a module not found error for my Mongoose User model
- Create Note.js inside server/models/ with the following code:
const mongoose = require('mongoose');
// Note schema for uploaded PDFs and manual notes
const noteSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
title: {
type: String,
required: true
},
content: {
type: String,
required: true
},
subject: {
type: String
},
sourceType: {
type: String,
enum: ['pdf', 'manual'],
required: true
},
originalFileName: {
type: String
},
wordCount: {
type: Number
}
}, { timestamps: true });
module.exports = mongoose.model('Note', noteSchema);
What does this schema do?
The userId field uses mongoose.Schema.Types.ObjectId with ref: 'User' to create a foreign-key-like relationship. This lets Mongoose populate the full user document when querying notes.
The sourceType field uses enum to restrict values to either pdf or manual. MongoDB will reject any document that tries to use a different value.
- Save Note.js.
- Create Summary.js inside server/models/ with the following code:
const mongoose = require('mongoose');
// Summary schema for AI-generated note summaries
const summarySchema = new mongoose.Schema({
noteId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Note',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
content: {
type: String,
required: true
},
keyPoints: [{
type: String
}],
generatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Summary', summarySchema);
- Save Summary.js.
- Verify both new models load correctly by running:
node -e "require('./models/Note'); require('./models/Summary'); console.log('Note + Summary models OK')"
You should see Note + Summary models OK printed.
- Create Quiz.js inside server/models/ with the following code:
const mongoose = require('mongoose');
// Quiz schema for AI-generated quizzes from notes
const quizSchema = new mongoose.Schema({
noteId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Note',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
title: {
type: String,
required: true
},
questions: [{
question: { type: String, required: true },
options: [{ type: String }],
correctAnswer: { type: Number, required: true },
explanation: { type: String }
}],
generatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Quiz', quizSchema);
Why embed questions inside the Quiz document?
Each quiz has a small, fixed set of questions (5-10). Embedding them as a subdocument array means you fetch the entire quiz in a single database read. There is no need for a separate Questions collection.
The correctAnswer stores the index of the correct option in the options array (0-based).
- Save Quiz.js.
- Create QuizAttempt.js inside server/models/ with the following code:
const mongoose = require('mongoose');
// QuizAttempt schema for tracking quiz scores over time
const quizAttemptSchema = new mongoose.Schema({
quizId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Quiz',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
score: {
type: Number,
required: true
},
totalQuestions: {
type: Number,
required: true
},
answers: [{
type: Number
}],
completedAt: {
type: Date,
default: Date.now
},
timeTaken: {
type: Number
}
});
module.exports = mongoose.model('QuizAttempt', quizAttemptSchema);
- Save QuizAttempt.js.
- Create Flashcard.js inside server/models/ with the following code:
const mongoose = require('mongoose');
// Flashcard schema for AI-generated flashcard decks
const flashcardSchema = new mongoose.Schema({
noteId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Note',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
deckTitle: {
type: String,
required: true
},
cards: [{
front: { type: String, required: true },
back: { type: String, required: true },
status: {
type: String,
enum: ['new', 'learning', 'known'],
default: 'new'
},
nextReviewDate: { type: Date },
easinessFactor: { type: Number, default: 2.5 },
repetitions: { type: Number, default: 0 },
interval: { type: Number, default: 0 }
}],
generatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Flashcard', flashcardSchema);
Why include spaced repetition fields now?
The nextReviewDate, easinessFactor, repetitions, and interval fields are for the Secret Mission (SM-2 algorithm). Including them in the schema now avoids a migration later. They default to sensible starting values and are ignored until the algorithm is implemented.
- Save Flashcard.js.
- Create the final model, StudySession.js, inside server/models/:
const mongoose = require('mongoose');
// StudySession schema for tracking user study activity
const studySessionSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
type: {
type: String,
enum: ['quiz', 'flashcard', 'reading'],
required: true
},
resourceId: {
type: mongoose.Schema.Types.ObjectId
},
duration: {
type: Number
},
cardsReviewed: {
type: Number
},
score: {
type: Number
},
date: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('StudySession', studySessionSchema);
- Save StudySession.js.
- Verify all seven models load without errors by running:
node -e "require('./models/User'); require('./models/Note'); require('./models/Summary'); require('./models/Quiz'); require('./models/QuizAttempt'); require('./models/Flashcard'); require('./models/StudySession'); console.log('All 7 models OK')"
You should see All 7 models OK printed with no errors.
Seeing a syntax or validation error?
Check for missing commas between field definitions. Each field object needs a comma after its closing brace if another field follows.
Make sure every enum value is wrapped in square brackets as an array (e.g., ['pdf', 'manual']).
help me fix a syntax error in my Mongoose model files
✔️ Awesome, I've got everything!
Great. Make sure all seven files are saved inside server/models/.
ⓧ I'd like to double check the full code
Here are all seven model files for comparison:
const mongoose = require('mongoose');
// User schema for authentication and profile data
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
index: true
},
password: {
type: String,
required: true
},
name: {
type: String,
required: true
},
studyStreak: {
type: Number,
default: 0
},
lastStudyDate: {
type: Date
}
}, { timestamps: true });
module.exports = mongoose.model('User', userSchema);
const mongoose = require('mongoose');
// Note schema for uploaded PDFs and manual notes
const noteSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
title: {
type: String,
required: true
},
content: {
type: String,
required: true
},
subject: {
type: String
},
sourceType: {
type: String,
enum: ['pdf', 'manual'],
required: true
},
originalFileName: {
type: String
},
wordCount: {
type: Number
}
}, { timestamps: true });
module.exports = mongoose.model('Note', noteSchema);
const mongoose = require('mongoose');
// Summary schema for AI-generated note summaries
const summarySchema = new mongoose.Schema({
noteId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Note',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
content: {
type: String,
required: true
},
keyPoints: [{
type: String
}],
generatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Summary', summarySchema);
const mongoose = require('mongoose');
// Quiz schema for AI-generated quizzes from notes
const quizSchema = new mongoose.Schema({
noteId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Note',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
title: {
type: String,
required: true
},
questions: [{
question: { type: String, required: true },
options: [{ type: String }],
correctAnswer: { type: Number, required: true },
explanation: { type: String }
}],
generatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Quiz', quizSchema);
const mongoose = require('mongoose');
// QuizAttempt schema for tracking quiz scores over time
const quizAttemptSchema = new mongoose.Schema({
quizId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Quiz',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
score: {
type: Number,
required: true
},
totalQuestions: {
type: Number,
required: true
},
answers: [{
type: Number
}],
completedAt: {
type: Date,
default: Date.now
},
timeTaken: {
type: Number
}
});
module.exports = mongoose.model('QuizAttempt', quizAttemptSchema);
const mongoose = require('mongoose');
// Flashcard schema for AI-generated flashcard decks
const flashcardSchema = new mongoose.Schema({
noteId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Note',
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
deckTitle: {
type: String,
required: true
},
cards: [{
front: { type: String, required: true },
back: { type: String, required: true },
status: {
type: String,
enum: ['new', 'learning', 'known'],
default: 'new'
},
nextReviewDate: { type: Date },
easinessFactor: { type: Number, default: 2.5 },
repetitions: { type: Number, default: 0 },
interval: { type: Number, default: 0 }
}],
generatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Flashcard', flashcardSchema);
const mongoose = require('mongoose');
// StudySession schema for tracking user study activity
const studySessionSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
type: {
type: String,
enum: ['quiz', 'flashcard', 'reading'],
required: true
},
resourceId: {
type: mongoose.Schema.Types.ObjectId
},
duration: {
type: Number
},
cardsReviewed: {
type: Number
},
score: {
type: Number
},
date: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('StudySession', studySessionSchema);
Document the API endpoints
Your API specification is the contract between frontend and backend. It tells you exactly which URLs to call, what data to send, and what to expect back.
- Create a new file called api-spec.md inside the docs folder with the following content:
# API Specification
## Base URL
- Development: `http://localhost:5000/api`
- Production: `https://<your-render-service>.onrender.com/api`
## Authentication
All protected routes require a `Authorization: Bearer <token>` header.
---
## Auth Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| POST | /api/auth/register | Register new user | No |
| POST | /api/auth/login | Login and receive JWT | No |
| GET | /api/auth/me | Get current user profile | Yes |
| PUT | /api/auth/profile | Update user profile | Yes |
### POST /api/auth/register
**Request Body:**
```json
{ "email": "string", "password": "string", "name": "string" }
```
**Response (201):**
```json
{ "token": "jwt-string", "user": { "id": "string", "email": "string", "name": "string" } }
```
### POST /api/auth/login
**Request Body:**
```json
{ "email": "string", "password": "string" }
```
**Response (200):**
```json
{ "token": "jwt-string", "user": { "id": "string", "email": "string", "name": "string" } }
```
---
## Notes Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| GET | /api/notes | Get all user's notes | Yes |
| GET | /api/notes/:id | Get single note | Yes |
| POST | /api/notes | Create note (text) | Yes |
| POST | /api/notes/upload | Upload PDF and extract text | Yes |
| PUT | /api/notes/:id | Update note | Yes |
| DELETE | /api/notes/:id | Delete note | Yes |
### POST /api/notes
**Request Body:**
```json
{ "title": "string", "content": "string", "subject": "string" }
```
**Response (201):** Full note object
### POST /api/notes/upload
**Request:** multipart/form-data with `file` field (PDF, max 10MB)
**Response (201):** Full note object with extracted text as content
---
## AI Generation Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| POST | /api/ai/summarize/:noteId | Generate summary for a note | Yes |
| POST | /api/ai/quiz/:noteId | Generate quiz from a note | Yes |
| POST | /api/ai/flashcards/:noteId | Generate flashcards from a note | Yes |
### POST /api/ai/summarize/:noteId
**Response (201):**
```json
{ "content": "string", "keyPoints": ["string"] }
```
### POST /api/ai/quiz/:noteId
**Response (201):**
```json
{
"title": "string",
"questions": [{
"question": "string",
"options": ["string"],
"correctAnswer": 0,
"explanation": "string"
}]
}
```
### POST /api/ai/flashcards/:noteId
**Response (201):**
```json
{
"deckTitle": "string",
"cards": [{ "front": "string", "back": "string" }]
}
```
---
## Quiz Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| GET | /api/quizzes | Get all user's quizzes | Yes |
| GET | /api/quizzes/:id | Get single quiz | Yes |
| POST | /api/quizzes/:id/attempt | Submit quiz attempt | Yes |
| GET | /api/quizzes/:id/attempts | Get quiz attempt history | Yes |
### POST /api/quizzes/:id/attempt
**Request Body:**
```json
{ "answers": [0, 2, 1, 3, 0], "timeTaken": 120 }
```
**Response (201):**
```json
{ "score": 4, "totalQuestions": 5, "answers": [0, 2, 1, 3, 0] }
```
---
## Flashcard Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| GET | /api/flashcards | Get all user's flashcard decks | Yes |
| GET | /api/flashcards/:id | Get single deck | Yes |
| PUT | /api/flashcards/:id/cards/:cardIndex | Update card status | Yes |
| GET | /api/flashcards/due | Get cards due for review | Yes |
### PUT /api/flashcards/:id/cards/:cardIndex
**Request Body:**
```json
{ "status": "known" }
```
**Response (200):** Updated flashcard deck
---
## Analytics Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| GET | /api/analytics/overview | Get dashboard overview stats | Yes |
| GET | /api/analytics/study-time | Get study time data | Yes |
| GET | /api/analytics/quiz-scores | Get quiz score trends | Yes |
| GET | /api/analytics/progress | Get overall progress metrics | Yes |
| POST | /api/analytics/session | Log a study session | Yes |
### POST /api/analytics/session
**Request Body:**
```json
{ "type": "quiz", "resourceId": "string", "duration": 300, "score": 80 }
```
**Response (201):** Created study session object
---
## Error Responses
All errors follow this format:
```json
{ "error": "Error message string" }
```
| Status | Meaning |
|--------|---------|
| 400 | Bad request (validation failed) |
| 401 | Unauthorized (missing or invalid token) |
| 404 | Resource not found |
| 500 | Internal server error |
What does this spec give you?
Every endpoint is documented with its HTTP method, URL path, whether it requires authentication, and what data it sends or receives. When you build the frontend later, this file tells you exactly which URL to call and what JSON shape to expect.
The /api/flashcards/due endpoint is included for the Secret Mission spaced repetition feature. Having it in the spec now keeps the design consistent.
- Save docs/api-spec.md.
- Confirm all three documentation files exist by running from the project root:
ls docs/
You should see api-spec.md, architecture.md, and er-diagram.md listed.
Missing a file in the docs folder?
Make sure you created each file inside the docs/ directory at the project root, not inside server/ or client/. The path should be ai-study-assistant/docs/api-spec.md.
help me find where my docs files ended up
Your data layer is fully designed. You have seven models defining every collection your app needs, a clear relationship map, and a complete API contract. Next up, you will build the authentication system that creates and verifies users.
Build the Authentication System
Your database models are defined and your API spec is documented. Now you need a way for users to prove who they are before accessing their notes and study materials.
In this step, you will build a complete authentication system across both the backend and frontend. The backend will handle password hashing and token generation. The frontend will manage login state and protect routes from unauthenticated access.
In this step, get ready to:
- Implement backend auth routes with bcrypt password hashing and JWT token generation.
- Create JWT middleware to protect private endpoints.
- Build frontend login/register pages with an auth context and protected routing.
Implement backend auth middleware and routes
Before writing any route handlers, you need two pieces of middleware. The first verifies JWT tokens on incoming requests. The second catches errors globally so your route handlers stay clean.
- Create the file server/middleware/auth.js and paste the following code:
const jwt = require('jsonwebtoken');
// Verify JWT token from Authorization header
const auth = (req, res, next) => {
try {
// Extract token from Bearer header
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token, authorization denied' });
}
const token = authHeader.replace('Bearer ', '');
// Verify token using JWT_SECRET
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ message: 'Token is not valid' });
}
};
module.exports = auth;
What does this code do?
This middleware intercepts every request to protected routes. It reads the Authorization header, strips the Bearer prefix, and verifies the token using your JWT_SECRET environment variable.
If the token is valid, the decoded payload (containing userId) is attached to req.user so downstream route handlers know which user made the request.
- Create the file server/middleware/errorHandler.js and paste the following code:
// Global error handler middleware
const errorHandler = (err, req, res, next) => {
console.error(err.stack);
// Default to 500 if no status code set
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
message,
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
});
};
module.exports = errorHandler;
What does this code do?
This is Express's global error-catching middleware. Any error passed to next(error) in your route handlers ends up here. It logs the error stack and returns a JSON response with an appropriate status code.
In production, the stack trace is hidden to avoid leaking internal details to users.
Now you can build the auth routes themselves. These handle user registration, login, and profile retrieval.
- Create the file server/routes/auth.js and paste the following code:
const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const auth = require('../middleware/auth');
// POST /api/auth/register - Register a new user
router.post('/register', async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'User already exists with this email' });
}
// Hash password with 10 salt rounds
const hashedPassword = await bcrypt.hash(password, 10);
// Create new user
const user = await User.create({
name,
email,
password: hashedPassword
});
// Generate JWT token (1h expiry)
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.status(201).json({
token,
user: { id: user._id, name: user.name, email: user.email }
});
} catch (error) {
next(error);
}
});
// POST /api/auth/login - Login user
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Compare password with stored hash
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Generate JWT token (1h expiry)
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({
token,
user: { id: user._id, name: user.name, email: user.email }
});
} catch (error) {
next(error);
}
});
// GET /api/auth/me - Get current user profile
router.get('/me', auth, async (req, res, next) => {
try {
const user = await User.findById(req.user.userId).select('-password');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
next(error);
}
});
// PUT /api/auth/profile - Update user profile
router.put('/profile', auth, async (req, res, next) => {
try {
const { name, email } = req.body;
const user = await User.findByIdAndUpdate(
req.user.userId,
{ name, email, updatedAt: Date.now() },
{ new: true }
).select('-password');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
next(error);
}
});
module.exports = router;
What does this code do?
This file defines four endpoints. POST /register hashes the password with bcrypt using 10 salt rounds, creates a user document, and returns a signed JWT. POST /login finds the user by email and uses bcrypt.compare to verify the password against the stored hash.
The GET /me and PUT /profile routes use the auth middleware, meaning they require a valid token. The .select('-password') call ensures the hashed password is never returned to the client.
Why bcrypt with 10 salt rounds?
Bcrypt is a one-way hashing algorithm designed specifically for passwords. The salt rounds parameter controls how computationally expensive the hash is to produce. 10 rounds strikes a good balance between security and speed for a development project.
Each round doubles the computation time. Increasing to 12 or higher is recommended for production apps handling real user data.
Finally, mount the auth routes in your main server file and attach the error handler.
- Open server/server.js and find the section with your placeholder route mounts.
- Replace the placeholder with the auth routes mount and add the error handler as the last middleware:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const connectDB = require('./config/db');
const errorHandler = require('./middleware/errorHandler');
require('dotenv').config();
const app = express();
// Connect to MongoDB
connectDB();
// Security middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use(limiter);
// Routes
app.use('/api/auth', require('./routes/auth'));
// Error handler (must be last middleware)
app.use(errorHandler);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What changed here?
Two additions. First, require('./routes/auth') is mounted at /api/auth, so all four endpoints (register, login, me, profile) live under that path. Second, errorHandler is registered as the very last middleware so it catches any errors thrown by route handlers above it.
- Save all four files: server/middleware/auth.js, server/middleware/errorHandler.js, server/routes/auth.js, and server/server.js.
- Start your server by running:
cd server
node server.js
- You should see Server running on port 5000 and a MongoDB connection success message in your terminal.
Server won't start?
- Check that your .env file exists in the server/ folder with MONGODB_URI, JWT_SECRET, and PORT=5000 defined.
- Ensure your MongoDB Atlas IP whitelist includes your current IP address.
- Verify bcrypt and jsonwebtoken are listed in your server/package.json dependencies. If not, run npm install bcrypt jsonwebtoken from the server/ folder.
Help me debug my server startup error.
Set up the frontend API service and auth context
The backend is ready. Now you need a way for your React frontend to talk to it and manage the logged-in user's state across page navigations.
- Create the file client/src/services/api.js and paste the following code:
import axios from 'axios';
// Create Axios instance with base URL
const api = axios.create({
baseURL: '/api'
});
// Request interceptor to attach auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor to handle 401 errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
What does this code do?
This creates a configured Axios instance that all frontend API calls will use. The request interceptor automatically attaches the JWT from localStorage to every outgoing request as an Authorization: Bearer header.
The response interceptor catches 401 responses (expired or invalid tokens). When that happens, it clears the stored token and redirects to the login page.
- Create the file client/src/context/AuthContext.jsx and paste the following code:
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../services/api';
const AuthContext = createContext();
// Custom hook to access auth context
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Restore session from localStorage on mount
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const res = await api.get('/auth/me');
setUser(res.data);
} catch (error) {
localStorage.removeItem('token');
}
}
setLoading(false);
};
loadUser();
}, []);
// Register a new user
const register = async (name, email, password) => {
const res = await api.post('/auth/register', { name, email, password });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data;
};
// Login an existing user
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data;
};
// Logout the current user
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout, register }}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;
What does this code do?
The AuthContext provides a single source of truth for the logged-in user across your entire React app. On mount, it checks localStorage for an existing token and calls GET /api/auth/me to restore the user session.
The login, register, and logout functions update both localStorage and the React state, so any component using useAuth() re-renders immediately when the auth state changes.
- Save both files: client/src/services/api.js and client/src/context/AuthContext.jsx.
Seeing import errors?
Ensure your client/package.json includes axios and react-router-dom in its dependencies. If either is missing, run npm install axios react-router-dom from the client/ folder.
Help me fix import errors in my React auth files.
Build the auth pages and routing
With the API layer and auth context in place, you can now build the pages that users will interact with. You need a login form, a registration form, a protected route wrapper, and a landing page.
- Create the file client/src/components/auth/LoginForm.jsx and paste the following code:
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const LoginForm = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
// Handle input changes
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
// Validate and submit the form
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!formData.email || !formData.password) {
setError('Please fill in all fields');
return;
}
try {
await login(formData.email, formData.password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.message || 'Login failed');
}
};
return (
<div className="auth-form-container">
<h2>Login</h2>
{error && <p className="error-message">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Enter your password"
/>
</div>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <Link to="/register">Register</Link></p>
</div>
);
};
export default LoginForm;
What does this code do?
This is a controlled form component. React state tracks both input values via formData. On submit, it validates that both fields are filled, then calls login() from the AuthContext. If successful, the user is navigated to /dashboard. If the API returns an error, it displays the message to the user.
- Create the file client/src/components/auth/RegisterForm.jsx and paste the following code:
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const RegisterForm = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
// Handle input changes
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
// Validate and submit the form
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!formData.name || !formData.email || !formData.password || !formData.confirmPassword) {
setError('Please fill in all fields');
return;
}
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(formData.name, formData.email, formData.password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.message || 'Registration failed');
}
};
return (
<div className="auth-form-container">
<h2>Register</h2>
{error && <p className="error-message">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Enter your name"
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Enter your password"
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="Confirm your password"
/>
</div>
<button type="submit">Register</button>
</form>
<p>Already have an account? <Link to="/login">Login</Link></p>
</div>
);
};
export default RegisterForm;
What does this code do?
The registration form adds three extra validations on top of the login form. It checks that all four fields are filled, that the password and confirm password match, and that the password is at least 6 characters long. These checks happen on the client before any network request is made.
- Create the file client/src/components/common/ProtectedRoute.jsx and paste the following code:
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
// Show nothing while checking authentication
if (loading) return null;
// Redirect to login if not authenticated
if (!user) return <Navigate to="/login" />;
return children;
};
export default ProtectedRoute;
What does this code do?
This component wraps any route that requires authentication. While the AuthContext is still checking for a stored token (loading is true), it renders nothing. Once loading completes, if there is no user it redirects to /login. Otherwise it renders the child components.
- Create the file client/src/pages/Landing.jsx and paste the following code:
import { Link } from 'react-router-dom';
const Landing = () => {
return (
<div className="landing-page">
<section className="hero">
<h1>AI Study Assistant</h1>
<p>Upload your notes and let AI generate summaries, quizzes, and flashcards to supercharge your learning.</p>
<div className="cta-buttons">
<Link to="/register" className="btn btn-primary">Get Started</Link>
<Link to="/login" className="btn btn-secondary">Login</Link>
</div>
</section>
<section className="features">
<h2>Features</h2>
<ul>
<li>AI-powered summaries from your notes</li>
<li>Auto-generated quizzes to test your knowledge</li>
<li>Smart flashcards with spaced repetition</li>
<li>Progress tracking and analytics dashboard</li>
</ul>
</section>
</div>
);
};
export default Landing;
Now wire everything together in the main app files.
- Open client/src/App.jsx and replace its contents with the following:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Landing from './pages/Landing';
import LoginForm from './components/auth/LoginForm';
import RegisterForm from './components/auth/RegisterForm';
import ProtectedRoute from './components/common/ProtectedRoute';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<h1>Dashboard</h1>
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
);
}
export default App;
- Open client/src/main.jsx and replace its contents with the following:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './context/AuthContext';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);
Why wrap App with AuthProvider?
The AuthProvider must sit outside BrowserRouter and Routes so that every component in the tree can call useAuth(). This includes ProtectedRoute, the login form, and any future page that needs to know the current user.
- Save all the files you just created and modified.
- Start the frontend dev server by running:
cd client
npm run dev
- Open the URL shown in your terminal (typically http://localhost:5173). You should see the landing page with a hero heading and two CTA buttons.
- Click **Get Started** to navigate to the registration form. Fill it out and submit. If everything is wired correctly, you will be redirected to /dashboard showing the text "Dashboard".
Registration fails or shows a network error?
- Confirm your backend server is still running on port 5000. The Vite proxy in vite.config.js forwards /api requests to http://localhost:5000.
- Open your browser DevTools (**Network** tab) and check the failing request. A 500 error usually means a missing environment variable or a Mongoose connection issue.
- If you see a CORS error, confirm your cors() middleware is before your route mounts in server.js.
Help me debug my registration form not connecting to the backend.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here are the complete files for this step.
const jwt = require('jsonwebtoken');
// Verify JWT token from Authorization header
const auth = (req, res, next) => {
try {
// Extract token from Bearer header
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token, authorization denied' });
}
const token = authHeader.replace('Bearer ', '');
// Verify token using JWT_SECRET
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ message: 'Token is not valid' });
}
};
module.exports = auth;
// Global error handler middleware
const errorHandler = (err, req, res, next) => {
console.error(err.stack);
// Default to 500 if no status code set
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
message,
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
});
};
module.exports = errorHandler;
const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const auth = require('../middleware/auth');
// POST /api/auth/register - Register a new user
router.post('/register', async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'User already exists with this email' });
}
// Hash password with 10 salt rounds
const hashedPassword = await bcrypt.hash(password, 10);
// Create new user
const user = await User.create({
name,
email,
password: hashedPassword
});
// Generate JWT token (1h expiry)
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.status(201).json({
token,
user: { id: user._id, name: user.name, email: user.email }
});
} catch (error) {
next(error);
}
});
// POST /api/auth/login - Login user
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Compare password with stored hash
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Generate JWT token (1h expiry)
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({
token,
user: { id: user._id, name: user.name, email: user.email }
});
} catch (error) {
next(error);
}
});
// GET /api/auth/me - Get current user profile
router.get('/me', auth, async (req, res, next) => {
try {
const user = await User.findById(req.user.userId).select('-password');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
next(error);
}
});
// PUT /api/auth/profile - Update user profile
router.put('/profile', auth, async (req, res, next) => {
try {
const { name, email } = req.body;
const user = await User.findByIdAndUpdate(
req.user.userId,
{ name, email, updatedAt: Date.now() },
{ new: true }
).select('-password');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
next(error);
}
});
module.exports = router;
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const connectDB = require('./config/db');
const errorHandler = require('./middleware/errorHandler');
require('dotenv').config();
const app = express();
// Connect to MongoDB
connectDB();
// Security middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use(limiter);
// Routes
app.use('/api/auth', require('./routes/auth'));
// Error handler (must be last middleware)
app.use(errorHandler);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
import axios from 'axios';
// Create Axios instance with base URL
const api = axios.create({
baseURL: '/api'
});
// Request interceptor to attach auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor to handle 401 errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../services/api';
const AuthContext = createContext();
// Custom hook to access auth context
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Restore session from localStorage on mount
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const res = await api.get('/auth/me');
setUser(res.data);
} catch (error) {
localStorage.removeItem('token');
}
}
setLoading(false);
};
loadUser();
}, []);
// Register a new user
const register = async (name, email, password) => {
const res = await api.post('/auth/register', { name, email, password });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data;
};
// Login an existing user
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data;
};
// Logout the current user
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout, register }}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const LoginForm = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
// Handle input changes
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
// Validate and submit the form
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!formData.email || !formData.password) {
setError('Please fill in all fields');
return;
}
try {
await login(formData.email, formData.password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.message || 'Login failed');
}
};
return (
<div className="auth-form-container">
<h2>Login</h2>
{error && <p className="error-message">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Enter your password"
/>
</div>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <Link to="/register">Register</Link></p>
</div>
);
};
export default LoginForm;
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const RegisterForm = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
// Handle input changes
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
// Validate and submit the form
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!formData.name || !formData.email || !formData.password || !formData.confirmPassword) {
setError('Please fill in all fields');
return;
}
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(formData.name, formData.email, formData.password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.message || 'Registration failed');
}
};
return (
<div className="auth-form-container">
<h2>Register</h2>
{error && <p className="error-message">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Enter your name"
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Enter your password"
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="Confirm your password"
/>
</div>
<button type="submit">Register</button>
</form>
<p>Already have an account? <Link to="/login">Login</Link></p>
</div>
);
};
export default RegisterForm;
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
// Show nothing while checking authentication
if (loading) return null;
// Redirect to login if not authenticated
if (!user) return <Navigate to="/login" />;
return children;
};
export default ProtectedRoute;
import { Link } from 'react-router-dom';
const Landing = () => {
return (
<div className="landing-page">
<section className="hero">
<h1>AI Study Assistant</h1>
<p>Upload your notes and let AI generate summaries, quizzes, and flashcards to supercharge your learning.</p>
<div className="cta-buttons">
<Link to="/register" className="btn btn-primary">Get Started</Link>
<Link to="/login" className="btn btn-secondary">Login</Link>
</div>
</section>
<section className="features">
<h2>Features</h2>
<ul>
<li>AI-powered summaries from your notes</li>
<li>Auto-generated quizzes to test your knowledge</li>
<li>Smart flashcards with spaced repetition</li>
<li>Progress tracking and analytics dashboard</li>
</ul>
</section>
</div>
);
};
export default Landing;
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Landing from './pages/Landing';
import LoginForm from './components/auth/LoginForm';
import RegisterForm from './components/auth/RegisterForm';
import ProtectedRoute from './components/common/ProtectedRoute';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<h1>Dashboard</h1>
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
);
}
export default App;
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './context/AuthContext';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);
Your authentication system is fully wired. Users can register, log in, and access protected routes. Next up, you will build the file upload and note management system so users have content for AI to work with.
Implement File Upload and Note Management
Your authentication system is in place. Users can register, log in, and access protected routes.
Now you need a way for users to get their study material into the app. In this step, you will build the file upload pipeline that accepts PDF documents, extracts their text content, and stores it as notes. You will also build the frontend pages that let users browse, search, and manage their notes.
In this step, get ready to:
- Set up PDF upload middleware with Multer and text extraction with pdf-parse.
- Create CRUD API endpoints for notes.
- Build the notes dashboard UI with file upload, search, and filtering.
Set up the upload middleware and PDF parser
Before you can accept file uploads, you need two things: middleware to handle the incoming file data, and a service to extract readable text from PDFs.
You will use Multer for file handling and pdf-parse for text extraction.
- Install the backend dependencies by running this command from inside your server folder:
npm install multer pdf-parse
What are these packages?
Multer is Express middleware that handles multipart/form-data (the format browsers use to send files). It gives you access to the uploaded file via req.file.
pdf-parse reads a PDF buffer and returns the extracted text content as a string. You pass it a buffer and get back data.text.
- Create the Multer configuration file at server/middleware/upload.js and add the following code:
const multer = require('multer');
// Use memory storage so the file stays as a buffer (no disk writes)
const storage = multer.memoryStorage();
// Only accept PDF files
const fileFilter = (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'), false);
}
};
// Configure multer with 10MB size limit
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 10 * 1024 * 1024 }
});
module.exports = upload;
What does this code do?
Memory storage keeps the uploaded file as a Buffer in memory rather than writing it to disk. This is important because Render's free tier has no persistent disk storage.
The fileFilter function checks file.mimetype and rejects anything that is not a PDF. The 10MB limit prevents oversized uploads from exhausting server memory.
- Create the PDF parser service at server/services/pdfParser.js and add this code:
const pdfParse = require('pdf-parse');
// Accept a PDF buffer and return the extracted text
const extractText = async (buffer) => {
const data = await pdfParse(buffer);
return data.text;
};
module.exports = { extractText };
What does this code do?
The extractText function takes a raw PDF buffer (from Multer's memory storage) and passes it to pdf-parse. The library returns an object with a text property containing all readable text from the PDF.
- Save both files.
Seeing a module not found error?
Make sure you ran npm install multer pdf-parse from inside the server directory, not the project root. Check that both packages appear in your server/package.json under dependencies.
Help me debug a module not found error in my Express server.
Create the notes API routes
With the upload middleware and parser ready, you can build the full set of CRUD endpoints for notes. These endpoints let users create notes manually, upload PDFs, retrieve their notes, update them, and delete them.
- Create the notes router at server/routes/notes.js and add the GET endpoints:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const upload = require('../middleware/upload');
const Note = require('../models/Note');
const { extractText } = require('../services/pdfParser');
// GET /api/notes - Get all notes for the authenticated user
router.get('/', auth, async (req, res, next) => {
try {
const notes = await Note.find({ userId: req.user.id }).sort({ createdAt: -1 });
res.json(notes);
} catch (err) {
next(err);
}
});
// GET /api/notes/:id - Get a single note by ID
router.get('/:id', auth, async (req, res, next) => {
try {
const note = await Note.findOne({ _id: req.params.id, userId: req.user.id });
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
res.json(note);
} catch (err) {
next(err);
}
});
What does this code do?
Both GET routes use the auth middleware to ensure only logged-in users can access them. The query { userId: req.user.id } ensures users can only see their own notes.
The list endpoint sorts by createdAt: -1 so the newest notes appear first. The single-note endpoint checks both _id and userId to prevent unauthorized access.
- Below the GET routes in the same file, add the POST endpoints for creating notes and uploading PDFs:
// POST /api/notes - Create a manual text note
router.post('/', auth, async (req, res, next) => {
try {
const { title, content, subject } = req.body;
const wordCount = content.split(/\s+/).filter(Boolean).length;
const note = await Note.create({
userId: req.user.id,
title,
content,
subject,
sourceType: 'manual',
wordCount
});
res.status(201).json(note);
} catch (err) {
next(err);
}
});
// POST /api/notes/upload - Upload a PDF and extract text
router.post('/upload', auth, upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
// Extract text from the PDF buffer
const text = await extractText(req.file.buffer);
const wordCount = text.split(/\s+/).filter(Boolean).length;
const note = await Note.create({
userId: req.user.id,
title: req.body.title || req.file.originalname.replace('.pdf', ''),
content: text,
subject: req.body.subject || 'General',
sourceType: 'pdf',
originalFileName: req.file.originalname,
wordCount
});
res.status(201).json(note);
} catch (err) {
next(err);
}
});
What does this code do?
The manual note endpoint receives title, content, and subject from the request body, calculates the word count, and saves it with sourceType: 'manual'.
The upload endpoint uses upload.single('file') as middleware to receive the PDF. The field name 'file' must match what the frontend sends in its FormData. After upload, it passes req.file.buffer to extractText and saves the extracted text as the note content.
- Below the POST routes, add the PUT and DELETE endpoints and the module export:
// PUT /api/notes/:id - Update a note
router.put('/:id', auth, async (req, res, next) => {
try {
const note = await Note.findOne({ _id: req.params.id, userId: req.user.id });
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
const { title, content, subject } = req.body;
if (title) note.title = title;
if (content) {
note.content = content;
note.wordCount = content.split(/\s+/).filter(Boolean).length;
}
if (subject) note.subject = subject;
await note.save();
res.json(note);
} catch (err) {
next(err);
}
});
// DELETE /api/notes/:id - Delete a note
router.delete('/:id', auth, async (req, res, next) => {
try {
const note = await Note.findOne({ _id: req.params.id, userId: req.user.id });
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
await note.deleteOne();
res.json({ message: 'Note deleted' });
} catch (err) {
next(err);
}
});
module.exports = router;
Why check ownership on every route?
Every route queries with both _id and userId: req.user.id. This prevents a logged-in user from modifying or deleting another user's notes by guessing the note ID.
- Mount the notes router in server/server.js. Find the line where auth routes are mounted:
app.use('/api/auth', require('./routes/auth'));
- Add the notes route directly below it:
app.use('/api/notes', require('./routes/notes'));
- Save server/routes/notes.js and server/server.js.
- Restart your server and test the upload endpoint with a tool like Postman or curl. You should receive a 201 response with the created note object.
Getting a 500 error on upload?
Check that the field name in your request matches 'file' (the name passed to upload.single()). If the names don't match, Multer throws a LIMIT_UNEXPECTED_FILE error.
Help me debug a file upload error in my Express notes route.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your server files before moving on.
ⓧ I'd like to double check the full code
const multer = require('multer');
// Use memory storage so the file stays as a buffer (no disk writes)
const storage = multer.memoryStorage();
// Only accept PDF files
const fileFilter = (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'), false);
}
};
// Configure multer with 10MB size limit
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 10 * 1024 * 1024 }
});
module.exports = upload;
const pdfParse = require('pdf-parse');
// Accept a PDF buffer and return the extracted text
const extractText = async (buffer) => {
const data = await pdfParse(buffer);
return data.text;
};
module.exports = { extractText };
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const upload = require('../middleware/upload');
const Note = require('../models/Note');
const { extractText } = require('../services/pdfParser');
// GET /api/notes - Get all notes for the authenticated user
router.get('/', auth, async (req, res, next) => {
try {
const notes = await Note.find({ userId: req.user.id }).sort({ createdAt: -1 });
res.json(notes);
} catch (err) {
next(err);
}
});
// GET /api/notes/:id - Get a single note by ID
router.get('/:id', auth, async (req, res, next) => {
try {
const note = await Note.findOne({ _id: req.params.id, userId: req.user.id });
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
res.json(note);
} catch (err) {
next(err);
}
});
// POST /api/notes - Create a manual text note
router.post('/', auth, async (req, res, next) => {
try {
const { title, content, subject } = req.body;
const wordCount = content.split(/\s+/).filter(Boolean).length;
const note = await Note.create({
userId: req.user.id,
title,
content,
subject,
sourceType: 'manual',
wordCount
});
res.status(201).json(note);
} catch (err) {
next(err);
}
});
// POST /api/notes/upload - Upload a PDF and extract text
router.post('/upload', auth, upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
// Extract text from the PDF buffer
const text = await extractText(req.file.buffer);
const wordCount = text.split(/\s+/).filter(Boolean).length;
const note = await Note.create({
userId: req.user.id,
title: req.body.title || req.file.originalname.replace('.pdf', ''),
content: text,
subject: req.body.subject || 'General',
sourceType: 'pdf',
originalFileName: req.file.originalname,
wordCount
});
res.status(201).json(note);
} catch (err) {
next(err);
}
});
// PUT /api/notes/:id - Update a note
router.put('/:id', auth, async (req, res, next) => {
try {
const note = await Note.findOne({ _id: req.params.id, userId: req.user.id });
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
const { title, content, subject } = req.body;
if (title) note.title = title;
if (content) {
note.content = content;
note.wordCount = content.split(/\s+/).filter(Boolean).length;
}
if (subject) note.subject = subject;
await note.save();
res.json(note);
} catch (err) {
next(err);
}
});
// DELETE /api/notes/:id - Delete a note
router.delete('/:id', auth, async (req, res, next) => {
try {
const note = await Note.findOne({ _id: req.params.id, userId: req.user.id });
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
await note.deleteOne();
res.json({ message: 'Note deleted' });
} catch (err) {
next(err);
}
});
module.exports = router;
Build the notes dashboard UI
With the API ready, you can build the frontend. Users need a page to see all their notes, a way to upload PDFs, and a detail view for each note.
- Create the NoteCard component at client/src/components/notes/NoteCard.jsx:
import { Link } from 'react-router-dom';
// Displays a single note as a clickable card
function NoteCard({ note }) {
return (
<Link to={`/notes/${note._id}`} className="note-card">
<h3>{note.title}</h3>
<span className="badge">{note.sourceType}</span>
<p className="subject">{note.subject}</p>
<p className="meta">{note.wordCount} words</p>
<p className="date">{new Date(note.createdAt).toLocaleDateString()}</p>
</Link>
);
}
export default NoteCard;
- Create the NoteList wrapper at client/src/components/notes/NoteList.jsx:
import NoteCard from './NoteCard';
// Grid layout for note cards with an empty state fallback
function NoteList({ notes }) {
if (notes.length === 0) {
return <p className="empty-state">No notes yet. Upload a PDF or create a note to get started.</p>;
}
return (
<div className="note-grid">
{notes.map((note) => (
<NoteCard key={note._id} note={note} />
))}
</div>
);
}
export default NoteList;
- Create the UploadZone drag-and-drop component at client/src/components/notes/UploadZone.jsx:
import { useState, useCallback } from 'react';
import api from '../../services/api';
// Drag-and-drop file upload for PDF files (10MB max)
function UploadZone({ onUploadSuccess }) {
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [progress, setProgress] = useState(0);
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragging(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
}, []);
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) uploadFile(file);
};
const uploadFile = async (file) => {
// Validate file type
if (file.type !== 'application/pdf') {
setError('Only PDF files are allowed.');
return;
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setError('File must be under 10MB.');
return;
}
setError('');
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
const response = await api.post('/notes/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
setProgress(Math.round((e.loaded * 100) / e.total));
}
});
setUploading(false);
setProgress(100);
if (onUploadSuccess) onUploadSuccess(response.data);
} catch (err) {
setUploading(false);
setError(err.response?.data?.message || 'Upload failed.');
}
};
return (
<div
className={`upload-zone ${dragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<p>Drag and drop a PDF here, or click to select a file.</p>
<input
type="file"
accept="application/pdf"
onChange={handleFileSelect}
disabled={uploading}
/>
{uploading && <p>Uploading... {progress}%</p>}
{error && <p className="error">{error}</p>}
</div>
);
}
export default UploadZone;
What does the UploadZone do?
It handles both drag-and-drop and file-picker selection. Before sending the file, it validates the type (PDF only) and size (10MB max) on the client side to give immediate feedback.
The formData.append('file', file) field name must match the 'file' string in upload.single('file') on the backend. If these don't match, Multer rejects the upload.
- Create the Notes page at client/src/pages/Notes.jsx:
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../services/api';
import NoteList from '../components/notes/NoteList';
// Main notes page with search and subject filtering
function Notes() {
const [notes, setNotes] = useState([]);
const [search, setSearch] = useState('');
const [subjectFilter, setSubjectFilter] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNotes();
}, []);
const fetchNotes = async () => {
try {
const response = await api.get('/notes');
setNotes(response.data);
} catch (err) {
console.error('Failed to fetch notes:', err);
} finally {
setLoading(false);
}
};
// Build unique subject list for the filter dropdown
const subjects = [...new Set(notes.map((n) => n.subject))];
// Filter notes by search term and selected subject
const filtered = notes.filter((note) => {
const matchesSearch = note.title.toLowerCase().includes(search.toLowerCase());
const matchesSubject = !subjectFilter || note.subject === subjectFilter;
return matchesSearch && matchesSubject;
});
if (loading) return <p>Loading notes...</p>;
return (
<div className="notes-page">
<div className="notes-header">
<h1>My Notes</h1>
<Link to="/notes/upload" className="btn-upload">Upload PDF</Link>
</div>
<div className="notes-filters">
<input
type="text"
placeholder="Search notes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select value={subjectFilter} onChange={(e) => setSubjectFilter(e.target.value)}>
<option value="">All Subjects</option>
{subjects.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<NoteList notes={filtered} />
</div>
);
}
export default Notes;
- Create the NoteDetail page at client/src/pages/NoteDetail.jsx:
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../services/api';
// Displays a single note with AI action placeholders
function NoteDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [note, setNote] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNote();
}, [id]);
const fetchNote = async () => {
try {
const response = await api.get(`/notes/${id}`);
setNote(response.data);
} catch (err) {
console.error('Failed to fetch note:', err);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!window.confirm('Are you sure you want to delete this note?')) return;
try {
await api.delete(`/notes/${id}`);
navigate('/notes');
} catch (err) {
console.error('Failed to delete note:', err);
}
};
if (loading) return <p>Loading...</p>;
if (!note) return <p>Note not found.</p>;
return (
<div className="note-detail">
<h1>{note.title}</h1>
<div className="note-meta">
<span className="badge">{note.sourceType}</span>
<span>{note.subject}</span>
<span>{note.wordCount} words</span>
</div>
<div className="note-content">
<p>{note.content}</p>
</div>
<div className="note-actions">
<button disabled>Summarize</button>
<button disabled>Generate Quiz</button>
<button disabled>Generate Flashcards</button>
<button onClick={handleDelete} className="btn-delete">Delete</button>
</div>
</div>
);
}
export default NoteDetail;
Why are the AI buttons disabled?
The Summarize, Generate Quiz, and Generate Flashcards buttons are placeholders for now. You will wire them up in the next steps when you integrate the OpenAI API.
- Update client/src/App.jsx to add the new routes. Import the new pages at the top and add ProtectedRoute-wrapped routes for /notes, /notes/upload, and /notes/:id:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/common/ProtectedRoute';
import Landing from './pages/Landing';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Notes from './pages/Notes';
import NoteDetail from './pages/NoteDetail';
import UploadZone from './components/notes/UploadZone';
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/notes" element={<ProtectedRoute><Notes /></ProtectedRoute>} />
<Route path="/notes/upload" element={<ProtectedRoute><UploadZone /></ProtectedRoute>} />
<Route path="/notes/:id" element={<ProtectedRoute><NoteDetail /></ProtectedRoute>} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
export default App;
- Save all frontend files.
- In your browser, navigate to /notes. You should see the empty state message since you have no notes yet.
- Navigate to /notes/upload and drag a PDF onto the upload zone. After uploading, navigate back to /notes and you should see your new note card.
Upload succeeds but the note shows no content?
Some PDFs contain only scanned images with no embedded text. The pdf-parse library can only extract embedded text, not perform OCR. Try uploading a text-based PDF (like one exported from Google Docs or Word).
Help me troubleshoot why my uploaded PDF has empty content in the note.
✔️ Awesome, I've got everything!
Great. Make sure both your frontend and backend files are saved.
ⓧ I'd like to double check the full code
import { Link } from 'react-router-dom';
// Displays a single note as a clickable card
function NoteCard({ note }) {
return (
<Link to={`/notes/${note._id}`} className="note-card">
<h3>{note.title}</h3>
<span className="badge">{note.sourceType}</span>
<p className="subject">{note.subject}</p>
<p className="meta">{note.wordCount} words</p>
<p className="date">{new Date(note.createdAt).toLocaleDateString()}</p>
</Link>
);
}
export default NoteCard;
import NoteCard from './NoteCard';
// Grid layout for note cards with an empty state fallback
function NoteList({ notes }) {
if (notes.length === 0) {
return <p className="empty-state">No notes yet. Upload a PDF or create a note to get started.</p>;
}
return (
<div className="note-grid">
{notes.map((note) => (
<NoteCard key={note._id} note={note} />
))}
</div>
);
}
export default NoteList;
import { useState, useCallback } from 'react';
import api from '../../services/api';
// Drag-and-drop file upload for PDF files (10MB max)
function UploadZone({ onUploadSuccess }) {
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [progress, setProgress] = useState(0);
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragging(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
}, []);
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) uploadFile(file);
};
const uploadFile = async (file) => {
// Validate file type
if (file.type !== 'application/pdf') {
setError('Only PDF files are allowed.');
return;
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setError('File must be under 10MB.');
return;
}
setError('');
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
const response = await api.post('/notes/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
setProgress(Math.round((e.loaded * 100) / e.total));
}
});
setUploading(false);
setProgress(100);
if (onUploadSuccess) onUploadSuccess(response.data);
} catch (err) {
setUploading(false);
setError(err.response?.data?.message || 'Upload failed.');
}
};
return (
<div
className={`upload-zone ${dragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<p>Drag and drop a PDF here, or click to select a file.</p>
<input
type="file"
accept="application/pdf"
onChange={handleFileSelect}
disabled={uploading}
/>
{uploading && <p>Uploading... {progress}%</p>}
{error && <p className="error">{error}</p>}
</div>
);
}
export default UploadZone;
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../services/api';
import NoteList from '../components/notes/NoteList';
// Main notes page with search and subject filtering
function Notes() {
const [notes, setNotes] = useState([]);
const [search, setSearch] = useState('');
const [subjectFilter, setSubjectFilter] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNotes();
}, []);
const fetchNotes = async () => {
try {
const response = await api.get('/notes');
setNotes(response.data);
} catch (err) {
console.error('Failed to fetch notes:', err);
} finally {
setLoading(false);
}
};
// Build unique subject list for the filter dropdown
const subjects = [...new Set(notes.map((n) => n.subject))];
// Filter notes by search term and selected subject
const filtered = notes.filter((note) => {
const matchesSearch = note.title.toLowerCase().includes(search.toLowerCase());
const matchesSubject = !subjectFilter || note.subject === subjectFilter;
return matchesSearch && matchesSubject;
});
if (loading) return <p>Loading notes...</p>;
return (
<div className="notes-page">
<div className="notes-header">
<h1>My Notes</h1>
<Link to="/notes/upload" className="btn-upload">Upload PDF</Link>
</div>
<div className="notes-filters">
<input
type="text"
placeholder="Search notes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select value={subjectFilter} onChange={(e) => setSubjectFilter(e.target.value)}>
<option value="">All Subjects</option>
{subjects.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<NoteList notes={filtered} />
</div>
);
}
export default Notes;
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../services/api';
// Displays a single note with AI action placeholders
function NoteDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [note, setNote] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNote();
}, [id]);
const fetchNote = async () => {
try {
const response = await api.get(`/notes/${id}`);
setNote(response.data);
} catch (err) {
console.error('Failed to fetch note:', err);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!window.confirm('Are you sure you want to delete this note?')) return;
try {
await api.delete(`/notes/${id}`);
navigate('/notes');
} catch (err) {
console.error('Failed to delete note:', err);
}
};
if (loading) return <p>Loading...</p>;
if (!note) return <p>Note not found.</p>;
return (
<div className="note-detail">
<h1>{note.title}</h1>
<div className="note-meta">
<span className="badge">{note.sourceType}</span>
<span>{note.subject}</span>
<span>{note.wordCount} words</span>
</div>
<div className="note-content">
<p>{note.content}</p>
</div>
<div className="note-actions">
<button disabled>Summarize</button>
<button disabled>Generate Quiz</button>
<button disabled>Generate Flashcards</button>
<button onClick={handleDelete} className="btn-delete">Delete</button>
</div>
</div>
);
}
export default NoteDetail;
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/common/ProtectedRoute';
import Landing from './pages/Landing';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Notes from './pages/Notes';
import NoteDetail from './pages/NoteDetail';
import UploadZone from './components/notes/UploadZone';
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/notes" element={<ProtectedRoute><Notes /></ProtectedRoute>} />
<Route path="/notes/upload" element={<ProtectedRoute><UploadZone /></ProtectedRoute>} />
<Route path="/notes/:id" element={<ProtectedRoute><NoteDetail /></ProtectedRoute>} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
export default App;
Your notes system is fully wired. Users can upload PDFs, create text notes, search and filter their collection, and view or delete individual notes. Next up, you will connect the OpenAI API to generate intelligent summaries from note content.
Integrate AI-Powered Content Generation
Your notes system is fully functional. Users can upload PDFs, view extracted text, and manage their notes. The next challenge is turning that raw text into useful study materials automatically.
In this step, you will integrate OpenAI's GPT-4o mini model to generate summaries, quizzes, and flashcards from note content. You will also build the frontend interfaces for taking quizzes and studying flashcards.
In this step, get ready to:
- Build the AI service layer with structured prompts and OpenAI integration.
- Create backend routes for AI generation, quizzes, and flashcards.
- Build frontend components for quiz-taking, flashcard study mode, and AI action buttons.
Build the AI service layer
The AI service layer sits between your routes and the OpenAI API. It defines the prompts that shape the AI's output and handles the API communication.
- Install the OpenAI Node.js SDK by running this command in the server directory:
npm install openai
What is this package?
The openai package is the official Node.js SDK for the OpenAI API. It handles authentication, request formatting, and response parsing for you.
- Verify the install succeeded by checking that openai appears in your server/package.json under dependencies.
npm install failed?
Make sure you ran the command inside the server directory, not the project root. Run cd server first if needed.
help me fix an npm install error in my Express server project
- Create the prompt templates file at server/utils/prompts.js by adding the following code:
// Prompt template for generating structured summaries
const summaryPrompt = (content) => [
{
role: 'system',
content: 'You are a study assistant that creates concise, structured summaries. Return your response as JSON with the format: {"content": "summary text", "keyPoints": ["point1", "point2", ...]}'
},
{
role: 'user',
content: `Summarize the following study material into a clear, concise summary with key points:\n\n${content}`
}
];
// Prompt template for generating multiple-choice quiz questions
const quizPrompt = (content) => [
{
role: 'system',
content: 'You are a study assistant that creates multiple-choice quizzes. Return your response as JSON with the format: {"title": "Quiz Title", "questions": [{"question": "...", "options": ["A", "B", "C", "D"], "correctAnswer": 0, "explanation": "..."}]}. Generate 5-10 questions.'
},
{
role: 'user',
content: `Create a multiple-choice quiz based on the following study material:\n\n${content}`
}
];
// Prompt template for generating flashcard question/answer pairs
const flashcardsPrompt = (content) => [
{
role: 'system',
content: 'You are a study assistant that creates flashcards. Return your response as JSON with the format: {"deckTitle": "Deck Title", "cards": [{"front": "question", "back": "answer"}]}. Generate 10-20 flashcards.'
},
{
role: 'user',
content: `Create flashcards based on the following study material:\n\n${content}`
}
];
module.exports = { summaryPrompt, quizPrompt, flashcardsPrompt };
What does this code do?
Each function returns an array of messages for the OpenAI Chat Completions API. The system message instructs the model to return structured JSON in a specific format. The user message provides the note content to transform.
By requesting JSON output with explicit field names, you ensure the AI response can be reliably parsed into your database schema.
- Create the OpenAI service file at server/services/openai.js with the following code:
const OpenAI = require('openai');
const { summaryPrompt, quizPrompt, flashcardsPrompt } = require('../utils/prompts');
// Initialize the OpenAI client with API key from environment
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
// Generate a structured summary from note content
const generateSummary = async (content) => {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: summaryPrompt(content),
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
};
// Generate a multiple-choice quiz from note content
const generateQuiz = async (content) => {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: quizPrompt(content),
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
};
// Generate flashcard pairs from note content
const generateFlashcards = async (content) => {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: flashcardsPrompt(content),
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
};
module.exports = { generateSummary, generateQuiz, generateFlashcards };
What does this code do?
This service initializes the OpenAI client using the OPENAI_API_KEY environment variable you set up earlier.
Each function calls chat.completions.create with the gpt-4o-mini model and sets response_format: { type: 'json_object' } to enable JSON mode. This guarantees the model returns valid JSON that you can safely parse.
Why GPT-4o mini instead of Gemini?
GPT-4o mini costs $0.15 per million input tokens and $0.60 per million output tokens. For a study app generating short outputs, this means hundreds of generations for pennies.
The JSON mode feature ensures reliable structured output. This matters because your app needs to parse quiz questions and flashcard pairs programmatically without risking malformed responses.
- Save both server/utils/prompts.js and server/services/openai.js.
Create the AI, quiz, and flashcard API routes
With the AI service ready, you need routes that fetch a note, pass its content to the AI, and save the generated result as a new document.
- Create the AI routes file at server/routes/ai.js with the following code:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Note = require('../models/Note');
const Summary = require('../models/Summary');
const Quiz = require('../models/Quiz');
const Flashcard = require('../models/Flashcard');
const { generateSummary, generateQuiz, generateFlashcards } = require('../services/openai');
// Generate a summary for a specific note
router.post('/summarize/:noteId', auth, async (req, res) => {
try {
const note = await Note.findOne({ _id: req.params.noteId, userId: req.user.id });
if (!note) return res.status(404).json({ message: 'Note not found' });
const result = await generateSummary(note.content);
// Save the summary document linked to this note
const summary = new Summary({
noteId: note._id,
userId: req.user.id,
content: result.content,
keyPoints: result.keyPoints,
generatedAt: new Date()
});
await summary.save();
res.status(201).json(summary);
} catch (error) {
res.status(500).json({ message: 'Failed to generate summary', error: error.message });
}
});
// Generate a quiz for a specific note
router.post('/quiz/:noteId', auth, async (req, res) => {
try {
const note = await Note.findOne({ _id: req.params.noteId, userId: req.user.id });
if (!note) return res.status(404).json({ message: 'Note not found' });
const result = await generateQuiz(note.content);
// Save the quiz document with generated questions
const quiz = new Quiz({
noteId: note._id,
userId: req.user.id,
title: result.title,
questions: result.questions,
generatedAt: new Date()
});
await quiz.save();
res.status(201).json(quiz);
} catch (error) {
res.status(500).json({ message: 'Failed to generate quiz', error: error.message });
}
});
// Generate flashcards for a specific note
router.post('/flashcards/:noteId', auth, async (req, res) => {
try {
const note = await Note.findOne({ _id: req.params.noteId, userId: req.user.id });
if (!note) return res.status(404).json({ message: 'Note not found' });
const result = await generateFlashcards(note.content);
// Save flashcard deck with default spaced repetition fields
const flashcard = new Flashcard({
noteId: note._id,
userId: req.user.id,
deckTitle: result.deckTitle,
cards: result.cards.map(card => ({
front: card.front,
back: card.back,
status: 'new',
nextReviewDate: new Date(),
easinessFactor: 2.5,
repetitions: 0,
interval: 0
})),
generatedAt: new Date()
});
await flashcard.save();
res.status(201).json(flashcard);
} catch (error) {
res.status(500).json({ message: 'Failed to generate flashcards', error: error.message });
}
});
module.exports = router;
What does this code do?
Each route follows the same pattern: verify the user owns the note, call the AI service, save the result as a new document, and return it.
The flashcards route also initializes each card with default SM-2 spaced repetition fields (easinessFactor: 2.5, repetitions: 0, interval: 0). These prepare the cards for the spaced repetition system you will build in the Secret Mission.
- Create the quizzes routes file at server/routes/quizzes.js with the following code:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Quiz = require('../models/Quiz');
const QuizAttempt = require('../models/QuizAttempt');
// Get all quizzes for the authenticated user
router.get('/', auth, async (req, res) => {
try {
const quizzes = await Quiz.find({ userId: req.user.id }).sort({ generatedAt: -1 });
res.json(quizzes);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch quizzes' });
}
});
// Get a single quiz by ID
router.get('/:id', auth, async (req, res) => {
try {
const quiz = await Quiz.findOne({ _id: req.params.id, userId: req.user.id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
res.json(quiz);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch quiz' });
}
});
// Submit a quiz attempt with answers and calculate score
router.post('/:id/attempt', auth, async (req, res) => {
try {
const quiz = await Quiz.findOne({ _id: req.params.id, userId: req.user.id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const { answers, timeTaken } = req.body;
let score = 0;
answers.forEach((answer, index) => {
if (answer === quiz.questions[index].correctAnswer) score++;
});
// Save the attempt with calculated score
const attempt = new QuizAttempt({
quizId: quiz._id,
userId: req.user.id,
score,
totalQuestions: quiz.questions.length,
answers,
completedAt: new Date(),
timeTaken
});
await attempt.save();
res.status(201).json(attempt);
} catch (error) {
res.status(500).json({ message: 'Failed to submit attempt' });
}
});
// Get attempt history for a specific quiz
router.get('/:id/attempts', auth, async (req, res) => {
try {
const attempts = await QuizAttempt.find({ quizId: req.params.id, userId: req.user.id })
.sort({ completedAt: -1 });
res.json(attempts);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch attempts' });
}
});
module.exports = router;
What does this code do?
The POST /:id/attempt route is the key endpoint. It receives the user's answers array, compares each answer index against the quiz's correctAnswer field, and calculates a score. The attempt is saved so the analytics dashboard can track score trends over time.
- Create the flashcards routes file at server/routes/flashcards.js with the following code:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Flashcard = require('../models/Flashcard');
// Get all flashcard decks for the authenticated user
router.get('/', auth, async (req, res) => {
try {
const decks = await Flashcard.find({ userId: req.user.id }).sort({ generatedAt: -1 });
res.json(decks);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch flashcard decks' });
}
});
// Get a single flashcard deck by ID
router.get('/:id', auth, async (req, res) => {
try {
const deck = await Flashcard.findOne({ _id: req.params.id, userId: req.user.id });
if (!deck) return res.status(404).json({ message: 'Deck not found' });
res.json(deck);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch deck' });
}
});
// Update a specific card's status within a deck
router.put('/:id/cards/:cardIndex', auth, async (req, res) => {
try {
const deck = await Flashcard.findOne({ _id: req.params.id, userId: req.user.id });
if (!deck) return res.status(404).json({ message: 'Deck not found' });
const cardIndex = parseInt(req.params.cardIndex);
if (cardIndex < 0 || cardIndex >= deck.cards.length) {
return res.status(400).json({ message: 'Invalid card index' });
}
// Update the card's learning status
const { status } = req.body;
deck.cards[cardIndex].status = status;
await deck.save();
res.json(deck);
} catch (error) {
res.status(500).json({ message: 'Failed to update card' });
}
});
module.exports = router;
What does this code do?
The PUT /:id/cards/:cardIndex route lets the study mode UI mark individual cards as known or learning. It uses the card's index within the deck's cards array to target the correct card without needing a separate card ID.
- Mount all three new route files in server/server.js. Find the line where you mounted the notes routes, then add these lines below it:
// Mount AI generation routes
const aiRoutes = require('./routes/ai');
app.use('/api/ai', aiRoutes);
// Mount quiz routes
const quizRoutes = require('./routes/quizzes');
app.use('/api/quizzes', quizRoutes);
// Mount flashcard routes
const flashcardRoutes = require('./routes/flashcards');
app.use('/api/flashcards', flashcardRoutes);
- Save all route files and server.js.
- Restart your server. You should see no errors in the terminal output.
Server crashes on startup?
Check that all model imports match the filenames in server/models/ exactly. The most common issue is a capitalization mismatch between require('../models/Quiz') and the actual file name.
help me debug why my Express server crashes when I add the AI routes
Build the frontend AI features
The backend is ready to generate AI content. Now you need the React components that let users trigger generation from their notes and interact with the results.
- Create the quiz question component at client/src/components/quiz/QuizQuestion.jsx:
import React from 'react';
// Single quiz question with clickable option buttons
function QuizQuestion({ question, questionIndex, selectedAnswer, onSelectAnswer, showFeedback }) {
return (
<div className="quiz-question">
<h3>Question {questionIndex + 1}</h3>
<p>{question.question}</p>
<div className="options">
{question.options.map((option, index) => {
// Determine styling based on answer state
let className = 'option-btn';
if (showFeedback && index === question.correctAnswer) className += ' correct';
if (showFeedback && index === selectedAnswer && index !== question.correctAnswer) className += ' incorrect';
if (!showFeedback && index === selectedAnswer) className += ' selected';
return (
<button
key={index}
className={className}
onClick={() => onSelectAnswer(index)}
disabled={showFeedback}
>
{option}
</button>
);
})}
</div>
{/* Show explanation after answering */}
{showFeedback && question.explanation && (
<p className="explanation">{question.explanation}</p>
)}
</div>
);
}
export default QuizQuestion;
- Create the quiz results component at client/src/components/quiz/QuizResults.jsx:
import React from 'react';
// Results display showing score and per-question breakdown
function QuizResults({ quiz, answers, score }) {
const percentage = Math.round((score / quiz.questions.length) * 100);
return (
<div className="quiz-results">
<h2>Quiz Complete</h2>
<div className="score-display">
<p className="score">{score} / {quiz.questions.length}</p>
<p className="percentage">{percentage}%</p>
</div>
{/* Per-question breakdown with correct answers */}
<div className="breakdown">
<h3>Question Breakdown</h3>
{quiz.questions.map((q, index) => (
<div key={index} className={`result-item ${answers[index] === q.correctAnswer ? 'correct' : 'incorrect'}`}>
<p><strong>Q{index + 1}:</strong> {q.question}</p>
<p>Your answer: {q.options[answers[index]]}</p>
<p>Correct answer: {q.options[q.correctAnswer]}</p>
{q.explanation && <p className="explanation">{q.explanation}</p>}
</div>
))}
</div>
</div>
);
}
export default QuizResults;
What do these components do?
QuizQuestion renders a single question with clickable option buttons. When showFeedback is true, it highlights the correct answer in green and any wrong selection in red.
QuizResults calculates the percentage score and displays a per-question breakdown showing the user's answer, the correct answer, and the explanation for each question.
- Create the flashcard component at client/src/components/flashcards/FlashCard.jsx:
import React, { useState } from 'react';
// Single flashcard with CSS flip animation on click
function FlashCard({ card }) {
const [flipped, setFlipped] = useState(false);
return (
<div
className={`flashcard ${flipped ? 'flipped' : ''}`}
onClick={() => setFlipped(!flipped)}
>
<div className="flashcard-inner">
{/* Front face shows the question */}
<div className="flashcard-front">
<p>{card.front}</p>
<span className="flip-hint">Click to flip</span>
</div>
{/* Back face shows the answer */}
<div className="flashcard-back">
<p>{card.back}</p>
</div>
</div>
</div>
);
}
export default FlashCard;
- Create the study mode component at client/src/components/flashcards/StudyMode.jsx:
import React, { useState } from 'react';
import FlashCard from './FlashCard';
import api from '../../services/api';
// Flashcard study mode with navigation and status buttons
function StudyMode({ deck }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [cards, setCards] = useState(deck.cards);
// Mark a card as known or needs review
const handleMarkCard = async (status) => {
try {
await api.put(`/api/flashcards/${deck._id}/cards/${currentIndex}`, { status });
// Update local state
const updatedCards = [...cards];
updatedCards[currentIndex] = { ...updatedCards[currentIndex], status };
setCards(updatedCards);
// Move to next card
if (currentIndex < cards.length - 1) {
setCurrentIndex(currentIndex + 1);
}
} catch (error) {
console.error('Failed to update card status:', error);
}
};
// Navigation between cards
const goNext = () => {
if (currentIndex < cards.length - 1) setCurrentIndex(currentIndex + 1);
};
const goPrev = () => {
if (currentIndex > 0) setCurrentIndex(currentIndex - 1);
};
return (
<div className="study-mode">
<div className="progress">
Card {currentIndex + 1} of {cards.length}
</div>
<FlashCard card={cards[currentIndex]} />
{/* Navigation controls */}
<div className="controls">
<button onClick={goPrev} disabled={currentIndex === 0}>Previous</button>
<button onClick={goNext} disabled={currentIndex === cards.length - 1}>Next</button>
</div>
{/* Status marking buttons */}
<div className="status-buttons">
<button className="needs-review" onClick={() => handleMarkCard('learning')}>
Needs Review
</button>
<button className="known" onClick={() => handleMarkCard('known')}>
Known
</button>
</div>
</div>
);
}
export default StudyMode;
What does this code do?
StudyMode manages navigation through the deck and sends PUT /api/flashcards/:id/cards/:cardIndex requests when the user marks a card. The local state updates immediately so the UI feels responsive without waiting for the server round-trip.
FlashCard uses a flipped state toggle and a CSS class to trigger a 3D flip animation, showing the question on front and answer on back.
- Create the Quiz page at client/src/pages/Quiz.jsx:
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../services/api';
import QuizQuestion from '../components/quiz/QuizQuestion';
import QuizResults from '../components/quiz/QuizResults';
// Interactive quiz-taking page with scoring
function Quiz() {
const { id } = useParams();
const navigate = useNavigate();
const [quiz, setQuiz] = useState(null);
const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState([]);
const [showFeedback, setShowFeedback] = useState(false);
const [completed, setCompleted] = useState(false);
const [score, setScore] = useState(0);
const [startTime] = useState(Date.now());
// Fetch the quiz data on mount
useEffect(() => {
const fetchQuiz = async () => {
try {
const response = await api.get(`/api/quizzes/${id}`);
setQuiz(response.data);
setAnswers(new Array(response.data.questions.length).fill(-1));
} catch (error) {
console.error('Failed to fetch quiz:', error);
}
};
fetchQuiz();
}, [id]);
// Handle answer selection
const handleSelectAnswer = (answerIndex) => {
const newAnswers = [...answers];
newAnswers[currentQuestion] = answerIndex;
setAnswers(newAnswers);
setShowFeedback(true);
};
// Advance to the next question or finish the quiz
const handleNext = async () => {
setShowFeedback(false);
if (currentQuestion < quiz.questions.length - 1) {
setCurrentQuestion(currentQuestion + 1);
} else {
// Calculate final score and submit attempt
let finalScore = 0;
answers.forEach((answer, index) => {
if (answer === quiz.questions[index].correctAnswer) finalScore++;
});
const timeTaken = Math.round((Date.now() - startTime) / 1000);
try {
await api.post(`/api/quizzes/${id}/attempt`, { answers, timeTaken });
} catch (error) {
console.error('Failed to submit attempt:', error);
}
setScore(finalScore);
setCompleted(true);
}
};
if (!quiz) return <div className="loading">Loading quiz...</div>;
// Show results after completing all questions
if (completed) {
return <QuizResults quiz={quiz} answers={answers} score={score} />;
}
return (
<div className="quiz-page">
<h1>{quiz.title}</h1>
<QuizQuestion
question={quiz.questions[currentQuestion]}
questionIndex={currentQuestion}
selectedAnswer={answers[currentQuestion]}
onSelectAnswer={handleSelectAnswer}
showFeedback={showFeedback}
/>
{showFeedback && (
<button className="next-btn" onClick={handleNext}>
{currentQuestion < quiz.questions.length - 1 ? 'Next Question' : 'Finish Quiz'}
</button>
)}
</div>
);
}
export default Quiz;
- Create the Flashcards listing page at client/src/pages/Flashcards.jsx:
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../services/api';
// Flashcard decks list page
function Flashcards() {
const [decks, setDecks] = useState([]);
const [loading, setLoading] = useState(true);
// Fetch all flashcard decks on mount
useEffect(() => {
const fetchDecks = async () => {
try {
const response = await api.get('/api/flashcards');
setDecks(response.data);
} catch (error) {
console.error('Failed to fetch decks:', error);
} finally {
setLoading(false);
}
};
fetchDecks();
}, []);
if (loading) return <div className="loading">Loading flashcards...</div>;
return (
<div className="flashcards-page">
<h1>Flashcard Decks</h1>
{decks.length === 0 ? (
<p>No flashcard decks yet. Generate some from your notes.</p>
) : (
<div className="deck-grid">
{decks.map(deck => (
<div key={deck._id} className="deck-card">
<h3>{deck.deckTitle}</h3>
<p>{deck.cards.length} cards</p>
<Link to={`/flashcards/${deck._id}/study`}>Study Now</Link>
</div>
))}
</div>
)}
</div>
);
}
export default Flashcards;
What does the Flashcards page do?
The Flashcards page lists all decks the user has generated. Each deck card shows the title and card count, with a link to /flashcards/:id/study which renders the StudyMode component.
- Update client/src/pages/NoteDetail.jsx to make the AI action buttons functional. Find the placeholder AI action buttons and replace them with this implementation:
// AI action button state
const [generating, setGenerating] = useState(null);
// Generate AI content and navigate to result
const handleGenerate = async (type) => {
setGenerating(type);
try {
const response = await api.post(`/api/ai/${type}/${id}`);
// Navigate to the generated resource
if (type === 'summarize') {
setSummary(response.data);
} else if (type === 'quiz') {
navigate(`/quiz/${response.data._id}`);
} else if (type === 'flashcards') {
navigate(`/flashcards/${response.data._id}/study`);
}
} catch (error) {
console.error(`Failed to generate ${type}:`, error);
} finally {
setGenerating(null);
}
};
What does this code do?
The generating state tracks which AI action is in progress so you can show a loading spinner on the active button. After successful generation, the user is navigated to the quiz or flashcard study view.
Summaries are displayed inline on the same page since they are short text content, while quizzes and flashcards navigate to dedicated interactive pages.
- Update client/src/App.jsx to add routes for the new pages. Add these imports and route definitions alongside your existing routes:
// Quiz and flashcard routes
import Quiz from './pages/Quiz';
import Flashcards from './pages/Flashcards';
import StudyMode from './components/flashcards/StudyMode';
// Add inside your Route definitions:
<Route path="/quiz/:id" element={<ProtectedRoute><Quiz /></ProtectedRoute>} />
<Route path="/flashcards" element={<ProtectedRoute><Flashcards /></ProtectedRoute>} />
<Route path="/flashcards/:id/study" element={<ProtectedRoute><StudyModePage /></ProtectedRoute>} />
- Save all frontend files.
- Verify your dev server recompiles without errors. You should see no red errors in the terminal running your React dev server.
Import errors in the browser?
Make sure the file paths in your imports match the exact directory structure. Common mistakes include ./components/quiz/QuizQuestion vs ./components/Quiz/QuizQuestion. Case sensitivity matters on Linux and macOS.
help me fix import path errors in my React app
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in both the server and client directories.
ⓧ I'd like to double check the full code
Here are the key new files for this step.
// server/utils/prompts.js
const summaryPrompt = (content) => [
{
role: 'system',
content: 'You are a study assistant that creates concise, structured summaries. Return your response as JSON with the format: {"content": "summary text", "keyPoints": ["point1", "point2", ...]}'
},
{
role: 'user',
content: `Summarize the following study material into a clear, concise summary with key points:\n\n${content}`
}
];
const quizPrompt = (content) => [
{
role: 'system',
content: 'You are a study assistant that creates multiple-choice quizzes. Return your response as JSON with the format: {"title": "Quiz Title", "questions": [{"question": "...", "options": ["A", "B", "C", "D"], "correctAnswer": 0, "explanation": "..."}]}. Generate 5-10 questions.'
},
{
role: 'user',
content: `Create a multiple-choice quiz based on the following study material:\n\n${content}`
}
];
const flashcardsPrompt = (content) => [
{
role: 'system',
content: 'You are a study assistant that creates flashcards. Return your response as JSON with the format: {"deckTitle": "Deck Title", "cards": [{"front": "question", "back": "answer"}]}. Generate 10-20 flashcards.'
},
{
role: 'user',
content: `Create flashcards based on the following study material:\n\n${content}`
}
];
module.exports = { summaryPrompt, quizPrompt, flashcardsPrompt };
// server/services/openai.js
const OpenAI = require('openai');
const { summaryPrompt, quizPrompt, flashcardsPrompt } = require('../utils/prompts');
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
const generateSummary = async (content) => {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: summaryPrompt(content),
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
};
const generateQuiz = async (content) => {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: quizPrompt(content),
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
};
const generateFlashcards = async (content) => {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: flashcardsPrompt(content),
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
};
module.exports = { generateSummary, generateQuiz, generateFlashcards };
// server/routes/ai.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Note = require('../models/Note');
const Summary = require('../models/Summary');
const Quiz = require('../models/Quiz');
const Flashcard = require('../models/Flashcard');
const { generateSummary, generateQuiz, generateFlashcards } = require('../services/openai');
router.post('/summarize/:noteId', auth, async (req, res) => {
try {
const note = await Note.findOne({ _id: req.params.noteId, userId: req.user.id });
if (!note) return res.status(404).json({ message: 'Note not found' });
const result = await generateSummary(note.content);
const summary = new Summary({
noteId: note._id,
userId: req.user.id,
content: result.content,
keyPoints: result.keyPoints,
generatedAt: new Date()
});
await summary.save();
res.status(201).json(summary);
} catch (error) {
res.status(500).json({ message: 'Failed to generate summary', error: error.message });
}
});
router.post('/quiz/:noteId', auth, async (req, res) => {
try {
const note = await Note.findOne({ _id: req.params.noteId, userId: req.user.id });
if (!note) return res.status(404).json({ message: 'Note not found' });
const result = await generateQuiz(note.content);
const quiz = new Quiz({
noteId: note._id,
userId: req.user.id,
title: result.title,
questions: result.questions,
generatedAt: new Date()
});
await quiz.save();
res.status(201).json(quiz);
} catch (error) {
res.status(500).json({ message: 'Failed to generate quiz', error: error.message });
}
});
router.post('/flashcards/:noteId', auth, async (req, res) => {
try {
const note = await Note.findOne({ _id: req.params.noteId, userId: req.user.id });
if (!note) return res.status(404).json({ message: 'Note not found' });
const result = await generateFlashcards(note.content);
const flashcard = new Flashcard({
noteId: note._id,
userId: req.user.id,
deckTitle: result.deckTitle,
cards: result.cards.map(card => ({
front: card.front,
back: card.back,
status: 'new',
nextReviewDate: new Date(),
easinessFactor: 2.5,
repetitions: 0,
interval: 0
})),
generatedAt: new Date()
});
await flashcard.save();
res.status(201).json(flashcard);
} catch (error) {
res.status(500).json({ message: 'Failed to generate flashcards', error: error.message });
}
});
module.exports = router;
// server/routes/quizzes.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Quiz = require('../models/Quiz');
const QuizAttempt = require('../models/QuizAttempt');
router.get('/', auth, async (req, res) => {
try {
const quizzes = await Quiz.find({ userId: req.user.id }).sort({ generatedAt: -1 });
res.json(quizzes);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch quizzes' });
}
});
router.get('/:id', auth, async (req, res) => {
try {
const quiz = await Quiz.findOne({ _id: req.params.id, userId: req.user.id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
res.json(quiz);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch quiz' });
}
});
router.post('/:id/attempt', auth, async (req, res) => {
try {
const quiz = await Quiz.findOne({ _id: req.params.id, userId: req.user.id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const { answers, timeTaken } = req.body;
let score = 0;
answers.forEach((answer, index) => {
if (answer === quiz.questions[index].correctAnswer) score++;
});
const attempt = new QuizAttempt({
quizId: quiz._id,
userId: req.user.id,
score,
totalQuestions: quiz.questions.length,
answers,
completedAt: new Date(),
timeTaken
});
await attempt.save();
res.status(201).json(attempt);
} catch (error) {
res.status(500).json({ message: 'Failed to submit attempt' });
}
});
router.get('/:id/attempts', auth, async (req, res) => {
try {
const attempts = await QuizAttempt.find({ quizId: req.params.id, userId: req.user.id })
.sort({ completedAt: -1 });
res.json(attempts);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch attempts' });
}
});
module.exports = router;
// server/routes/flashcards.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Flashcard = require('../models/Flashcard');
router.get('/', auth, async (req, res) => {
try {
const decks = await Flashcard.find({ userId: req.user.id }).sort({ generatedAt: -1 });
res.json(decks);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch flashcard decks' });
}
});
router.get('/:id', auth, async (req, res) => {
try {
const deck = await Flashcard.findOne({ _id: req.params.id, userId: req.user.id });
if (!deck) return res.status(404).json({ message: 'Deck not found' });
res.json(deck);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch deck' });
}
});
router.put('/:id/cards/:cardIndex', auth, async (req, res) => {
try {
const deck = await Flashcard.findOne({ _id: req.params.id, userId: req.user.id });
if (!deck) return res.status(404).json({ message: 'Deck not found' });
const cardIndex = parseInt(req.params.cardIndex);
if (cardIndex < 0 || cardIndex >= deck.cards.length) {
return res.status(400).json({ message: 'Invalid card index' });
}
const { status } = req.body;
deck.cards[cardIndex].status = status;
await deck.save();
res.json(deck);
} catch (error) {
res.status(500).json({ message: 'Failed to update card' });
}
});
module.exports = router;
Your AI-powered content generation is live. Users can now transform their uploaded notes into interactive study materials with a single click. Next up, you will build the analytics dashboard to track study progress over time.
Build the Analytics Dashboard
Your AI Study Assistant can now generate summaries, quizzes, and flashcards. But how does a student know if they're actually learning?
In this step, you'll build a progress tracking system that logs every study session and visualizes trends over time. You'll create analytics API endpoints that aggregate data from quizzes and flashcard sessions, then render interactive charts on a dedicated analytics page using Recharts.
In this step, get ready to:
- Create study session logging and analytics API endpoints.
- Build frontend analytics components with interactive charts.
- Connect quiz and flashcard completion to session tracking.
Create the analytics API routes
The analytics system needs endpoints to both record study sessions and serve aggregated data back to the frontend for visualization.
- Create a new file at server/routes/analytics.js and add the following route handlers:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const StudySession = require('../models/StudySession');
const Note = require('../models/Note');
const QuizAttempt = require('../models/QuizAttempt');
const Flashcard = require('../models/Flashcard');
const User = require('../models/User');
// GET /overview - aggregate dashboard stats
router.get('/overview', auth, async (req, res) => {
try {
const totalNotes = await Note.countDocuments({ userId: req.user.id });
const quizzesTaken = await QuizAttempt.countDocuments({ userId: req.user.id });
// Sum all flashcards reviewed across study sessions
const flashcardsReviewed = await StudySession.aggregate([
{ $match: { userId: req.user._id, type: 'flashcard' } },
{ $group: { _id: null, total: { $sum: '$cardsReviewed' } } }
]);
const user = await User.findById(req.user.id);
res.json({
totalNotes,
quizzesTaken,
flashcardsReviewed: flashcardsReviewed[0]?.total || 0,
studyStreak: user.studyStreak || 0
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// GET /study-time - daily aggregated duration for charts
router.get('/study-time', auth, async (req, res) => {
try {
const sessions = await StudySession.aggregate([
{ $match: { userId: req.user._id } },
{ $group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$date' } },
totalDuration: { $sum: '$duration' }
}},
{ $sort: { _id: 1 } },
{ $limit: 30 }
]);
res.json(sessions.map(s => ({
date: s._id,
duration: Math.round(s.totalDuration / 60)
})));
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// GET /quiz-scores - score trends over time
router.get('/quiz-scores', auth, async (req, res) => {
try {
const attempts = await QuizAttempt.find({ userId: req.user.id })
.sort({ completedAt: 1 })
.limit(20)
.select('score totalQuestions completedAt');
res.json(attempts.map(a => ({
date: a.completedAt,
score: Math.round((a.score / a.totalQuestions) * 100)
})));
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// GET /progress - overall progress metrics
router.get('/progress', auth, async (req, res) => {
try {
// Calculate flashcard mastery percentage
const flashcards = await Flashcard.find({ userId: req.user.id });
let totalCards = 0;
let knownCards = 0;
flashcards.forEach(deck => {
deck.cards.forEach(card => {
totalCards++;
if (card.status === 'known') knownCards++;
});
});
// Calculate quiz average score
const quizAvg = await QuizAttempt.aggregate([
{ $match: { userId: req.user._id } },
{ $group: {
_id: null,
avgScore: { $avg: { $multiply: [{ $divide: ['$score', '$totalQuestions'] }, 100] } }
}}
]);
// Calculate total study time in minutes
const totalTime = await StudySession.aggregate([
{ $match: { userId: req.user._id } },
{ $group: { _id: null, total: { $sum: '$duration' } } }
]);
res.json({
flashcardMastery: totalCards > 0 ? Math.round((knownCards / totalCards) * 100) : 0,
quizAverageScore: Math.round(quizAvg[0]?.avgScore || 0),
totalStudyTime: Math.round((totalTime[0]?.total || 0) / 60)
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// POST /session - log a study session and update streak
router.post('/session', auth, async (req, res) => {
try {
const { type, resourceId, duration, cardsReviewed, score } = req.body;
const session = new StudySession({
userId: req.user.id,
type,
resourceId,
duration,
cardsReviewed,
score,
date: new Date()
});
await session.save();
// Update the user's study streak
const user = await User.findById(req.user.id);
const today = new Date().toDateString();
const lastStudy = user.lastStudyDate ? user.lastStudyDate.toDateString() : null;
if (lastStudy !== today) {
const yesterday = new Date(Date.now() - 86400000).toDateString();
if (lastStudy === yesterday) {
user.studyStreak = (user.studyStreak || 0) + 1;
} else {
user.studyStreak = 1;
}
user.lastStudyDate = new Date();
await user.save();
}
res.status(201).json(session);
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router;
What does this code do?
This router file handles all analytics-related operations for the study assistant.
- GET /overview aggregates high-level stats: total notes, quizzes taken, flashcards reviewed, and the current study streak.
- GET /study-time uses MongoDB's aggregation pipeline to group sessions by date and sum durations, returning the last 30 days of data in minutes.
- GET /quiz-scores returns the 20 most recent quiz attempts with scores as percentages, sorted chronologically for trend visualization.
- GET /progress calculates flashcard mastery (percentage of cards marked "known"), average quiz score, and total study time.
- POST /session saves a new StudySession document and updates the user's study streak by comparing today's date to their last study date.
- Save server/routes/analytics.js.
- Now mount this router in your Express app. In server/server.js, find where your other routes are mounted (below the flashcards route), and add this line:
// Mount analytics routes
const analyticsRoutes = require('./routes/analytics');
app.use('/api/analytics', analyticsRoutes);
- Save server/server.js.
- Restart your server. You should see no errors in the terminal.
Server won't start?
- Check that the require path matches your file location exactly. It should be ./routes/analytics relative to server.js.
- Make sure StudySession and all other model imports at the top of analytics.js match the filenames in your models/ folder.
Why is my analytics route throwing a module not found error?
Build the analytics frontend with Recharts
With your API ready, you need a charting library to visualize the data. Recharts is a React-based chart library built on D3 that lets you compose charts from declarative components.
- Install Recharts in your client project by running this command from the client/ directory:
npm install recharts react-is
Why react-is?
Recharts requires react-is as a peer dependency, and its version must match your installed React version. Installing it alongside Recharts ensures the library renders correctly.
- Create a new file at client/src/components/analytics/StatsCard.jsx with this component:
// Displays a single metric with label and value
function StatsCard({ label, value, trend }) {
return (
<div className="stats-card">
<p className="stats-label">{label}</p>
<h3 className="stats-value">{value}</h3>
{trend && <span className={`stats-trend ${trend > 0 ? 'positive' : 'negative'}`}>
{trend > 0 ? '+' : ''}{trend}%
</span>}
</div>
);
}
export default StatsCard;
What does this component do?
StatsCard is a reusable card that shows a metric label (like "Total Notes"), its current value, and an optional trend indicator. The trend shows green for positive changes and red for negative ones.
- Save StatsCard.jsx.
- Create another file at client/src/components/analytics/ProgressBar.jsx:
// Shows a percentage completion bar with label
function ProgressBar({ label, percentage }) {
return (
<div className="progress-bar-container">
<div className="progress-bar-header">
<span>{label}</span>
<span>{percentage}%</span>
</div>
<div className="progress-bar-track">
<div
className="progress-bar-fill"
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
);
}
export default ProgressBar;
- Save ProgressBar.jsx.
- Now create the main Analytics page at client/src/pages/Analytics.jsx:
import { useState, useEffect } from 'react';
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from 'recharts';
import StatsCard from '../components/analytics/StatsCard';
import api from '../services/api';
function Analytics() {
const [overview, setOverview] = useState(null);
const [studyTime, setStudyTime] = useState([]);
const [quizScores, setQuizScores] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchAnalytics();
}, []);
// Fetch all analytics data in parallel
const fetchAnalytics = async () => {
try {
const [overviewRes, timeRes, scoresRes] = await Promise.all([
api.get('/api/analytics/overview'),
api.get('/api/analytics/study-time'),
api.get('/api/analytics/quiz-scores')
]);
setOverview(overviewRes.data);
setStudyTime(timeRes.data);
setQuizScores(scoresRes.data);
} catch (error) {
console.error('Failed to fetch analytics:', error);
} finally {
setLoading(false);
}
};
if (loading) return <div className="loading">Loading analytics...</div>;
return (
<div className="analytics-page">
<h1>Study Analytics</h1>
{/* Overview stats grid */}
<div className="stats-grid">
<StatsCard label="Total Notes" value={overview?.totalNotes || 0} />
<StatsCard label="Quizzes Taken" value={overview?.quizzesTaken || 0} />
<StatsCard label="Study Streak" value={`${overview?.studyStreak || 0} days`} />
<StatsCard label="Flashcards Reviewed" value={overview?.flashcardsReviewed || 0} />
</div>
{/* Quiz score trend line chart */}
<div className="chart-section">
<h2>Quiz Score Trends</h2>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={quizScores}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="score" stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
</div>
{/* Daily study time bar chart */}
<div className="chart-section">
<h2>Daily Study Time (minutes)</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={studyTime}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Bar dataKey="duration" fill="#82ca9d" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
export default Analytics;
What does this page do?
The Analytics page fetches three endpoints in parallel using Promise.all for fast loading. It renders a grid of StatsCard components for quick metrics, a LineChart showing quiz score trends over time, and a BarChart showing daily study duration in minutes.
Each chart is wrapped in ResponsiveContainer so it resizes fluidly with the browser window. The dataKey props tell Recharts which fields in your data array to plot.
- Save Analytics.jsx.
- Add the analytics route to your app. In client/src/App.jsx, import the Analytics page and add a route:
import Analytics from './pages/Analytics';
// Add inside your Routes:
<Route path="/analytics" element={<Analytics />} />
- Save App.jsx.
- In your browser, navigate to /analytics. You should see the stats grid and two chart areas (they will be empty until you log study sessions).
Charts not rendering?
- Verify recharts and react-is both appear in your client/package.json dependencies. If not, re-run the install command.
- Check that ResponsiveContainer has a parent element with a defined height. Without a height, SVG charts cannot render.
- Open your browser console and look for import errors. Ensure all Recharts components are imported from 'recharts' (not a sub-path).
Why are my Recharts charts showing as blank?
Log study sessions from quiz and flashcard completion
The analytics endpoints are ready, but they need data. You need to log a study session each time the learner completes a quiz or finishes a flashcard study session.
- In client/src/pages/Quiz.jsx, find the section where you POST the quiz attempt (after score calculation). Add a study session log immediately after the attempt is saved:
// Log the study session after saving the quiz attempt
await api.post('/api/analytics/session', {
type: 'quiz',
resourceId: quizId,
duration: timeTaken,
score: score
});
What does this addition do?
After the quiz attempt is saved, this call logs the session to the analytics system. It records the quiz type, which quiz was taken, how long the learner spent, and their score. This data powers the charts on the analytics page.
- Save Quiz.jsx.
- In client/src/components/flashcards/StudyMode.jsx, find where the study session ends (when the learner finishes all cards or clicks a "Done" button). Add a session log there:
// Log flashcard study session on completion
await api.post('/api/analytics/session', {
type: 'flashcard',
resourceId: deckId,
duration: sessionDuration,
cardsReviewed: cardsReviewed
});
- Save StudyMode.jsx.
- Now create the Dashboard page at client/src/pages/Dashboard.jsx that shows a quick overview when users first log in:
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import StatsCard from '../components/analytics/StatsCard';
import api from '../services/api';
function Dashboard() {
const [overview, setOverview] = useState(null);
const [recentNotes, setRecentNotes] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData();
}, []);
// Fetch overview stats and recent notes
const fetchDashboardData = async () => {
try {
const [overviewRes, notesRes] = await Promise.all([
api.get('/api/analytics/overview'),
api.get('/api/notes')
]);
setOverview(overviewRes.data);
setRecentNotes(notesRes.data.slice(0, 5));
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setLoading(false);
}
};
if (loading) return <div className="loading">Loading dashboard...</div>;
return (
<div className="dashboard-page">
<h1>Dashboard</h1>
{/* Overview stats */}
<div className="stats-grid">
<StatsCard label="Total Notes" value={overview?.totalNotes || 0} />
<StatsCard label="Quizzes Taken" value={overview?.quizzesTaken || 0} />
<StatsCard label="Study Streak" value={`${overview?.studyStreak || 0} days`} />
</div>
{/* Recent notes list */}
<div className="recent-section">
<h2>Recent Notes</h2>
{recentNotes.length === 0 ? (
<p>No notes yet. Upload a PDF or create a note to get started.</p>
) : (
<ul className="recent-list">
{recentNotes.map(note => (
<li key={note._id}>
<Link to={`/notes/${note._id}`}>{note.title}</Link>
<span className="note-subject">{note.subject}</span>
</li>
))}
</ul>
)}
</div>
{/* Link to full analytics */}
<Link to="/analytics" className="view-analytics-link">
View Full Analytics
</Link>
</div>
);
}
export default Dashboard;
What does the Dashboard page show?
The Dashboard is the landing page after login. It fetches the overview stats (notes count, quizzes taken, study streak) and the user's most recent 5 notes. It gives a quick snapshot without overwhelming the learner with charts.
A link to the full Analytics page lets users drill deeper when they want detailed trends and progress visualizations.
- Save Dashboard.jsx.
- Test the full flow: take a quiz, then navigate to /analytics. You should see your quiz score appear in the LineChart and your study time reflected in the BarChart.
Analytics page still empty after taking a quiz?
- Open the browser Network tab and check if the POST to /api/analytics/session returns a 201 status. If it fails, verify your auth token is being sent with the request.
- Check that timeTaken and score variables in Quiz.jsx have actual numeric values at the point you POST the session. Add a console.log before the call to verify.
Why is my study session POST failing?
Your study assistant now tracks everything. Students can see their quiz scores trending up, their daily study habits forming, and their streak growing. Next up, you'll deploy this entire application to the cloud so anyone can use it.
Deploy the Application
Your AI Study Assistant works locally. Every feature is tested and ready. Now it is time to put it on the internet where you (and others) can actually use it.
In this step, you will deploy your Express backend to Render and your React frontend to Vercel. Both platforms offer free tiers that are perfect for portfolio projects.
In this step, get ready to:
- Prepare your codebase for production deployment.
- Deploy the Express backend to Render.
- Deploy the React frontend to Vercel and verify the live application.
Prepare the codebase for production
Before deploying, you need to make a few code changes. Your backend needs to know which frontend URL to trust for CORS. Your frontend needs to know the production backend URL.
- In VS Code, open server/server.js.
- Find the line where you set up CORS (your existing app.use(cors(...)) call).
- Replace it with the following production-ready CORS configuration:
// Production CORS - allow requests from the deployed frontend
const corsOptions = {
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true
};
app.use(cors(corsOptions));
What does this code do?
The origin field tells Express which frontend URL is allowed to make requests. In production, CLIENT_URL will be your Vercel deployment URL. Locally, it falls back to http://localhost:5173 (Vite's dev server).
- Save server/server.js.
- Now open client/src/services/api.js.
- Update the Axios instance's baseURL to use an environment variable with a local fallback:
import axios from 'axios';
// Use the production API URL if available, otherwise fall back to local proxy
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api'
});
// Attach JWT token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
What does this code do?
In production, VITE_API_URL points to your Render backend URL (e.g. https://your-app.onrender.com/api). Locally, it falls back to /api which the Vite dev proxy handles.
Vite exposes environment variables prefixed with VITE_ to client-side code via import.meta.env. This prefix is required for the variable to be available in the browser bundle.
- Save client/src/services/api.js.
- Next, create a vercel.json file in the client/ directory to handle SPA routing:
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
Why do you need this file?
React Router handles routing on the client side. Without this rewrite rule, refreshing a page like /dashboard would return a 404 because Vercel looks for a file at that path. The rewrite sends all requests to index.html so React Router can take over.
- Save client/vercel.json.
- Now update MongoDB Atlas to allow connections from Render. Open your MongoDB Atlas dashboard in your browser.
- Click **Network Access** in the left sidebar.
- Click **Add IP Address**.
- Click **Allow Access from Anywhere** (this sets the IP to 0.0.0.0/0).
- Click **Confirm**.
Why allow all IPs?
Render's free tier does not provide static IP addresses for outbound connections. Your server's IP changes each time it redeploys or wakes from sleep. Allowing 0.0.0.0/0 means any IP can connect, which is fine because your database is still protected by its username and password in the connection string.
- Finally, commit and push all your changes to GitHub by running these commands in your terminal:
git add .
git commit -m "Prepare for production deployment"
git push origin main
Push failed?
Make sure you are in the root ai-study-assistant directory when running these commands. If you see an authentication error, check that your GitHub credentials are configured correctly.
help me fix my git push error
Deploy the Express backend to Render
With your code on GitHub, you can now connect it to Render. Render will pull your code, install dependencies, and start your Express server automatically.
- Open https://render.com in your browser.
- Sign up for a free account (or sign in if you already have one). Use your GitHub account to sign up for the easiest setup.
- In the Render Dashboard, click **New** in the top navigation.
- Select **Web Service**.
- Under **Git Provider**, connect your GitHub account if you have not already.
- Find your ai-study-assistant repository in the list and click **Connect**.
The service creation form appears. Fill in the following fields:
- Set **Name** to ai-study-assistant-api (or a name of your choice).
- Set **Region** to the one closest to you.
- Set **Branch** to main.
- Set **Root Directory** to server.
- Set **Language** to Node.
- Set **Build Command** to npm install.
- Set **Start Command** to node server.js.
- Under **Instance Type**, select **Free**.
Why set root directory to server?
Your repository is a monorepo with client/ and server/ folders. Setting the root directory to server tells Render to run build and start commands from inside that folder, where your package.json and server.js live.
- Scroll down to the **Environment Variables** section (or expand **Advanced**).
- Add the following environment variables:
- MONGODB_URI - your MongoDB Atlas connection string.
- JWT_SECRET - a strong random string for signing tokens.
- OPENAI_API_KEY - your OpenAI API key.
- CLIENT_URL - leave this blank for now. You will fill it in after deploying the frontend.
- PORT - set to 10000 (Render's default port for web services).
- Click **Create Web Service**.
Render begins building and deploying your backend. This takes a few minutes. You can watch the progress in the deploy logs.
- Wait for the deploy to show a green **Live** status.
- Copy your service URL from the top of the page (it looks like https://ai-study-assistant-api.onrender.com). You will need this in the next substep.
Deploy failed?
Check the deploy logs in the Render Dashboard. Common issues include a missing environment variable or an incorrect start command. Make sure your server.js uses process.env.PORT to bind to the correct port.
help me debug my Render deployment failure
What about cold starts?
Render's free tier spins down your service after 15 minutes of inactivity. The first request after a sleep period takes 30-60 seconds while the server wakes up. This is normal for free-tier hosting and does not affect functionality.
Deploy the React frontend to Vercel
Your backend is live. Now you will deploy the React frontend to Vercel and connect it to the backend.
- Open https://vercel.com in your browser.
- Sign up for a free Hobby account (or sign in). Use your GitHub account for the easiest setup.
- From the Vercel Dashboard, click **Add New** then **Project**.
- Find your ai-study-assistant repository and click **Import**.
The project configuration page appears. Fill in the following:
- Set **Framework Preset** to **Vite**.
- Set **Root Directory** to client.
- Expand the **Environment Variables** section.
- Add a variable with key VITE_API_URL and value set to your Render backend URL followed by /api (for example: https://ai-study-assistant-api.onrender.com/api).
- Click **Deploy**.
Vercel detects Vite, installs dependencies, and builds your React app. This takes 1-2 minutes.
- Once the deploy completes, Vercel shows a celebration screen with your live URL (something like https://ai-study-assistant.vercel.app).
- Copy your Vercel URL.
Now go back to Render and set the CLIENT_URL environment variable so your backend knows which frontend to trust.
- In the Render Dashboard, open your backend service.
- Click **Environment** in the left sidebar.
- Find CLIENT_URL and set its value to your Vercel URL (e.g. https://ai-study-assistant.vercel.app). Do not include a trailing slash.
- Click **Save Changes**. Render automatically redeploys with the updated variable.
Time to verify the full application works end-to-end in production.
- Open your Vercel URL in a new browser tab.
- Register a new account on the live site.
- Upload a PDF or create a note.
- Generate a summary, quiz, or flashcard deck to confirm the AI integration works.
Seeing CORS errors in the browser console?
Double check that CLIENT_URL on Render matches your Vercel URL exactly (including https://). Also confirm your server.js CORS configuration uses process.env.CLIENT_URL as the origin. After updating, wait for Render to finish redeploying.
help me fix CORS errors between my Vercel frontend and Render backend
First request takes a long time?
This is Render's cold start on the free tier. After 15 minutes of no traffic, the server sleeps. The first request after sleep takes 30-60 seconds. Subsequent requests are fast.
- Finally, update your docs/architecture.md with the production URLs and update README.md with live demo links and setup instructions.
- Commit and push these documentation updates:
git add .
git commit -m "Add production URLs to documentation"
git push origin main
Both Vercel and Render auto-deploy when you push to main. Future code changes deploy automatically.
Your AI Study Assistant is live on the internet. Next up, you will tackle the Secret Mission and add a spaced repetition algorithm to supercharge the flashcard system.
Secret mission
Add a Spaced Repetition Algorithm
Your flashcard study mode currently treats every card equally. In real learning science, cards you struggle with should appear more frequently, while cards you know well can wait days or weeks before reappearing. Implement the SM-2 spaced repetition algorithm to intelligently schedule flashcard reviews based on how well you recall each card.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses free tiers for hosting and database, so ongoing costs are minimal. The only pay-per-use cost is the OpenAI API, which charges based on token usage.
Cost warning
Your OpenAI API key is pay-per-use. If you leave it active and any application calls it, you will be charged. If you are done with this project, revoke the key to prevent unexpected charges.
Resources you used:
- MongoDB Atlas free M0 cluster (stores all application data).
- Render free web service (hosts the Express backend API).
- Vercel Hobby deployment (hosts the React frontend).
- OpenAI API key (pay-per-use, charges per token).
- Local ai-study-assistant project folder on your computer.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the app live.
- MongoDB Atlas free cluster stays active indefinitely. It auto-pauses after 30 days of zero connections and resumes when you reconnect.
- Render free service spins down after 15 minutes of no inbound traffic. It wakes automatically on the next request (first request takes 30-60 seconds).
- Vercel Hobby deployment stays live with no action required.
- Your OpenAI API key remains active. You are only charged when API calls are made.
- Your local ai-study-assistant folder remains on your computer.
Pause - I'll come back to this later
Free up local resources while keeping your cloud services intact. All three hosting services handle pausing automatically on their free tiers.
- MongoDB Atlas auto-pauses your free cluster after 30 days of zero connections. No manual action needed.
- Render automatically spins down your service after 15 minutes of no traffic. No manual action needed.
- Vercel keeps your deployment live at no cost on the Hobby plan. No manual action needed.
- Your OpenAI API key stays active but will not incur charges unless something calls it.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete your MongoDB Atlas cluster
- Log in to MongoDB Atlas at cloud.mongodb.com.
- In the sidebar, click Clusters under the Database heading.
- Click the ... menu next to your cluster.
- Click Terminate from the dropdown menu.
- Enter the cluster name to confirm, then click Terminate.
Delete your Render web service
- Log in to the Render Dashboard at dashboard.render.com.
- Click on your backend web service to open it.
- Click Settings in the left sidebar.
- Scroll to the bottom of the Settings page and click Delete Web Service.
- Type the service name to confirm deletion.
Delete your Vercel project
- Log in to the Vercel Dashboard at vercel.com/dashboard.
- Select your AI Study Assistant project.
- Click Settings in the top navigation.
- Scroll to the bottom of the General page to find the Delete Project section.
- Click Delete, enter the project name to confirm, then click Continue.
Revoke your OpenAI API key
- Go to the OpenAI API keys page at platform.openai.com/api-keys.
- Click the trashcan icon next to the API key you used for this project.
- Confirm the deletion. The key can no longer be used after this.
Delete your local project folder
- Remove the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/ai-study-assistant
Windows
Remove-Item -Recurse -Force ~\Desktop\ai-study-assistant
That's a Wrap!
That's a Wrap!
Congratulations! You've architected and deployed a complete AI-powered study assistant from scratch.
This is a production-grade full-stack application that demonstrates system design, AI integration, authentication, file processing, analytics, and multi-service deployment. That's a serious portfolio piece.
You've learned how to:
- Integrate the OpenAI API to auto-generate summaries, quizzes, and flashcards from uploaded PDFs and notes.
- Design and build a full-stack system architecture with React, Express, and MongoDB Atlas, including JWT authentication, PDF text extraction, and a study analytics dashboard.
- Deploy a multi-service application live across Vercel and Render with production environment variables and CORS configuration.
- Implement the SM-2 spaced repetition algorithm to schedule flashcard reviews at optimal intervals based on learner performance.
Ready to quiz yourself?