This repo can be used to start a React+Express project fully equipped with Auth for user creation and login.
Table of Contents
- Fork this template repo
- Copy the
.env.templateand name it.env - Create a database called
react_auth_exampledatabase (or update your new.envto whatever database you are using) - Double check that the
.envvariables are all correct (username, password, database name) npm run kickstart(npm run devornpm startafterwards). This will do the following commands all together:cd front-end && npm i && cd ..- installs front end dependenciesnpm i- installs all dependenciesnpm run migrate- runsknex migrate:latestwhich will run the provided migration file (look in thesrc/db/migrationsfolder)npm run seed- runsknex seed:runwhich will run the provided seed file (look insrc/db/seedsfolder)npm run start- runsnode src/index.js, starting your server.
- Then, open a new terminal and
cdintofront-end. Then runnpm run devto start your Vite development server.
The provided migration and seeds file will create a users table with id, username, and password_hash columns.
- For an overview of migrations and seeds, check out these notes.
- If you need to update these columns, consider looking into the alterTable Knex documentation.
- If creating a new table, look at the createTable documentation.
Run the npm run dev command from the root directory to start your Express server.
The Express server is configured to serve static assets from the public/ folder. Those static assets are the current build of the React front-end found in the front-end/ folder. You can see the built version of the React front-end by going to the server's address: http://localhost:3000/
In order to update this built version of your React application, you will need to run the npm run build command from the front-end/ folder.
If you would like to work on the front-end without having to constantly rebuild the project, start a Vite dev server by running the npm run dev command from the front-end/ folder.
If you look in the vite.config.js file, you will see that we've already configured the dev server to proxy any requests made to /api to the back-end server.
front-end/- the front-end application code (React)public/- the front-end application's compiled static assetssrc/- the back-end server application code
The package.json file in the root directory defines the dependencies and scripts for running the back-end server.
The front-end/package.json file defines the dependencies and scripts for running the front-end Vite server.
The front-end React application's entrypoint is the index.html file which loads in the main.jsx script. This script renders the top-level App component which may render various page components. The adapter files manage data-fetching logic while context files manage global front-end state.
All of the adapters make use of the fetchHandler helper function defined in the frontend/src/utils.js file:
export const fetchHandler = async (url, options = {}) => {
try {
const response = await fetch(url, options);
const { ok, status, headers } = response;
if (!ok) throw new Error(`Fetch failed with status - ${status}`, { cause: status });
const isJson = (headers.get('content-type') || '').includes('application/json');
const responseData = await (isJson ? response.json() : response.text());
return [responseData, null];
} catch (error) {
console.warn(error);
return [null, error];
}
};This function standardizes the way that fetched data will be packaged and returned by the adapters. This function will ALWAYS return a "tuple" — an array with two values.
- The first value is the fetched
data(if present) - The second value is the
error(if present).
Only one of these two values will ever be present while the other will be null. This pattern gives us an easy way to access data (if present) or the error (if present).
An adapter's sole responsibility is to wrap around the fetch logic making it incredibly easy for front-end components to execute a particular type of fetch and utilize the returned data.
Often, they will be short, like this from the adapters/user-adapter.js file:
const baseUrl = '/api/users';
export const getAllUsers = async () => {
const [users, error] = await fetchHandler(baseUrl);
if (error) console.log(error); // print the error for simplicity.
return users || [];
};- A
baseUrlis defined for all adapters in thisuser-adapterfile. - The
fetchHandlerwill return a tuple with either theusersdata or theerror. - Here, we print the
errorif it exists but in more robust applications, errors would be handled more gracefully, or they would potentially be returned. - If
usersexists, we'll return it, otherwise return an empty array (thus ignoring theerror).
The frontend/src/pages/Users.jsx page provides a clean and simple example of how a front-end page can fetch and then render data from the backend. This page is responsible for fetching and displaying a list of all users in the database:
import { useEffect, useState } from "react";
import { getAllUsers } from "../adapters/user-adapter";
import UserLink from "../components/UserLink";
export default function UsersPage() {
const [users, setUsers] = useState([]);
useEffect(() => {
getAllUsers().then(setUsers);
}, []);
return <>
<h1>Users</h1>
<ul>
{
users.map((user) => <li key={user.id}><UserLink user={user} /></li>)
}
</ul>
</>;
}- The
useStatehook is created to manage the fetchedusers. On the first render, theusersarray will be empty. When the fetch is complete,userswill hold the fetched users. - The
useEffecthook initiates an asynchronous fetch of all users, making use of thegetAllUsershelper function from theadapters/user-adapterfile. When this fetch is complete,setUserswill be invoked to re-render the component with the fetchedusers. - The
usersarray is mapped to render aUserLinkfor each user. On the first render, nothing will appear. When the fetch is complete and the component re-renders, we will see all users.
The back-end is responsible for receiving and responding to client requests. Requests are received by the server, routed by the router, and parsed by the controller. The controller then passes along data from the request to the model to perform CRUD operations on the database before sending a response back to the client.
The provided back-end exposes the following API endpoints defined in src/routes.js:
| Method | Path | Description |
|---|---|---|
| GET | /users | Get the list of all users |
| GET | /me | Get the current logged in user based on the cookie |
| GET | /users/:id | Get a specific user by id |
| POST | /users | Create a new user |
| POST | /login | Log in to an existing user |
| PATCH | /users/:id | Update the username of a specific user by id |
| DELETE | /logout | Log the current user out |
In src/server.js and in src/routes.js, various pieces of middleware are used. These pieces of middleware are either provided by express or are custom-made and found in the src/middleware/ folder
Express Middleware
app.use(express.json());- We are telling Express to parse incoming data as JSON
app.use(express.static(path.join(__dirname, "..", "public")));- We are telling Express to serve static assets from the
public/folder
app.use("/api", routes);routesis the Router exported fromsrc/routes.js. We are telling Express to send any requests starting with/apito that Router.
Custom Middleware
app.use(handleCookieSessions);handleCookieSessionsadds areq.sessionobject to everyreqcoming into the server. (seesrc/middleware/handle-cookie-sessions)
Router.use(addModels);addModelsadds areq.dbproperty to all incoming requests. This is an object containing the models imported from thedb/models/folder (seesrc/middleware/add-model)
Router.patch("/users/:id", checkAuthentication, userController.update);checkAuthenticationverifies that the current user is logged in before processing the request. (seesrc/middleware/check-authentication)- Here, we specify middleware for a singular route. Only logged-in users should be able to hit this endpoint.
-
authenticated means "We have confirmed this person is who they say they are"
-
authorized means "This person is who they say they are AND they are allowed to be here."
So if we just want a user to be logged into the site to show content, we just check if they're authenticated.
However, if they wanted to update their profile info, we'd need to make sure they were authorized to do that (e.g. the profile they're updating is their own).
In the context of computing and the internet, a cookie is a small text file that is sent by a website to your web browser and stored on your computer or mobile device.
Cookies contain information about your preferences and interactions with the website, such as login information, shopping cart contents, or browsing history.
When you visit the website again, the server retrieves the information from the cookie to personalize your experience and provide you with relevant content.
In our application, we are using cookies to store the userId of the currently logged-in user on the req.session object. This will allow us to implement authentication (confirm that the user is logged in).
The flow of cookie data looks like this:
- When a request comes in for sign up/login, the server creates a cookie (the
handle-cookie-sessionsmiddleware does this for us). That cookie is an object calledsessionthat is added to each requestreq. - The model will store the user data in the database (or look it up for
/login) and return back the user with it's uniqueuser.id - When we get the
Userback from the model, we store theuser.idin that cookie (session.userId = user.id) - Now, that cookie lives with every request made by that user (
req.session) and the client can check if it is logged in using the/api/meendpoint (see below).
In order to keep source of truth simple, we're going to track who is logged in with that GET /api/me convention.
- Each time a page is loaded, we quickly hit
GET /api/me. - If there is a logged in user, we'll see that in the json.
The reason this route is used instead of GET /api/users/:id is two fold.
- We don't know the user's
idon load, so how could we know whichidto provide in the URL? GETREST routes are supposed to be idempotent (eye-dem-PO-tent) which means "don't change."GET /api/mewill change depending on the auth cookie. So, this little example app also has aGET /api/users/:idroute becauseGET /api/meis not a replacement for it.GET /api/users:idisn't used in the client yet but your projects might in the future if you ever want to find a particular user by id (or username)!
We recommend deploying using Render.com. It offers free hosting of web servers and PostgreSQL databases with minimal limitations.
Follow the steps below to create a PostgreSQL database hosted by Render and deploy a web application forked from this repository:
-
Make an account on https://render.com/
-
Create a PostgreSQL Server
- https://dashboard.render.com/ and click on New +
- Select PostgreSQL
- Fill out information for your DB
- Region:
US East (Ohio) - Instance Type: Free
- Region:
- Select Create Database
- Keep the created database page open. You will need the
Internal Database URLvalue from this page for step 4
-
Deploy Your Express Server
- https://dashboard.render.com/ and click on New +
- Select Web Service
- Connect your GitHub account (if not connected already)
- Find your repository and select Connect
- Fill out the information for your Server
- Name: the name of your app
- Region:
US East (Ohio)- the important thing is that it matches the PostgreSQL region - Branch:
main - Root Directory: leave this blank
- Runtime:
Node - Build Command:
npm build - Start Command:
npm start - Instance Type: Free
- Select Create Web Service (Note: The first build will fail because you need to set up environment variables)
-
Set up environment variables
- From the Web Service you just created, select Environment on the left side-menu
- Under Secret Files, select Add Secret File
-
Filename:
.env -
Contents:
- Look at your local
.envfile and copy over theSESSION_SECRETvariable and value. - Add a
PG_CONNECTION_STRINGvariable. Its value should be theInternal Database URLvalue from your Postgres page (created in step 2) - Add a
NODE_ENVvariable with the value'production' - The contents should look like this:
SESSION_SECRET='AS12FD42FKJ42FIE3WOIWEUR1283' PG_CONNECTION_STRING='postgresql://user:password@host/dbname' NODE_ENV='production'
- Look at your local
-
- Click Save Changes
-
Future changes to your code
- If you followed these steps, your Render server will automatically redeploy whenever the main branch is committed to. To update the deployed application, simply commit to main.
- For front-end changes, make sure to run
npm run buildto update the contents of thepublic/folder and push those changes.
Remember, DO NOT TRUST THE FRONT-END. Validate everything on the server. Just because you write logic to prevent a form from submitting on the front-end doesn't mean a nefarious actor couldn't just pop open a console and make a fetch request there. Also, the front-end can be buggy and mistakes can happen.
Given time constraints, this project is handling barely any errors. The model is very brittle right now, the server and sql errors should be handled like we've done before. We're also only handling the most basic of flows and errors on the client. Things like handling attempted recreations of users who already exist or even wrong passwords can be handled much more delicately.