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-template
within the~/code/ga
folder. -
cd
into the newmern-project-template
folder and open it in VS Code. -
Open a Terminal in VS Code.
-
Run
npm init -y
to create apackage.json
file. -
To make the integrated project deployable on Heroku, update the "scripts" in the
package.json
to:"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
-
cd
into the newly createdfrontend
folder andnpm i
to install the Node modules for the React frontendnpm run dev
to 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.js
will proxy (forward) requests beginning with/api
to the Express backend running onlocalhost:3000
:export default defineConfig({ plugins: [react()], server: { proxy: { '/api': 'http://localhost:3000', }, }, });
-
Delete the
frontend/README.md
or move it to the root of the project. -
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
.env
file 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
. -
Create a folder named
backend
to be used for the Express server code. The new folder should appear right above thefrontend
folder in VS Code's Explorer. -
Create an empty
server.js
module within thebackend
folder.
-
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('*', 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.js
module thatserver.js
is 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.html
when 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.html
file 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.css
to 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.css
to 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
cd
into thefrontend
folder. -
Create a
components
and apages
folder within thefrontend/src
folder. -
For better organization, create an
App
folder in thepages
and move theApp.*
files into it. This change requires that the import inmain.jsx
be updated. -
Add
user
state - initialize tonull
for now. -
Install
react-router
. -
In
main.jsx
import{ 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-routers
Routes
&Route
components inApp.jsx
. -
Update
App.jsx
to 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 thepages
folder 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 onuser
state. Note that<NavLink>
components will add anactive
class 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.css
that'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
bcrypt
library used to hash passwords. -
Create a
User
model 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); }); module.exports = mongoose.model('User', userSchema);
-
Create a
<SignUpPage>
component with controlled inputs andformState
containingname
,email
,password
&confirm
properties. Also add a separateerrorMsg
state 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.jsx
to 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.js
responsible for communicating with the Express backend when signing up and logging in. Create aservices/authService.js
module in thesrc
folder. -
Discuss token-based authentication using JWTs (JSON Web Tokens).
-
Before we start coding a
signUp
method inauthService.js
let'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.js
module 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
signUp
function 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
authService
inSignUpPage.jsx
and call thesignUp
function 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 thesetUser
function - 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.js
backend/controllers/auth.js
-
In
routes/auth.js
we'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.js
to a starts with path of/api/auth
. -
The Express app now shows an error because the route is expecting a
signUp
function, 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
SECRET
to the.env
that 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
signUp
controller 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
user
state 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.jsx
to 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.js
module 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.js
module 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 in a router in server.js like this:
// API Routes app.use('/api/auth', require('./routes/auth')); // Routers mounted below ensureLoggedIn middleware // protects all routes defined in that router app.use(require('./middleware/ensureLoggedIn')); // All routes in routes/posts.js will be protected // app.use('/api/posts', require('./routes/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);