Who is this for? You are a junior backend developer who has just received this project. You have never seen this code before. This guide will teach you how to build this exact same project from scratch, step by step β just like a YouTube tutorial. Every command, every file, every reason is explained.
A full-stack Note Taking Web Application using Node.js with:
- User Authentication β Register β Verify OTP β Login β Logout
- Session-based access control β only logged-in users can see notes
- Full CRUD β Create, Read, Update, Delete notes
- Winston Logger β professional logging on every API action
- Handlebars Templating β server-side rendered UI
- MongoDB β database to store users, OTPs and notes
| Package | What it does | Why we use it |
|---|---|---|
express |
Web framework | Handles HTTP requests and routes |
mongoose |
MongoDB ODM | Makes DB queries easy with models |
express-handlebars |
Template engine | Renders HTML pages on the server |
express-session |
Session management | Remembers who is logged in |
bcryptjs |
Password hashing | Never store plain passwords in DB |
winston |
Logger | Professional logging to console + files |
dotenv |
Environment variables | Hides secrets like DB URLs and keys |
connect-mongo |
Session store | Saves sessions in MongoDB (optional upgrade) |
Before you write a single line of code, install these on your computer:
Download from: https://nodejs.org (choose LTS version)
Check it's installed:
node -v # should show v18.x or higher
npm -v # should show 9.x or higherDownload from: https://www.mongodb.com/try/download/community
After installing, start MongoDB service:
# Windows (run in Command Prompt as Admin)
net start MongoDB
# Mac
brew services start mongodb-community
# Linux
sudo systemctl start mongodCheck it's running:
mongosh # should open a MongoDB shell β type exit to closeDownload from: https://code.visualstudio.com
Download from: https://www.postman.com/downloads
This is how your project will look when it is 100% complete. Read this carefully β every file has a purpose.
noteapp/
β
βββ π server.js β Entry point. Starts the whole app
βββ π package.json β Project info + list of all packages
βββ π .env β Secret values (NEVER share this file)
β
βββ π config/
β βββ config.js β Reads .env and exports the values
β
βββ π utils/
β βββ logger.js β Winston logger setup
β
βββ π middleware/
β βββ authMiddleware.js β Guards routes from non-logged-in users
β
βββ π model/
β βββ userSchema.js β MongoDB schema for User accounts
β βββ otpSchema.js β MongoDB schema for OTP codes
β βββ noteSchema.js β MongoDB schema for Notes
β
βββ π controllers/
β βββ authController.js β Register, Verify OTP, Login, Logout logic
β βββ noteController.js β Add, View, Edit, Delete note logic
β
βββ π router/
β βββ authRoutes.js β URL paths for auth (login, register, etc.)
β βββ noteRoutes.js β URL paths for notes (all protected)
β
βββ π public/
β βββ style.css β All CSS styling for the whole app
β
βββ π logs/
β βββ error.log β Auto-created by Winston
β βββ combined.log β Auto-created by Winston
β
βββ π views/ β All HTML pages (Handlebars templates)
βββ π layouts/
β βββ main.handlebars β The master layout (navbar + footer wrapper)
βββ π partials/
β βββ _navbar.handlebars
β βββ _footer.handlebars
βββ π auth/
β βββ register.handlebars
β βββ verifyOtp.handlebars
β βββ login.handlebars
βββ π noteApp/
β βββ addNote.handlebars
β βββ allNotes.handlebars
β βββ editNote.handlebars
β βββ singleNote.handlebars
βββ home.handlebars
Open your terminal, go to wherever you keep your projects, and run:
mkdir noteapp
cd noteapp
npm init -yWhat this does:
mkdir noteappβ creates a new folder called noteappcd noteappβ enter that foldernpm init -yβ createspackage.jsonautomatically with default values
You will now see a package.json file. This file tracks your project name, version, and all the packages you install.
Run this single command to install every package the project needs:
npm install express mongoose express-handlebars express-session bcryptjs winston dotenv connect-mongoWhat each package does (explained simply):
| Package | Simple explanation |
|---|---|
express |
The main web server. Think of it as the engine of your car |
mongoose |
Lets you talk to MongoDB easily using JavaScript objects |
express-handlebars |
Lets you create HTML pages with dynamic data using {{variable}} syntax |
express-session |
After a user logs in, the server "remembers" them using a session |
bcryptjs |
Scrambles (hashes) passwords so even if your DB is hacked, passwords are safe |
winston |
A professional logging tool β better than console.log because it saves to files |
dotenv |
Reads your .env file and makes those values available as process.env.SOMETHING |
connect-mongo |
Optional: stores sessions in MongoDB instead of memory |
After installing, you will see a node_modules/ folder and a package-lock.json file appear. Never edit these manually.
Add this to .gitignore to never accidentally push them:
node_modules/
.env
logs/
Create a file called .env in the root of your project (same level as package.json):
PORT=3000
MONGODB_URL=mongodb://localhost:27017/Note_App
SESSION_SECRET=noteapp_super_secret_key_2025
Why do we need this?
Imagine you have a database password. If you write it directly in your code and push to GitHub, everyone in the world can see it. The .env file keeps secrets out of your code. The dotenv package reads this file and makes the values available in your app.
β οΈ IMPORTANT: Always add.envto.gitignore. Never commit it to Git.
Create the config/ folder, then create config/config.js:
require('dotenv').config()
module.exports = {
PORT: process.env.PORT || 3000,
MONGODB_URL: process.env.MONGODB_URL,
SESSION_SECRET: process.env.SESSION_SECRET || 'fallback_secret'
}Why do we need this separate file?
Instead of calling require('dotenv').config() in every file, we do it once here. Then everywhere else in the project, we just do const { PORT } = require('./config/config') and get the value cleanly.
The || 3000 part means: "use PORT from .env, but if it's missing, use 3000 as a fallback."
Create the utils/ folder, then create utils/logger.js:
const { createLogger, format, transports } = require('winston')
const { combine, timestamp, printf, colorize } = format
const logFormat = printf(({ level, message, timestamp }) => {
return `[${timestamp}] ${level.toUpperCase()}: ${message}`
})
const logger = createLogger({
level: 'info',
format: combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
transports: [
new transports.Console({
format: combine(
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
)
}),
new transports.File({ filename: 'logs/error.log', level: 'error' }),
new transports.File({ filename: 'logs/combined.log' })
]
})
module.exports = loggerWhy Winston instead of console.log?
console.log is fine for learning, but in real projects:
- You need logs saved to files (so you can check them later)
- You need timestamps on every message
- You need different levels β
info,warn,error - You need color coding in the terminal
Winston does all of this.
How to use it in any file:
const logger = require('../utils/logger')
logger.info('User logged in successfully') // normal info
logger.warn('Failed login attempt') // warning
logger.error('Database connection failed') // error (also goes to error.log)Create the logs/ folder now so Winston can write to it:
mkdir logsThink of a model as a blueprint. It tells MongoDB: "Every document in this collection must look exactly like this."
Create the model/ folder, then create model/userSchema.js:
const { Schema, model } = require('mongoose')
const UserSchema = new Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true },
isVerified: { type: Boolean, default: false }
}, { timestamps: true })
module.exports = model('User', UserSchema)Field explanations:
| Field | Type | Why |
|---|---|---|
name |
String | The user's display name |
email |
String, unique | Login identifier. unique: true prevents two accounts with same email |
password |
String | Stores the hashed password (never plain text) |
isVerified |
Boolean | Starts as false. Only becomes true after OTP verification |
timestamps: true automatically adds createdAt and updatedAt fields to every document.
Create model/otpSchema.js:
const { Schema, model } = require('mongoose')
const OtpSchema = new Schema({
email: { type: String, required: true },
otp: { type: String, required: true },
expiresAt: { type: Date, required: true }
})
// This line tells MongoDB to automatically delete the document when expiresAt passes
OtpSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })
module.exports = model('Otp', OtpSchema)Why a separate OTP model?
When a user registers, we generate a 6-digit code and save it here temporarily. Once they verify, we delete this record. The expireAfterSeconds: 0 on the expiresAt field tells MongoDB's built-in TTL (Time To Live) feature to automatically delete the document when the expiry time passes. This is clean and automatic.
Create model/noteSchema.js:
const { Schema, model } = require('mongoose')
const NoteSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
noteId: { type: String, required: true },
noteName: { type: String, required: true },
description: { type: String, required: true },
status: { type: String, required: true, enum: ['active', 'inactive'] }
}, { timestamps: true })
module.exports = model('Note', NoteSchema)The key field here is userId.
Schema.Types.ObjectId is MongoDB's unique ID type. ref: 'User' creates a relationship β every Note belongs to a User. When we query notes, we always filter by userId: req.session.userId so users can only ever see their own notes.
enum: ['active', 'inactive'] means if you try to save any other value for status, MongoDB will reject it.
Create the middleware/ folder, then create middleware/authMiddleware.js:
const logger = require('../utils/logger')
const authMiddleware = (req, res, next) => {
if (!req.session || !req.session.userId) {
logger.warn(`Unauthorized access attempt β ${req.originalUrl}`)
return res.redirect('/auth/login?msg=login_required')
}
next()
}
module.exports = authMiddlewareWhat is middleware?
Middleware is a function that runs between the request arriving and your controller handling it. Think of it as a security guard at the door.
When a user tries to visit /api/allNotes:
- The request arrives at Express
- Express runs
authMiddlewarefirst - If
req.session.userIddoes not exist β user is not logged in β redirect to login with a message - If it does exist β call
next()β continue to the controller
Why ?msg=login_required?
We pass this as a URL query parameter so the login page can read it and show the user a message: "Please login first to access that page."
Create the controllers/ folder, then create controllers/authController.js.
The controller holds all the business logic for each route. Here is the full file with explanations inline:
const bcrypt = require('bcryptjs')
const User = require('../model/userSchema')
const Otp = require('../model/otpSchema')
const logger = require('../utils/logger')
// Helper: generates a random 6-digit number as a string
const generateOtp = () => Math.floor(100000 + Math.random() * 900000).toString()
// βββ REGISTER PAGE (GET) βββββββββββββββββββββββββββββββββ
// Just renders the register HTML page
exports.registerPage = (req, res) => {
res.render('auth/register', { title: 'Register' })
}
// βββ REGISTER (POST) βββββββββββββββββββββββββββββββββββββ
exports.register = async (req, res) => {
try {
const { name, email, password } = req.body
// req.body holds what the user typed in the form
// Check if this email already exists in DB
const existing = await User.findOne({ email })
if (existing) {
logger.warn(`Register failed - email already exists: ${email}`)
return res.render('auth/register', {
title: 'Register',
error: 'Email already registered. Please login.'
})
}
// Hash the password β NEVER store plain text passwords
// bcrypt.hash(password, saltRounds) β 10 is the industry standard
const hashed = await bcrypt.hash(password, 10)
// Save new user to MongoDB β isVerified defaults to false
await User.create({ name, email, password: hashed })
// Generate OTP and save to DB
const otp = generateOtp()
const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // expires in 10 minutes
await Otp.deleteMany({ email }) // delete any old OTPs for this email first
await Otp.create({ email, otp, expiresAt })
// Log the OTP via Winston (in production this would be emailed)
logger.info(`OTP generated for ${email} β ${otp}`)
// Redirect to verify page, passing the OTP in devOtp for dev/testing
res.render('auth/verifyOtp', {
title: 'Verify OTP',
email,
devOtp: otp
})
} catch (err) {
logger.error(`Register error: ${err.message}`)
res.render('auth/register', { title: 'Register', error: 'Something went wrong.' })
}
}
// βββ VERIFY OTP PAGE (GET) βββββββββββββββββββββββββββββββ
exports.verifyOtpPage = (req, res) => {
res.render('auth/verifyOtp', { title: 'Verify OTP', email: req.query.email })
}
// βββ VERIFY OTP (POST) βββββββββββββββββββββββββββββββββββ
exports.verifyOtp = async (req, res) => {
try {
const { email, otp } = req.body
// Look for an OTP record matching this email
const record = await Otp.findOne({ email })
if (!record) {
logger.warn(`OTP verify failed - no OTP found for: ${email}`)
return res.render('auth/verifyOtp', {
title: 'Verify OTP',
email,
error: 'OTP expired or not found. Register again.'
})
}
// Check if the code the user entered matches what we saved
if (record.otp !== otp.trim()) {
logger.warn(`OTP verify failed - wrong OTP for: ${email}`)
return res.render('auth/verifyOtp', {
title: 'Verify OTP',
email,
error: 'Invalid OTP. Try again.'
})
}
// Check if OTP has expired (extra safety check alongside MongoDB TTL)
if (new Date() > record.expiresAt) {
await Otp.deleteMany({ email })
logger.warn(`OTP expired for: ${email}`)
return res.render('auth/verifyOtp', {
title: 'Verify OTP',
email,
error: 'OTP has expired. Register again.'
})
}
// OTP is valid β mark user as verified in DB
await User.findOneAndUpdate({ email }, { isVerified: true })
// Delete the OTP record β it's been used, we don't need it anymore
await Otp.deleteMany({ email })
logger.info(`Email verified successfully: ${email}`)
// Send user to login page with a success message
res.redirect('/auth/login?verified=true')
} catch (err) {
logger.error(`OTP verify error: ${err.message}`)
res.render('auth/verifyOtp', {
title: 'Verify OTP',
email: req.body.email,
error: 'Something went wrong.'
})
}
}
// βββ LOGIN PAGE (GET) ββββββββββββββββββββββββββββββββββββ
exports.loginPage = (req, res) => {
const verified = req.query.verified === 'true'
const loginRequired = req.query.msg === 'login_required'
res.render('auth/login', { title: 'Login', verified, loginRequired })
}
// βββ LOGIN (POST) ββββββββββββββββββββββββββββββββββββββββ
exports.login = async (req, res) => {
try {
const { email, password } = req.body
// Find user by email
const user = await User.findOne({ email })
if (!user) {
logger.warn(`Login failed - user not found: ${email}`)
return res.render('auth/login', {
title: 'Login',
error: 'Invalid email or password.'
})
}
// Block login if email is not verified yet
if (!user.isVerified) {
logger.warn(`Login blocked - email not verified: ${email}`)
return res.render('auth/login', {
title: 'Login',
error: 'Please verify your email first.'
})
}
// Compare typed password with the hashed one in DB
// bcrypt.compare returns true if they match
const match = await bcrypt.compare(password, user.password)
if (!match) {
logger.warn(`Login failed - wrong password for: ${email}`)
return res.render('auth/login', {
title: 'Login',
error: 'Invalid email or password.'
})
}
// Save user info in the session β this is how the server "remembers" them
req.session.userId = user._id
req.session.userName = user.name
logger.info(`User logged in: ${email}`)
res.redirect('/') // send to home page
} catch (err) {
logger.error(`Login error: ${err.message}`)
res.render('auth/login', { title: 'Login', error: 'Something went wrong.' })
}
}
// βββ LOGOUT (GET) ββββββββββββββββββββββββββββββββββββββββ
exports.logout = (req, res) => {
const name = req.session.userName
req.session.destroy() // destroys the session on the server
logger.info(`User logged out: ${name}`)
res.redirect('/auth/login')
}Create the router/ folder, then create router/authRoutes.js:
const { Router } = require('express')
const router = Router()
const auth = require('../controllers/authController')
router.get('/register', auth.registerPage) // shows the register form
router.post('/register', auth.register) // handles form submission
router.get('/verify-otp', auth.verifyOtpPage) // shows the OTP form
router.post('/verify-otp', auth.verifyOtp) // handles OTP check
router.get('/login', auth.loginPage) // shows the login form
router.post('/login', auth.login) // handles login form
router.get('/logout', auth.logout) // logs the user out
module.exports = routerWhy GET and POST for the same path?
GET /auth/loginβ User visits the URL β show the login pagePOST /auth/loginβ User submits the form β check the credentials
HTML forms can only do GET or POST. The browser sends a POST when the user clicks the submit button.
Create controllers/noteController.js:
const mongoose = require('mongoose')
const Note = require('../model/noteSchema')
const logger = require('../utils/logger')
// Helper: checks if a MongoDB ID is valid format
const isValidId = (id) => mongoose.Types.ObjectId.isValid(id)
// Home page β only reached after login because of authMiddleware
exports.home = (req, res) => {
res.render('home', {
title: 'Home',
userName: req.session.userName // pass name to show "Welcome back, John!"
})
}
// Show the Add Note form page
exports.addNotePage = (req, res) => {
res.render('noteApp/addNote', { title: 'Add Note' })
}
// Handle the Add Note form submission
exports.createNote = async (req, res) => {
try {
// req.body has the form data. We add userId from session so the note is linked to this user
await Note.create({ ...req.body, userId: req.session.userId })
logger.info(`Note created by user: ${req.session.userId}`)
res.redirect('/api/allNotes')
} catch (err) {
logger.error(`Create note error: ${err.message}`)
res.render('noteApp/addNote', { title: 'Add Note', error: 'Failed to create note.' })
}
}
// Show all notes belonging to the logged-in user only
exports.allNotes = async (req, res) => {
// IMPORTANT: filter by userId so users never see each other's notes
const payload = await Note.find({ userId: req.session.userId }).lean()
// .lean() converts Mongoose documents to plain JS objects β needed for Handlebars
res.render('noteApp/allNotes', { title: 'All Notes', payload })
}
// Show one specific note
exports.singleNote = async (req, res) => {
if (!isValidId(req.params.id)) return res.redirect('/api/allNotes')
// Also filter by userId β prevents User A from viewing User B's note by guessing the ID
const payload = await Note.findOne({
_id: req.params.id,
userId: req.session.userId
}).lean()
if (!payload) return res.redirect('/api/allNotes')
res.render('noteApp/singleNote', { title: payload.noteName, payload })
}
// Show the Edit form pre-filled with existing note data
exports.editPage = async (req, res) => {
if (!isValidId(req.params.id)) return res.redirect('/api/allNotes')
const payload = await Note.findOne({
_id: req.params.id,
userId: req.session.userId
}).lean()
if (!payload) return res.redirect('/api/allNotes')
res.render('noteApp/editNote', { title: 'Edit Note', payload })
}
// Handle the Edit form submission β update the note in DB
exports.updateNote = async (req, res) => {
if (!isValidId(req.params.id)) return res.redirect('/api/allNotes')
await Note.findOneAndUpdate(
{ _id: req.params.id, userId: req.session.userId },
req.body
)
logger.info(`Note updated: ${req.params.id}`)
res.redirect('/api/allNotes')
}
// Delete a note
exports.deleteNote = async (req, res) => {
await Note.findOneAndDelete({
_id: req.params.id,
userId: req.session.userId
})
logger.info(`Note deleted: ${req.params.id}`)
res.redirect('/api/allNotes')
}Create router/noteRoutes.js:
const { Router } = require('express')
const router = Router()
const controller = require('../controllers/noteController')
const authMiddleware = require('../middleware/authMiddleware')
// Apply authMiddleware to EVERY route in this file
// If you're not logged in, ALL of these will redirect you to login
router.use(authMiddleware)
router.get('/addNote', controller.addNotePage) // show add form
router.get('/allNotes', controller.allNotes) // show all notes
router.get('/edit/:id', controller.editPage) // show edit form
router.post('/addNote', controller.createNote) // submit add form
router.post('/edit/:id', controller.updateNote) // submit edit form
router.get('/delete/:id', controller.deleteNote) // delete a note
// β οΈ IMPORTANT: this route MUST be last
// Because /:id would match /addNote and /allNotes too if placed first
router.get('/:id', controller.singleNote)
module.exports = routerWhy must /:id be last?
Express matches routes in the order you define them. If /:id comes first, then /allNotes would be treated as an ID (the string "allNotes"). By placing it last, Express only reaches it after checking all the specific routes above.
This is the file that ties everything together. Create server.js in the root:
const express = require('express')
const mongoose = require('mongoose')
const session = require('express-session')
const { engine } = require('express-handlebars')
const { PORT, MONGODB_URL, SESSION_SECRET } = require('./config/config')
const logger = require('./utils/logger')
const authMiddleware = require('./middleware/authMiddleware')
const noteRoutes = require('./router/noteRoutes')
const authRoutes = require('./router/authRoutes')
const noteController = require('./controllers/noteController')
const app = express()
// βββ BODY PARSERS βββββββββββββββββββββββββββββββββββββββββ
// These two lines allow Express to read form data (req.body)
app.use(express.urlencoded({ extended: true })) // for HTML forms
app.use(express.json()) // for JSON body (API calls)
// βββ STATIC FILES βββββββββββββββββββββββββββββββββββββββββ
// Tells Express: "serve files from the public/ folder directly"
// So /style.css in the browser actually serves public/style.css
app.use(express.static('public'))
// βββ SESSION SETUP ββββββββββββββββββββββββββββββββββββββββ
app.use(session({
secret: SESSION_SECRET, // used to sign/encrypt the session cookie
resave: false, // don't save session if nothing changed
saveUninitialized: false, // don't create a session until something is stored
cookie: { maxAge: 1000 * 60 * 60 * 24 } // session lasts 1 day (in milliseconds)
}))
// βββ INJECT SESSION INTO ALL VIEWS βββββββββββββββββββββββ
// This middleware runs on EVERY request.
// It puts isLoggedIn and userName into res.locals so EVERY Handlebars
// template can use {{isLoggedIn}} and {{userName}} without us passing
// them manually in every controller.
app.use((req, res, next) => {
res.locals.isLoggedIn = !!req.session.userId // !! converts to true/false
res.locals.userName = req.session.userName || null
next()
})
// βββ HANDLEBARS ENGINE SETUP ββββββββββββββββββββββββββββββ
app.engine('handlebars', engine({
helpers: {
// Custom helper: allows {{#if (eq a b)}} comparisons in templates
eq: (a, b) => a === b
}
}))
app.set('view engine', 'handlebars') // tells Express to use handlebars by default
// βββ ROUTES βββββββββββββββββββββββββββββββββββββββββββββββ
// Home route β protected by authMiddleware (must be logged in)
app.get('/', authMiddleware, noteController.home)
// Auth routes β /auth/register, /auth/login, etc.
app.use('/auth', authRoutes)
// Note routes β /api/allNotes, /api/addNote, etc. (all protected inside)
app.use('/api', noteRoutes)
// βββ CONNECT DB AND START SERVER ββββββββββββββββββββββββββ
const connectDb = async () => {
await mongoose.connect(MONGODB_URL)
logger.info('MongoDB connected successfully')
app.listen(PORT, () => {
logger.info(`Server running at http://localhost:${PORT}`)
})
}
connectDb().catch(err => logger.error(`DB connection failed: ${err.message}`))Handlebars is a template engine. It lets you write HTML with dynamic placeholders like {{name}} which get replaced with real data when the page is rendered.
Create views/layouts/ folder, then create main.handlebars:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}} | NoteApp</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="page">
{{> _navbar}}
<main class="content">
{{{body}}}
</main>
{{> _footer}}
</div>
</body>
</html>Key concepts here:
{{{body}}}β this is where the content of each page gets injected (triple braces = unescaped HTML){{> _navbar}}β this inserts the_navbarpartial (a reusable piece of HTML)- Every page automatically gets the navbar and footer without you repeating the code
Create views/partials/_navbar.handlebars:
<nav id="navbar">
<div class="nav-logo">
<a href="/">π NoteApp</a>
</div>
<ul id="navLinks">
{{#if isLoggedIn}}
<li><a href="/">π Home</a></li>
<li><a href="/api/allNotes">π My Notes</a></li>
<li><a href="/api/addNote">β Add Note</a></li>
{{else}}
<li><a href="/auth/login">π Login</a></li>
<li><a href="/auth/register">π Register</a></li>
{{/if}}
</ul>
<div class="nav-right">
{{#if isLoggedIn}}
<span class="nav-user">π€ {{userName}}</span>
<a href="/auth/logout" class="nav-logout">πͺ Logout</a>
{{/if}}
</div>
</nav>Why {{#if isLoggedIn}}?
This comes from res.locals.isLoggedIn that we set in server.js. If the user is logged in, show Home + My Notes + Add Note + Logout. If not, only show Login + Register.
Create views/partials/_footer.handlebars:
<footer id="footer">
<p>Β© 2025 NoteApp β Built with β€οΈ</p>
</footer>Create views/auth/register.handlebars:
<div class="auth-page">
<div class="auth-card">
<div class="auth-logo">π</div>
<h2>Create Account</h2>
<p class="auth-sub">Join NoteApp and start capturing ideas</p>
{{#if error}}
<div class="alert alert-error">{{error}}</div>
{{/if}}
<form action="/auth/register" method="POST" class="auth-form">
<div class="form-group">
<label>Full Name</label>
<input type="text" name="name" placeholder="John Doe" required autofocus>
</div>
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" placeholder="[email protected]" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" placeholder="Min. 6 characters" minlength="6" required>
</div>
<button type="submit" class="auth-btn">Register β</button>
</form>
<p class="auth-switch">Already have an account? <a href="/auth/login">Login</a></p>
</div>
</div>Create views/auth/verifyOtp.handlebars:
<div class="auth-page">
<div class="auth-card">
<div class="auth-logo">π</div>
<h2>Verify Your Email</h2>
<p class="auth-sub">Enter the 6-digit OTP for <strong>{{email}}</strong></p>
{{#if error}}
<div class="alert alert-error">{{error}}</div>
{{/if}}
{{#if devOtp}}
<div class="alert alert-info">
<strong>Dev Mode OTP:</strong> {{devOtp}}<br>
<small>In production, this is sent via email.</small>
</div>
{{/if}}
<form action="/auth/verify-otp" method="POST" class="auth-form">
<input type="hidden" name="email" value="{{email}}">
<div class="form-group">
<label>OTP Code</label>
<input type="text" name="otp" placeholder="Enter 6-digit OTP"
maxlength="6" pattern="[0-9]{6}" required autofocus
style="letter-spacing:8px;font-size:22px;text-align:center;">
</div>
<button type="submit" class="auth-btn">Verify OTP β</button>
</form>
<p class="auth-switch">Wrong email? <a href="/auth/register">Register again</a></p>
</div>
</div>Create views/auth/login.handlebars:
<div class="auth-page">
<div class="auth-card">
<div class="auth-logo">π</div>
<h2>Welcome Back</h2>
<p class="auth-sub">Login to access your notes</p>
{{#if loginRequired}}
<div class="alert alert-warning">π Please login first to access that page.</div>
{{/if}}
{{#if verified}}
<div class="alert alert-success">β
Email verified! You can now login.</div>
{{/if}}
{{#if error}}
<div class="alert alert-error">{{error}}</div>
{{/if}}
<form action="/auth/login" method="POST" class="auth-form">
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" placeholder="[email protected]" required autofocus>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" placeholder="Your password" required>
</div>
<button type="submit" class="auth-btn">Login β</button>
</form>
<p class="auth-switch">No account? <a href="/auth/register">Register here</a></p>
</div>
</div><div id="home">
<div class="home-hero">
<div class="home-icon">π</div>
<h1>Welcome back, <span class="accent">{{userName}}</span>!</h1>
<p class="home-text">Your personal space to capture ideas, thoughts, goals and everything in between.</p>
<div class="home-actions">
<a href="/api/allNotes" class="hero-btn primary">View My Notes</a>
<a href="/api/addNote" class="hero-btn secondary">+ New Note</a>
</div>
</div>
</div>Create views/noteApp/addNote.handlebars:
<div class="form-page">
<div class="form-card">
<div class="form-header">
<span class="form-icon">π</span>
<h2>Add New Note</h2>
</div>
{{#if error}}
<div class="alert alert-error">{{error}}</div>
{{/if}}
<form action="/api/addNote" method="POST" class="note-form">
<div class="form-group">
<label>Note ID</label>
<input type="text" name="noteId" placeholder="e.g. NOTE-001" required>
</div>
<div class="form-group">
<label>Note Name</label>
<input type="text" name="noteName" placeholder="Enter note title" required>
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" placeholder="Write your note here..." required></textarea>
</div>
<div class="form-group">
<label>Status</label>
<select name="status">
<option value="active">π’ Active</option>
<option value="inactive">π΄ Inactive</option>
</select>
</div>
<div class="form-actions">
<a href="/api/allNotes" class="btn-outline">Cancel</a>
<button type="submit" class="btn-primary">Save Note</button>
</div>
</form>
</div>
</div>Create views/noteApp/allNotes.handlebars:
<div class="notes-page">
<div class="notes-header">
<h1>π My Notes</h1>
<a href="/api/addNote" class="btn-primary">+ Add Note</a>
</div>
<div class="notes-grid">
{{#each payload}}
<div class="note-card hover-card">
<div class="note-top">
<span class="note-id">{{noteId}}</span>
<span class="status {{status}}">{{status}}</span>
</div>
<h3>{{noteName}}</h3>
<p class="note-desc">{{description}}</p>
<div class="card-actions">
<a href="/api/{{_id}}" class="btn btn-view">π View</a>
<a href="/api/edit/{{_id}}" class="btn btn-edit">βοΈ Edit</a>
<a href="/api/delete/{{_id}}" class="btn btn-delete"
onclick="return confirm('Delete this note?')">π Delete</a>
</div>
</div>
{{else}}
<div class="empty-state">
<div class="empty-icon">ποΈ</div>
<h3>No notes yet</h3>
<p>Start capturing your thoughts!</p>
<a href="/api/addNote" class="btn-primary">Create First Note</a>
</div>
{{/each}}
</div>
</div>Create views/noteApp/editNote.handlebars:
<div class="form-page">
<div class="form-card">
<div class="form-header">
<span class="form-icon">βοΈ</span>
<h2>Edit Note</h2>
</div>
<form action="/api/edit/{{payload._id}}" method="POST" class="note-form">
<div class="form-group">
<label>Note ID</label>
<input type="text" name="noteId" value="{{payload.noteId}}" required>
</div>
<div class="form-group">
<label>Note Name</label>
<input type="text" name="noteName" value="{{payload.noteName}}" required>
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" required>{{payload.description}}</textarea>
</div>
<div class="form-group">
<label>Status</label>
<select name="status">
<option value="active" {{#if (eq payload.status "active")}}selected{{/if}}>π’ Active</option>
<option value="inactive" {{#if (eq payload.status "inactive")}}selected{{/if}}>π΄ Inactive</option>
</select>
</div>
<div class="form-actions">
<a href="/api/allNotes" class="btn-outline">Cancel</a>
<button type="submit" class="btn-primary">Update Note</button>
</div>
</form>
</div>
</div>Create views/noteApp/singleNote.handlebars:
<div class="single-page">
<div class="single-card">
<div class="single-top">
<span class="note-id">{{payload.noteId}}</span>
<span class="status {{payload.status}}">{{payload.status}}</span>
</div>
<h2>{{payload.noteName}}</h2>
<p class="note-desc">{{payload.description}}</p>
<div class="single-actions">
<a href="/api/allNotes" class="btn btn-view">β Back</a>
<a href="/api/edit/{{payload._id}}" class="btn btn-edit">βοΈ Edit</a>
<a href="/api/delete/{{payload._id}}" class="btn btn-delete"
onclick="return confirm('Delete this note?')">π Delete</a>
</div>
</div>
</div>The full CSS is included in the project ZIP file. Copy it as-is into public/style.css.
Key design decisions in the CSS:
- CSS variables (
--primary,--danger, etc.) make color changes easy β change in one place, updates everywhere .form-pageusesmin-height: calc(100vh - 60px - 56px)to fill the full screen height minus navbar and footer- The navbar uses
display: flexwithjustify-content: space-betweenfor logo-left, links-center, logout-right layout .notes-gridusesgrid-template-columns: repeat(auto-fill, minmax(280px, 1fr))β this automatically adjusts columns based on screen width
Winston needs this folder to exist before it can write log files:
mkdir logsUSER REGISTERS
β
βΌ
POST /auth/register
βββ Validate email not taken
βββ Hash password with bcrypt
βββ Save user to DB (isVerified: false)
βββ Generate 6-digit OTP
βββ Save OTP to DB (expires in 10 min)
βββ Log OTP via Winston
βββ Render verifyOtp page
USER ENTERS OTP
β
βΌ
POST /auth/verify-otp
βββ Find OTP record by email
βββ Check OTP matches
βββ Check OTP not expired
βββ Update user isVerified = true
βββ Delete OTP record from DB
βββ Redirect to /auth/login?verified=true
USER LOGS IN
β
βΌ
POST /auth/login
βββ Find user by email
βββ Check isVerified is true
βββ bcrypt.compare(typed password, hashed DB password)
βββ Save userId + userName in session
βββ Redirect to / (home page)
USER VISITS ANY NOTES PAGE
β
βΌ
authMiddleware runs first
βββ req.session.userId exists? β next() β controller runs
βββ req.session.userId missing? β redirect to /auth/login?msg=login_required
USER LOGS OUT
β
βΌ
GET /auth/logout
βββ req.session.destroy()
βββ Redirect to /auth/login
cd noteappnpm installOpen .env and confirm your MongoDB URL is correct:
PORT=3000
MONGODB_URL=mongodb://localhost:27017/Note_App
SESSION_SECRET=noteapp_super_secret_key_2025
# Check if MongoDB is running
mongosh
# If it opens a shell, MongoDB is running. Type 'exit' to close.node server.jsYou should see in your terminal:
[2025-04-09 10:00:00] INFO: MongoDB connected successfully
[2025-04-09 10:00:00] INFO: Server running at http://localhost:3000
Go to: http://localhost:3000
You will be redirected to the login page. Register a new account to get started.
| Method | URL | Protected? | What it does |
|---|---|---|---|
| GET | / |
β Yes | Home page (welcome screen) |
| GET | /auth/register |
β No | Show register form |
| POST | /auth/register |
β No | Process registration, send OTP |
| GET | /auth/verify-otp |
β No | Show OTP form |
| POST | /auth/verify-otp |
β No | Verify OTP code |
| GET | /auth/login |
β No | Show login form |
| POST | /auth/login |
β No | Process login, create session |
| GET | /auth/logout |
β No | Destroy session, redirect |
| GET | /api/allNotes |
β Yes | Show all notes for logged-in user |
| GET | /api/addNote |
β Yes | Show add note form |
| POST | /api/addNote |
β Yes | Save new note to DB |
| GET | /api/:id |
β Yes | Show single note by ID |
| GET | /api/edit/:id |
β Yes | Show edit form pre-filled |
| POST | /api/edit/:id |
β Yes | Update note in DB |
| GET | /api/delete/:id |
β Yes | Delete note from DB |
Cause: You forgot to run npm install
Fix:
npm installCause: MongoDB is not running Fix: Start MongoDB service (see Prerequisites above)
Cause: The logs/ folder doesn't exist
Fix:
mkdir logsCause: express-session is not set up before your routes
Fix: Make sure app.use(session(...)) comes before app.use('/api', noteRoutes) in server.js
Cause: You used {{ for HTML content β use {{{ for unescaped content, or make sure the variable is being passed correctly
Fix: Check that res.locals.userName is set in the app.use middleware in server.js
Cause: The user's isVerified field is false β they haven't verified their OTP yet
Fix: Check MongoDB β find the user and confirm isVerified: true. Or re-register.
Cause: You forgot userId: req.session.userId filter in your query
Fix: All Note.find() calls must include { userId: req.session.userId }
| What we did | Why it matters |
|---|---|
bcrypt.hash(password, 10) |
Even if DB is stolen, passwords are unreadable |
isVerified flag on User |
Prevents fake email registrations |
| OTP expires in 10 minutes | Time-limited codes are safer |
| Session-based auth | Server controls access, not the browser |
userId filter on all note queries |
Users can never access each other's data |
.env for secrets |
Credentials never in source code |
Once you are comfortable with this project, explore:
- Email integration β use
nodemailerto actually send OTP via email instead of showing it on screen - Input validation β use
express-validatorto validate form fields on the server - JWT tokens β an alternative to sessions (used in REST APIs)
- Rate limiting β use
express-rate-limitto block brute force login attempts - Pagination β show 10 notes per page when the list grows large
- Search β add a search bar that filters notes by name or description
Built with Node.js, Express, MongoDB, Handlebars, and Winston. This README was written as a teaching guide for beginner backend developers.