Today's plan to code a mern-project-template that includes JWT authentication and can be used to start any MERN-Stack project, including Project 3!
-
Create a folder named
mern-project-templatewithin the~/code/gafolder. -
cdinto the newmern-project-templatefolder and open it in VS Code. -
Open a Terminal in VS Code.
-
Run
npm init -yto create apackage.jsonfile. -
To make the integrated project deployable on Heroku, update the "scripts" in the
package.jsonto:"scripts": { "start": "node ./backend/server.js", "build": "npm install && npm --prefix ./frontend install && npm --prefix ./frontend run build" },
-
Update the "main" script to reference the yet created server.js:
"main": "./backend/server.js",
-
Create a React frontend project:
npm create vite@latest frontend- If prompted to install any packages, answer
y - When prompted to select a framework, choose
React - When prompted to select a variant, choose
JavaScript
- If prompted to install any packages, answer
-
cdinto the newly createdfrontendfolder andnpm ito install the Node modules for the React frontendnpm run devto start the React dev server- Browse to
localhost:5173
-
Add to the rules within
eslint.config.js(last two entries):rules: { ... 'react/prop-types': 'off', 'react/no-unescaped-entities': 'off', },
-
During development, the fetch requests from the React app will be sent to the React Dev Server, however, those fetch requests are intended for the Express server. The following update to
vite.config.jswill proxy (forward) requests beginning with/apito the Express backend running onlocalhost:3000:export default defineConfig({ plugins: [react()], server: { proxy: { '/api': 'http://localhost:3000', }, }, });
-
Delete the
frontend/README.mdor move it to the root of the project. -
Update the
<title>element inindex.htmlto something like "MERN-Stack!". Be sure to update for each project you create! -
Open another Terminal in VS Code (it takes two Terminal sessions to develop in the MERN-Stack) and ensure that the working directory is the project root.
-
Since there are two terminal sessions required, consider renaming and updating their color, e.g.:
- Rename the React terminal (
npm run dev) to "React" and update its color to cyan. - Rename the new terminal to "Express" and update its color to green.
- Rename the React terminal (
-
Create a
.envfile in the root of the project and add your Atlas/MongoDB database connection string, e.g.:MONGODB_URI=mongodb+srv://seb:[email protected]/mern-project-template?retryWrites=true&w=majority&appName=Cluster0 -
Change the database name to something like
mern-project-template. Be sure to use a different database name for each project created using this template. -
Create a folder named
backendto be used for the Express server code. The new folder should appear right above thefrontendfolder in VS Code's Explorer. -
Create an empty
server.jsmodule within thebackendfolder.
-
Install the initial set of Node modules for the backend:
npm i express dotenv morgan mongoose -
Code a minimal
server.jsπ Click to View Code
const path = require('path'); // Built into Node const express = require('express'); const logger = require('morgan'); const app = express(); // Process the secrets/config vars in .env require('dotenv').config(); // Connect to the database require('./db'); app.use(logger('dev')); // Serve static assets from the frontend's built code folder (dist) app.use(express.static(path.join(__dirname, '../frontend/dist'))); // Note that express.urlencoded middleware is not needed // because forms are not submitted! app.use(express.json()); // API Routes // Use a "catch-all" route to deliver the frontend's production index.html app.get('/*splat', function (req, res) { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); }); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`The express app is listening on ${port}`); });
-
Create a
backend/db.jsmodule thatserver.jsis using to connect to the database. Add the familiar code to connect to the database.π Click to View Code
const mongoose = require('mongoose'); mongoose.connect(process.env.MONGODB_URI); mongoose.connection.on('connected', () => { console.log(`Connected to MongoDB ${mongoose.connection.name}`); });
-
Note that the Express server has a "catch-all" route used to deliver the frontend's production
index.htmlwhen the app is browsed to after it's deployed (also at localhost:3000 - but we only want to browse to localhost:5173 during development). Express will throw an error if the../frontend/dist/index.htmlfile does not exist. Let's use the build script to create it:npm run build -
Let's now start the Express server for development using the familiar
nodemon. You should see the following messages:The express app is listening on 3000 Connected to MongoDB mern-project-template -
If you browse to localhost:3000, you should see the "Vite + React" page. However, this is the built/production React frontend and again, you don't want to browse localhost:3000 during development - so close the tab!
-
Cleanup the code in
App.jsx:π Click to View Code
import { useState } from 'react'; import './App.css'; export default function App() { return ( <main className="App"> <section id="main-section">In progress...</section> </main> ); }
-
Update
App.cssto the following CSS:π Click to View CSS
.App { display: grid; grid-template-rows: auto auto; } #main-section { display: flex; flex-direction: column; justify-content: center; align-items: center; }
-
Update
index.cssto the following CSS:π Click to View CSS
:root { font-family: Arial, Helvetica, sans-serif; line-height: 1.5; font-weight: 400; color: #1a1a1a; background-color: whitesmoke; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; } h1, h2 { text-align: center; } button { border: 0.5vmin solid #1a1a1a; border-radius: 0.5vmin; padding: 0.8vmin; font-size: 2vmin; font-weight: 500; font-family: inherit; background-color: #1a1a1a; color: whitesmoke; cursor: pointer; } button:hover { background-color: white; color: #1a1a1a; border-color: #1a1a1a; } button:disabled { background-color: grey; } form { display: grid; gap: 1.2vmin; padding: 4vmin; border: 0.5vmin solid #1a1a1a; border-radius: 1vmin; } input, select, textarea { padding: 0.7vmin; font-size: 2vmin; border: 0.3vmin solid #1a1a1a; border-radius: 0.5vmin; } form > button:last-child { margin-top: 2vmin; } label { font-size: 2vmin; font-weight: bold; } h2 { text-align: center; } .error-message { color: rgb(166, 29, 29); }
-
Open a third Terminal session and
cdinto thefrontendfolder. -
Create a
componentsand apagesfolder within thefrontend/srcfolder. -
For better organization, create an
Appfolder in thepagesand move theApp.*files into it. This change requires that the import inmain.jsxbe updated. -
Add
userstate - initialize tonullfor now. -
Install
react-router. -
In
main.jsximport{ BrowserRouter as Router }and wrap<App />with it.π Click to View Code
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter as Router } from 'react-router'; import './index.css'; import App from './pages/App/App.jsx'; createRoot(document.getElementById('root')).render( <StrictMode> <Router> <App /> </Router> </StrictMode> );
-
Import react-router's
Routes&Routecomponents inApp.jsx. -
Update
App.jsxto include two blocks of<Routes>, one for when there's a user logged in, the other when not:π Click to View Code
<section id="main-section"> {user ? ( <Routes> <Route path="/" element={<HomePage />} /> <Route path="/posts" element={<PostListPage />} /> <Route path="/posts/new" element={<NewPostPage />} /> </Routes> ) : ( <Routes> <Route path="/" element={<HomePage />} /> </Routes> )} </section>
-
We're going to need the
<HomePage>,<PostListPage>&<NewPostPage>page-level components (components that are rendered by<Route>components) we just referenced. Be sure to create them in their own folder within thepagesfolder and import them inApp.jsx:π Click to View Code
Create basic components that simply render a basic page title...
export default function HomePage() { return <h1>Home Page</h1>; }
-
Create a
<NavBar>component that renders dynamically based onuserstate. Note that<NavLink>components will add anactiveclass to the link if it's currently browsed to:π Click to View Code
import { NavLink, Link } from 'react-router'; import './NavBar.css'; export default function NavBar({ user }) { return ( <nav className="NavBar"> <NavLink to="/">Home</NavLink> | {user ? ( <> <NavLink to="/posts" end> Post List </NavLink> | <NavLink to="/posts/new">New Post</NavLink> | {/* TODO: Add Log Out Link */} <span>Welcome, {user.name}</span> </> ) : ( <> <NavLink to="/login">Log In</NavLink> | <NavLink to="/signup">Sign Up</NavLink> </> )} </nav> ); }
-
We need to add that
NavBar.cssthat's being imported:π Click to View CSS
.NavBar { display: flex; justify-content: space-around; align-items: center; padding: 2vmin; font-size: 2vmin; } .NavBar { a { border-radius: 0.8vmin; padding: 0.5vmin 1.5vmin; text-decoration: none; color: #1a1a1a; } a:hover { background-color: #1a1a1a; color: whitesmoke; } a.active { background-color: rebeccapurple; color: whitesmoke; } span { color: rgb(108, 9, 87); } }
AAV, I want to click the
[Sign Up]link in the nav to see a Sign Up form and sign up so that I can access the app's functionality it has to offer.
-
Be sure that you're in the root of the project and install the
bcryptlibrary used to hash passwords. -
Create a
Usermodel that automatically hashes the password using bcrypt:π Click to View Code
const mongoose = require('mongoose'); const Schema = mongoose.Schema; const bcrypt = require('bcrypt'); const SALT_ROUNDS = 6; const userSchema = new Schema( { name: { type: String, required: true }, email: { type: String, unique: true, trim: true, lowercase: true, required: true, }, password: { type: String, required: true, }, }, { timestamps: true, // Remove password when doc is sent across network toJSON: { transform: function (doc, ret) { delete ret.password; return ret; }, }, } ); userSchema.pre('save', async function (next) { // 'this' is the user document if (!this.isModified('password')) return next(); // Replace the password with the computed hash this.password = await bcrypt.hash(this.password, SALT_ROUNDS); next(); }); module.exports = mongoose.model('User', userSchema);
-
Create a
<SignUpPage>component with controlled inputs andformStatecontainingname,email,password&confirmproperties. Also add a separateerrorMsgstate variable:π Click to View Code
import { useState } from 'react'; import { useNavigate } from 'react-router'; export default function SignUpPage() { const [formData, setFormData] = useState({ name: '', email: '', password: '', confirm: '', }); const [errorMsg, setErrorMsg] = useState(''); const navigate = useNavigate(); function handleChange(evt) { setFormData({ ...formData, [evt.target.name]: evt.target.value }); setErrorMsg(''); } const disable = formData.password !== formData.confirm; return ( <> <h2>Sign Up!</h2> <form autoComplete="off"> <label>Name</label> <input type="text" name="name" value={formData.name} onChange={handleChange} required /> <label>Email</label> <input type="email" name="email" value={formData.email} onChange={handleChange} required /> <label>Password</label> <input type="password" name="password" value={formData.password} onChange={handleChange} required /> <label>Confirm</label> <input type="password" name="confirm" value={formData.confirm} onChange={handleChange} required /> <button type="submit" disabled={disable}> SIGN UP </button> </form> <p className="error-message"> {errorMsg}</p> </> ); }
-
Add a
<Route>inApp.jsxto handle when the "Sign Up" link is clicked and renders the<SignUpPage>component.π Click to View Code
... <Routes> <Route path="/" element={<HomePage />} /> <Route path="/signup" element={<SignUpPage />} /> </Routes>
-
We're going to need an
authService.jsresponsible for communicating with the Express backend when signing up and logging in. Create aservices/authService.jsmodule in thesrcfolder. -
Discuss token-based authentication using JWTs (JSON Web Tokens).
-
Before we start coding a
signUpmethod inauthService.jslet's consider how much similar fetch code was repeated when we CRUD'd pets in thepetService. Now consider that there's additional code needed to send the logged in user's token with each request. Finally, consider that very similar code would be repeated for each additional data resource. Yep, not very DRY. When you have the same or similar code being repeated, it's a candidate for refactoring into a reusable function. Create asrc/services/sendRequest.jsmodule and let's code this mighty code saver:π Click to View Code
import { getToken } from './authService'; export default async function sendRequest( url, method = 'GET', payload = null ) { // Fetch accepts an options object as the 2nd argument // used to include a data payload, set headers, specifiy the method, etc. const options = { method }; // If payload is a FormData object (used to upload files), // fetch will automatically set the Content-Type to 'multipart/form-data', // otherwise set the Content-Type header as usual if (payload instanceof FormData) { options.body = payload; } else if (payload) { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify(payload); } const token = getToken(); if (token) { // Need to add an Authorization header // Use the Logical OR Assignment operator options.headers ||= {}; // Older approach // options.headers = options.headers || {}; options.headers.Authorization = `Bearer ${token}`; } const res = await fetch(url, options); // if res.ok is false then something went wrong if (res.ok) return res.json(); // Obtain error sent from server const err = await res.json(); // Throw error to be handled in React throw new Error(err.message); }
-
sendRequest()depends upon agetToken()method that retrieves the JWT from local storage and verifies that it isn't expired:π Click to View Code
export function getToken() { // getItem returns null if there's no key const token = localStorage.getItem('token'); if (!token) return null; const payload = JSON.parse(atob(token.split('.')[1])); // A JWT's exp is expressed in seconds, not milliseconds, so convert if (payload.exp * 1000 < Date.now()) { localStorage.removeItem('token'); return null; } return token; }
-
Now's a good time to code the
signUpfunction in theauthService:π Click to View Code
import sendRequest from './sendRequest'; const BASE_URL = '/api/auth'; export async function signUp(userData) { const token = await sendRequest(`${BASE_URL}/signup`, 'POST', userData); localStorage.setItem('token', token); // Return the user object from the token to component return getUser(); } export function getUser() { const token = getToken(); return token ? JSON.parse(atob(token.split('.')[1])).user : null; } export function getToken() { // getItem returns null if there's no key const token = localStorage.getItem('token'); if (!token) return null; const payload = JSON.parse(atob(token.split('.')[1])); // A JWT's exp is expressed in seconds, not milliseconds, so convert if (payload.exp * 1000 < Date.now()) { localStorage.removeItem('token'); return null; } return token; }
-
Let's import the
authServiceinSignUpPage.jsxand call thesignUpfunction when the[SIGN UP]button is clicked.π Click to View Code
import { useState } from 'react'; import * as authService from '../../services/authService'; export default function SignUpPage() { ... async function handleSubmit(evt) { evt.preventDefault(); try { const user = await authService.signUp(formData); setUser(user); navigate('/posts'); } catch (err) { console.log(err); setErrorMsg('Sign Up Failed - Try Again'); } } ... <form autoComplete="off" onSubmit={handleSubmit}>
-
Looks like
<SignUpPage>needs access to thesetUserfunction - you got this!
-
We'll get a nice "Sign Up Failed - Try Again" error if we try to sign up because the server doesn't have routing or controller code yet! Create the following modules:
backend/routes/auth.jsbackend/controllers/auth.js
-
In
routes/auth.jswe'll only define the routes and use controller functions that are defined incontrollers/auth.js. This is a best practice way to organize code in Express. Let's define a route to handle sign up:π Click to View Code
const express = require('express'); const router = express.Router(); const authCtrl = require('../controllers/auth'); // All paths start with '/api/auth' // POST /api/auth/signup router.post('/signup', authCtrl.signUp); module.exports = router;
-
It's so easy to forget to mount new routers in
server.js! Mountroutes/auth.jsto a starts with path of/api/auth. -
The Express app now shows an error because the route is expecting a
signUpfunction, but we're not exporting one from the controller yet. If we stub one up, we'll see the error go away:π Click to View Code
const User = require('../models/user'); module.exports = { signUp, }; async function signUp(req, res) {}
-
Before we can create a JWT in the signUp function, we need make sure that we're in the root of the project then
npm i jsonwebtoken. -
We're also going to need to add a
SECRETto the.envthat will be used to "sign" the JWT:MONGODB_URI=mongodb+srv://seb:[email protected]/mern-project-template?retryWrites=true&w=majority&appName=Cluster0 SECRET=SEBRocks -
Now we can code the
signUpcontroller function to create the user document and return a JWT:π Click to View Code
const User = require('../models/user'); const jwt = require('jsonwebtoken'); module.exports = { signUp, }; async function signUp(req, res) { try { const user = await User.create(req.body); const token = createJWT(user); res.json(token); } catch (err) { res.status(400).json({ message: 'Duplicate Email' }); } } /*--- Helper Functions ---*/ function createJWT(user) { return jwt.sign( // data payload { user }, process.env.SECRET, { expiresIn: '24h' } ); }
-
Bingo! If you get an error, be sure to use a unique email address that hasn't been used. If successful, you can observe the JWT in local storage using Chrome DevTool's Application tab.
If we refresh the page, the user state will be initialize back to null despite the fact that there's a valid JWT in localStorage. We can resolve this issue by using the authService.getUser() function to initialize the user state in App.jsx. Let's import just the getUser() function:
import { getUser } from '../../services/authService';and put it to work!
const [user, setUser] = useState(getUser());Logging out is just a matter of:
- Delete the JWT from local storage
- Set
userstate tonull
-
Starting with the UI, let's add a
[Log Out]link in<NavBar>:π Click to View Code
| <a onClick={handleLogOut}>Log Out</a> | <span>Welcome, {user.name}</span>
-
Here's that
handleLogOut()function:π Click to View Code
// Add useNavigate import { NavLink, Link, useNavigate } from 'react-router'; ... const navigate = useNavigate(); function handleLogOut(evt) { evt.preventDefault(); authService.logOut(); setUser(null); navigate('/'); }
-
Looking at handleLogOut, what else do we need to do?
-
Finish logging out functionality by coding a simple
logOut()function inauthService.js:π Click to View Code
export function logOut() { localStorage.removeItem('token'); }
Logging in is very much like signing up, so let's rock this...
-
Here's the
<LogInPage>component - a review of the code will show that it's very similar to the<SignUpPage>:π Click to View Code
import { useState } from 'react'; import { useNavigate } from 'react-router'; import * as authService from '../../services/authService'; export default function LogInPage({ setUser }) { const [formData, setFormData] = useState({ email: '', password: '', }); const [errorMsg, setErrorMsg] = useState(''); const navigate = useNavigate(); async function handleSubmit(evt) { evt.preventDefault(); try { const user = await authService.logIn(formData); setUser(user); navigate('/posts'); } catch (err) { setErrorMsg('Log In Failed - Try Again'); } } function handleChange(evt) { setFormData({ ...formData, [evt.target.name]: evt.target.value }); setErrorMsg(''); } return ( <> <h2>Log In!</h2> <form autoComplete="off" onSubmit={handleSubmit}> <label>Email</label> <input type="email" name="email" value={formData.email} onChange={handleChange} required /> <label>Password</label> <input type="password" name="password" value={formData.password} onChange={handleChange} required /> <button type="submit">LOG IN</button> </form> <p className="error-message"> {errorMsg}</p> </> ); }
-
Add the
<Route>inApp.jsxto match the "Log In" link. -
Now we need the
authService.logIn()function:π Click to View Code
export async function logIn(credentials) { const token = await sendRequest(`${BASE_URL}/login`, 'POST', credentials); localStorage.setItem('token', token); return getUser(); }
-
Okay, we're sending the HTTP request to the server. Now what? Try not to peek π:
π Click to View Code
// routes/auth.js ... // POST /api/auth/login router.post('/login', authCtrl.logIn);
-
The Express server will crash until we code the
login()controller function:π Click to View Code
async function logIn(req, res) { try { const user = await User.findOne({ email: req.body.email }); if (!user) throw new Error(); const match = await bcrypt.compare(req.body.password, user.password); if (!match) throw new Error(); const token = createJWT(user); res.json(token); } catch (err) { res.status(400).json({ message: 'Bad Credentials' }); } }
-
Try logging in. Congrats!
Yep, custom middleware to the rescue!
-
Create a
backend/middleware/checkToken.jsmodule with the following code:π Click to View Code
const jwt = require('jsonwebtoken'); module.exports = function (req, res, next) { // Check for the token being sent in a header or as a query param let token = req.get('Authorization') || req.query.token; // Default to null req.user = null; if (!token) return next(); // Remove the 'Bearer ' that was included in the token header token = token.replace('Bearer ', ''); // Check if token is valid and not expired jwt.verify(token, process.env.SECRET, function (err, decoded) { // Invalid token if err if (err) return next(); // decoded is the entire token payload req.user = decoded.user; return next(); }); };
-
Mount that middleware in
server.js:π Click to View Code
... app.use(express.json()); // Middleware to check the request's headers for a JWT and // verify that it's a valid. If so, it will assign the // user object in the JWT's payload to req.user app.use(require('./middleware/checkToken'));
One last thing, as always, remember to protect routes in your app that require a logged in...
-
Add a
middleware/ensureLoggedIn.jsmodule and add the following somewhat familiar bit of code:π Click to View Code
module.exports = function (req, res, next) { if (!req.user) return res.status(401).json({ message: 'Unauthorized' }); next(); };
-
Now you can protect all routes defined in a router in like this:
const express = require('express'); const router = express.Router(); // Middleware used to protect routes that need a logged in user const ensureLoggedIn = require('../middleware/ensure-logged-in'); // This is how we can more easily protect ALL routes for this router router.use(ensureLoggedIn); // ALL paths start with '/api/posts'
Or protect individual routes within routers like this:
const express = require('express'); const router = express.Router(); const postsCtrl = require('../controllers/posts'); const ensureLoggedIn = require('../middleware/ensureLoggedIn'); // All paths start with '/api/posts' // GET /api/posts router.post('/', ensureLoggedIn, postsCtrl.index);
