MERN Stack Project Tutorial: Build an Exam-Prep App with Auth, CRUD, and Deployment

Build PrepTrack as a small but complete MERN project, with a protected answer flow, server-owned scoring, and a deployment you can explain in an interview.

KnowledgeGate Team

Exam prep & CS education

Updated 29 Aug 20266 min read

A todo app proves that you can connect a form to a database. It does not prove that you understand authentication, trust boundaries, or production configuration. PrepTrack is an exam-prep app small enough to finish, yet complete enough to support a serious project-interview discussion.

PrepTrack project shape and data model

Use one repository with server/ for Express and client/ for React. MongoDB stores the data, Mongoose defines its shape, Express exposes the API, React renders the interface, and Node runs the server.

PrepTrack needs three collections:

User {
  name: String,
  email: { type: String, unique: true },
  passwordHash: String,
  role: { type: String, enum: ['learner', 'editor'], default: 'learner' }
}

Question {
  text: String,
  options: [String],
  correctIndex: Number,
  topic: String
}

Attempt {
  userId: ObjectId,
  questionId: ObjectId,
  chosenIndex: Number,
  isCorrect: Boolean,
  createdAt: Date
}

The most important decision is not a field type. It is ownership: correctIndex belongs to the server. The questions endpoint must not send it before the learner submits an answer. Hiding the answer in the React interface is useless if it is still visible in the network response.

Validate required fields and ensure chosenIndex is within the question's options. A database schema protects storage shape, but request validation protects the API boundary.

Register, log in, and protect the API

POST /api/auth/register accepts only a name, email, and password. Hash the password with bcrypt and store only passwordHash. Never store the original password or accept role from the registration body; new accounts keep the schema's learner default.

POST /api/auth/login finds the user, compares the submitted password with the hash, and returns a signed JWT. Its payload needs a stable user identifier. The signing secret comes from JWT_SECRET, which stays in the server environment rather than the repository.

Protected routes use middleware shaped like this:

function requireAuth(req, res, next) {
  const value = req.get('Authorization');
  const token = value?.startsWith('Bearer ') ? value.slice(7) : null;

  if (!token) return res.status(401).json({ message: 'Sign in required' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = payload.userId;
    next();
  } catch {
    res.status(401).json({ message: 'Invalid or expired token' });
  }
}

Mount it before every route that reads or writes user-specific attempts. Authentication identifies the caller; authorization must still ensure that one user cannot fetch or change another user's records.

CRUD with server-side scoring

Keep question management separate from learner attempts. All four question-management operations require authentication; Create, Update, and Delete also require an editor role loaded from the database rather than trusted from the request or an old token.

  • POST /api/questions creates a question after validating the prompt, options, topic, and answer index.

  • GET /api/questions?topic=arrays reads questions for a topic while projecting out correctIndex.

  • PATCH /api/questions/:id updates allowed fields and rechecks that correctIndex remains within the updated options array.

  • DELETE /api/questions/:id deletes an unused question, but returns 409 Conflict when attempts already reference it so score history is not orphaned.

router.post('/api/questions', requireAuth, requireEditor, createQuestion);
router.get('/api/questions', requireAuth, listQuestionsWithoutAnswers);
router.patch('/api/questions/:id', requireAuth, requireEditor, updateQuestion);
router.delete('/api/questions/:id', requireAuth, requireEditor, deleteQuestion);

The route declarations make the CRUD contract visible, but the handlers still own the important checks. Reject malformed MongoDB identifiers before querying, return 404 for a missing question, whitelist patchable fields, and never spread an untrusted request body into a Mongoose update.

The read path is GET /api/questions?topic=arrays. Filter by the requested topic and project out correctIndex before serialising the questions. The client receives the prompt, options, topic, and identifier, but not the answer.

The write path is POST /api/attempts. Its body contains only questionId and chosenIndex. Do not accept isCorrect from the browser, because the caller can change any browser request.

Trace one valid submission. Suppose question 652ab... has correctIndex = 2 in MongoDB.

POST /api/attempts
Authorization: Bearer <jwt>
Content-Type: application/json

{ "questionId": "652ab...", "chosenIndex": 2 }

The server performs these steps:

  1. requireAuth verifies the JWT and sets req.userId.

  2. The handler loads question 652ab... from MongoDB.

  3. It computes 2 === 2, so isCorrect becomes true.

  4. It saves the attempt with the authenticated userId, the question ID, chosen index, result, and creation time.

  5. It returns status 201 with { "isCorrect": true, "correctIndex": 2 }.

The learner sees the correct answer only after the scoring decision. If the stored index were 1, the same submitted index 2 would produce false. The calculation never trusts a result supplied by the client.

PrepTrack architecture: a React client calls an Express API reaching MongoDB Atlas via Mongoose, using JWT auth and environment variables.

Build the React answer flow

An auth context can keep the JWT in memory and expose login and logout. In-memory storage reduces exposure to scripts compared with localStorage, though a page refresh clears the session. An httpOnly secure cookie is another design, but it changes the server and CSRF story. Pick one approach and explain the trade-off.

Wrap fetch so callers do not repeat authentication logic:

async function apiFetch(path, options = {}) {
  const headers = new Headers(options.headers);
  if (token) headers.set('Authorization', `Bearer ${token}`);

  const response = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
    ...options,
    headers
  });
  if (response.status === 401) logout();
  return response;
}

QuestionCard renders text and options, holds the chosen index, and posts the attempt. Only after the response arrives does it reveal whether the answer was correct and highlight the returned correctIndex. Disable repeat submission while the request is pending.

Sequence diagram: the client logs in for a JWT, fetches questions without the answer, then posts an attempt and receives its score.

The React interview questions for freshers are useful for explaining the component and state choices. The Node.js interview questions for freshers help you prepare for middleware, request flow, and API follow-ups.

Deploy the complete MERN application

Create a MongoDB Atlas database and place its connection string in MONGO_URI on the API host. Deploy the Express server to a Node host such as Render or Railway. A simple server with no compilation step can install with npm ci and start with npm start; make those scripts explicit in server/package.json. For the React client, run npm ci && npm run build and deploy its generated static output directory.

Set VITE_API_URL during the frontend build to the deployed API base URL. Otherwise, a correct local app can ship with requests still aimed at localhost. Configure Express CORS with the exact deployed frontend origin, including its scheme, and keep JWT_SECRET and MONGO_URI in the host's environment settings.

Before sharing the URL, register a new account, log in, load a topic, submit both a correct and an incorrect answer, refresh the page, and inspect the browser network responses. That short test catches deployment, CORS, token, and answer-leak problems.

Security traps that weaken the project

  • Computing isCorrect in React lets a caller forge the result.

  • Returning correctIndex from GET /questions leaks every answer.

  • Keeping a JWT in localStorage makes it readable by injected JavaScript.

  • Allowing every CORS origin in production removes an intended boundary.

  • Committing JWT_SECRET or MONGO_URI exposes credentials in repository history.

  • Skipping request validation allows malformed indexes and identifiers into the handler.

Use the Coding Skills category to place this project beside the JavaScript, database, and DSA topics an interviewer can connect to it.

PrepTrack summary and further practice

PrepTrack has three schemas, JWT-protected routes, hidden answers, server-side scoring, and a real deployed client and API. That is a compact project whose trust boundary you can defend, not just a CRUD screen.

Practise with about 600 MERN questions, then build the app alongside the MERN Stack Course: Full Stack Development. To pair the project with coding-round preparation, continue through MERN Stack + DSA.