Build and Deploy a Single Page App with React, Vite, and Netlify Functions

Table of Contents

React has emerged back in 2011 at Facebook (now Meta) and has continued evolving rapidly and gaining a significant ecosystem with a wide variety of plugins and UI frameworks on top of it.

This guide will cover in detail the steps to create a working example bookstore single page application using the latest React 18 and run it using the Vite. It also includes details on how to add a custom state management solution and routing using the wouter library.

The present article is the React equivalent of the Vue 3+Vite article which we’ve published recently on this blog. The end result is the same for both (a bookstore single page app which we’ve called Middlemarch). However, this tutorial also contains a backend solution using serverless functions.

Here’s main tasks that we’ll focus on over the course of this tutorial:

  1. bootstrap the application skeleton with Vite
  2. manage routes with the wouter library
  3. create an internal state management tool
  4. create and run netlify functions
  5. write React component tests with Nightwatch
  6. write and run automated end-to-end tests with Nightwatch
  7. build and deploy the application with Github Actions

This is a large undertaking, but it accurately describes the whole development cycle. The application will be deployed to Netlify. If you're eager to get right down to coding and you'd like to jump into it right away, you can just get the project up and running with:

git clone https://github.com/pineviewlabs/middlemarch-react
cd middlemarch-react
npm install
npm start

Or fork the project at https://github.com/pineviewlabs/middlemarch-react.

Step 1 – bootstrap the Application skeleton with Vite

We will use the create-vite CLI to bootstrap the basic structure and dependencies.

npm create vite middlemarch -- --template react

Then follow the instructions that appear at the end, e.g.:

Scaffolding project in /home/projects/middlemarch...

Done. Now run:

  cd middlemarch
  npm install
  npm run dev

The folder structure will likely look similar to:

 /
 ├── src/
 |    ├── App.css
 |    ├── App.jsx
 |    ├── favicon.ico
 |    ├── index.css
 |    ├── logo.svg
 |    └── main.jsx
 ├─── .gitignore
 ├─── index.html
 ├─── package.json
 └─── vite.config.js

We don't need the App.css and logo.svg, so you may delete them right away. The last thing we need to do is replace default scripts with these:

{
  "scripts": {
    "vite:start": "vite",
    "vite:build": "vite build"
  }
}
💡
We have added the vite: prefix to distinguish the front-end build part from the serverless functions. All about it later.

Prepare the App.jsx file

At this point we have prepared the development environment. Let's dig into React and update the  App.jsx file to match the following:

import { StrictMode } from "react";

export default () => (
  <StrictMode>
    <main></main>
  </StrictMode>
);
💡
StrictMode highlights potential problems in the application, which you may not notice. It's the good practice to wrap the whole application in it.

Now run the npm vite:start script to get the application running at http://localhost:3000

npm run vite:start

At this point you will see an empty page with the <main> element under the #root. The next step is to write pages with the actual content to see something in the browser.


Step 2 – Add routes

It's time to create our application's main routes. In React every page is just a separate component. For this application, we'll consider the following components:

  • Home – the very first page that will be served at the / base URL
  • Cart – a page that contains the list of products; it is available only to logged-in users
  • Sign-in – the login form
  • Register – the user sign-up form

For the sake of the simplicity, I will show you a full implementation of the Home page only. But you may always go to the Github repository to see the final source code of the application.

The whole application will have the following markup:

Page markup

Let's create the Header and Footer components which remain the same across every page:

mkdir src/components
mkdir src/components/Header
mkdir src/components/Footer
touch src/components/Header/index.jsx
touch src/components/Footer/index.jsx

I am using Unix's touch command to create files and mkdir to create directories. You may use your own editor or IDE to accomplish those tasks.

In the Header/index.jsx file, add the following content:

import { Link } from "wouter";

export default () => (
  <header>
    <nav>
      <ul>
        <li>
          <Link href="/">Home</Link>
        </li>
        <li>
          <Link href="#">Features</Link>
        </li>
        <li>
          <Link href="#">Pricing</Link>
        </li>
        <li>
          <Link href="#">FAQs</Link>
        </li>
        <li>
          <Link href="/about">About</Link>
        </li>
      </ul>

      <div>
        <Link href="/sign-in">Sign in</Link>
        <Link href="/register">Sign in</Link>
      </div>
    </nav>
  </header>
);
src/components/Header/index.jsx
💡
You may notice an unknown Link tag here and the wouter import. It is one of the Navigation components provided by the wouter library and triggers navigation to the URL from the href attribute. We will cover that library soon.

And the Footer/index.jsx:

export default () => (
  <footer>
    <div>
      <a href="#" target="_blank" rel="noopener noreferrer">
        Twitter
      </a>
      <a href="#" target="_blank" rel="noopener noreferrer">
        Github
      </a>
    </div>

    <p>© {new Date().getFullYear()} Middlemarch. All Right Reserved.</p>
  </footer>
);
src/components/Footer/index.jsx

After that we need to include them into the application entry point:

import { StrictMode } from "react";

import Header from "./components/Header/index.jsx";
import Footer from "./components/Footer/index.jsx";

export default () => (
  <StrictMode>
    <Header />
    <main></main>
    <Footer />
  </StrictMode>
);
App.jsx

The page content will be rendered inside the main element. You may notice that we declared the file extensions for Home and Footer component files. It is useful to distinguish the local files from external dependencies, though it is completely optional and depends on only your preferences.

What is wouter?

Wouter is a minimal router implementation for React and Preact. You already saw the Link component which is simply the <a> element under the hood.

Now it's time to introduce the Route component which will render a declared component if the URL property matches the current location.pathname value.

Let's modify the App.jsx component again and declare the routes:

import { StrictMode } from "react";

import Home from "./pages/Home/index.jsx";
import Cart from "./pages/Cart/index.jsx";
import Header from "./components/Header/index.jsx";
import Footer from "./components/Footer/index.jsx";
import SignIn from "./pages/SignIn/index.jsx";
import Register from "./pages/Register/index.jsx";

export default () => (
  <StrictMode>
    <Header />
    <main>
      <Switch>
        <Route path="/">
          <Home />
        </Route>
        <Route path="/cart">
          <Cart />
        </Route>
        <Route path="/sign-in">
          <SignIn />
        </Route>
        <Route path="/register">
          <Register />
        </Route>
      </Switch>
    </main>
    <Footer />
  </StrictMode>
);
App.jsx
💡
We are using yet another component from wouterSwitch, which performs exclusive routing. That means that only the first matched route will be rendered.

After that we have to provide the default markup for each page:

export default () => <div></div>;

The last thing left is the Home page. Edit the src/pages/Home/index.jsx file and add th the following code:

import { useBooks } from "../../cache/books.jsx";

export default () => {
  const [books] = useBooks();

  return (
    <div>
      <h1>Middlemarch</h1>
      <p>Your nightly bookstore</p>
      <input type="search" name="search" placeholder="Search books..." />

      <div>
        {books.map((book) => (
          <div data-book-id={book.id} key={book.id}>
            <p>{book.title}</p>
            <button>Buy</button>
          </div>
        ))}
      </div>
    </div>
  );
};
src/pages/Home/index.jsx

For now we will omit the search functionality for brevity. You may notice the new useBooks import – it is a custom hook which will manage the global books' data of the application. Now, that we mention it let's proceed to the state management section.

Step 3 – state management with Context + Hook

There are plenty state management libraries for React that have diverse ideas behind them. Each of them is worth exploring, but in this small application we don't need to add yet another dependency and write complex logic to store our data, which is only an array of objects that hold the available books. React already has the API which we can use to implement it in a concise way.

Implementing the "useBooks" hook

We will use Context for globally storing the data and hooks for delivering that data to components that need it.

mkdir src/cache
touch src/cache/books.jsx

Here are the steps to do that:

1) Create the context – not exported and it is private to this module

import { createContext } from "react";

const BooksContext = createContext([]);
src/cache/books.jsx

2) Create the Provider component – exported by default

This defines the state where we are going to store the data and returns the context Provider with the value and state dispatcher. The last one we need to dispatch state changes, though it won't be exposed directly to the components.

import { useEffect, useReducer, useContext, createContext } from "react";

const refreshActionType = "booksRefreshed";
const refreshAction = (dispatch) =>
  fetch("/api/books")
    .then((response) => response.json())
    .then((payload) => dispatch({ type: refreshActionType, payload }));

export default ({ children }) => {
  const [books, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case refreshActionType:
        return action.payload;

      default:
        return state;
    }
  }, []);

  // Loads the books on the mounting the component.
  useEffect(() => void refreshAction(dispatch), []);

  return (
    <BooksContext.Provider value={[books, dispatch]}>
      {children}
    </BooksContext.Provider>
  );
};
src/cache/books.jsx

3) Create the hook
The hook uses the context and returns a fresh array and useful methods which will modify the state.

import { useContext } from "react";

const refreshActionType = "booksRefreshed";
const refreshAction = (dispatch) =>
  fetch("/api/books")
    .then((response) => response.json())
    .then((payload) => dispatch({ type: refreshActionType, payload }));

export const useBooks = () => {
  const [books, dispatch] = useContext(BooksContext);

  return [books, { refresh: () => refreshAction(dispatch) }];
};
src/cache/books.jsx
💡
The useBooks hook exports a tuple (an array of two items) where the first item is the books array and second is collection of methods which describe the available ways of interacting with the books array.

Fetchin the /api/books URL errors for now. It will work when we provide the serverless function for it.

In order to use the books state we need to wrap the application with the component according to the Context using rules:

import { StrictMode } from "react";

import Home from "./pages/Home/index.jsx";
// ...
import Register from "./pages/Register/index.jsx";
import BooksProvider from "./cache/books.jsx";

export default () => (
  <StrictMode>
    <BooksProvider>
      <Header />
      <main>...</main>
      <Footer />
    </BooksProvider>
  </StrictMode>
);
App.jsx

Step 4 – creating serveless functions with Netlify

Serverless functions is a relatively new concept where the idea is to delegate running and maintaining server-side tasks to a cloud-computing service and only focus on writing the code. There are several service providers that offer this feature, including: Netlify, Vercel, Render, Azure, or Cloudflare.

For this example we are going to use Netlify's functions, but you can choose your favourite provider.

Here is the schema of how everything works:

Install the Netlify CLI

First we have to install the Netlify CLI and create the netlify.tomlconfiguration file:

npm i -D netlify-cli
touch netlify.toml

The Netlify CLI is needed to emulate the FaaS (Function-as-a-Service) environment and test our functions. The CLI is also responsible for running the application.

We are going to adjust Netlify's config file, so it will know how to run the Vite development server:

[dev]
  command = "npm run vite:start"
  publish = "dist"
  # We need another port for the application because Vite occupies the 3000.
  port = 8080

[build]
  command = "npm run vite:build"
  publish = "dist"

[functions]
  # We are goint to use ES modules so we need esbuild to transpile the code.
  node_bundler = "esbuild"
  # This line will include into the environment a file with the mock data.
  included_files = ["data/*.json"]

# We have to tell the Netlify that this URL will be served by the books.js function.
[[redirects]]
  from = "/api/books"
  to = "/.netlify/functions/books"
  status = 200
netlify.toml

Adding a lowdb database

Now let's create the mock file with the data.

mkdir data
touch data/db.json
{
  "books": [
    {
      "id": "f30396cb-fb98-4b7b-b7b6-a3f62539e4bc",
      "title": "The Memory Police",
      "author": "Yoko Ogawa",
      "image": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1544335119l/37004370.jpg",
      "price": 14.72,
      "currency": "$",
      "category": "Science Fiction > Dystopian",
      "isbn13": 9781101911815,
      "description": "On an unnamed island, objects are disappearing: first hats, then ribbons, birds, roses..."
    },
    {
      "id": "a1080342-843b-49b1-92fa-c90e115a1627",
      "title": "Rhinoceros and Other Plays",
      "author": "Eugène Ionesco",
      "image": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1347454436l/323823.jpg",
      "price": 16,
      "currency": "$",
      "category": "Plays > Theatre",
      "publisher": "Grove Press",
      "isbn13": 9780802130983,
      "description": "A rhinoceros suddenly appears in a small town, tramping through its peaceful streets."
    },
    {
      "id": "63d64d0a-6f7b-49b1-91be-2ef74aa5b477",
      "title": "Memento Mori",
      "author": "Muriel Spark",
      "image": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1348186701l/120158.jpg",
      "price": 15.9,
      "currency": "$",
      "category": "Novels",
      "isbn13": 9780811223041,
      "publisher": "New Directions Publishing Corporation",
      "description": "In late 1950s London, something uncanny besets a group of elderly friends."
    },
    {
      "id": "98357068-95c3-4681-92d8-df5400866a64",
      "title": "Atlas of AI",
      "author": "Kate Crawford",
      "image": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1603542518l/50131136._SX318_.jpg",
      "price": 25.76,
      "currency": "$",
      "category": "Nonfiction > Technology",
      "publisher": "Yale University Press",
      "isbn13": 9780300209570,
      "description": "The hidden costs of artificial intelligence--from natural resources and labor to privacy, equality, and freedom"
    }
  ]
}
db.json

Okay, let's move on to the functions. By default, Netlify will search for them in netlify/functions directory.

mkdir -p netlify/functions
touch netlify/functions/books.js

Edit the netlify/functions/books.js and add the following content:

import { resolve } from "path";

import { JSONFile, Low } from "lowdb";

const Database = () => {
  const filePath = resolve("data", "db.json");
  const adapter = new JSONFile(filePath);
  return new Low(adapter);
};

export const handler = async (_event, _context) => {
  const db = Database();

  await db.read();

  return {
    statusCode: 200,
    body: JSON.stringify(db.data.books),
  };
netlify/functions/books.js

We are using the lowdb package to read from the JSON file. Of course, you can use the fs.promises.readFile directly and read from the file. But this is an example and in a real-world app you will have a database and you will have to use a database driver so this example shows you that it is possible.

Adding user authentication

Serverless functions allows to implement complex behaviour like user authentication. If you check out to the source code of the current application you may see there two pages:
- SignIn/index.jsx
- Register/index.js

And the corresponding functions:
- login.js
- register.js.

To create an account you just need to pass an email and a password. The latter will be hashed on the server and the hash will be saved into the DB. It's a good practice to add a so-called "salt" to the hashing function which should be private.

For that purpose we will create the .env file that will contain the PASSWORD_SECRET variable, which we will use as the "salt".

Here's the contents of the .env.example file. For local development, rename this to .env and fill these in:

VITE_AUTH_KEY = 'your_super_secret_auth_key'
PASSWORD_SECRET = 'super_secret_password_salt'
.env

The VITE_AUTH_KEY environment variable is used for the remember me functionality.

Configure env variables in Netlify

When running in Netlify, use the administration UI to define the environment variables above:

https://app.netlify.com/sites/middlemarch/settings/deploys#environment

Step 5 – Testing React components with Nightwatch.js

Component testing is a type of UI testing where the component is rendered in isolation, without the rest of app components, for the purpose of verifying its functionality. It’s usually a testing strategy which happens prior the end-to-end testing step, which we’ll elaborate in the next section.

Install Nightwatch.js

We will use Nightwatch for both component and end-to-end testing. The easiest way to add Nightwatch tests to your project is using the create-nightwatch init tool.

Just run the following command from the project's root folder and follow the steps:

npm init nightwatch@latest

Install the @nightwatch/react plugin

Nightwatch supports testing React component using the @nightwatch/react, which is using internally the vite-plugin-nightwatch Vite plugin.

To install it, run:

npm i -D @nightwatch/react

After you have run the npm init nightwatch command, you should have a generated nightwatch.conf.js file.

You need to edit it and let Nightwatch know that React component testing plugin is installed:

module.exports = {
    // ...
    plugins: ["@nightwatch/react"],
    // ...
}
nightwatch.conf.js

Write your first component test

There should be a tests directory now. We can add our first component test.

mkdir tests/component
touch tests/component/basic.js

With the following content:

describe("Basic Component Test", () => {
  it("tests the component", async (browser) => {
    const component = await browser.mountReactComponent(
      "/src/pages/Home/index.jsx"
    );

    expect(component).to.be.present;
  });
});
test/component/basic.js

Nightwatch.js uses the same BDD syntax as Mocha or Jest. You can even use Mocha as a test runner in Nightwatch but for simplicity we aren't going to do that.

Run your first component test

It's time now to run the above test in Chrome:

npx nightwatch tests/component/basic.js --env chrome

You can pass the --headless argument if you don't want to see the opening browser (not available if using Safari).

npx nightwatch tests/component/basic.js --env chrome --headless
⚠️
Component testing in React is only available at the moment for Chrome and Edge browsers

Extend the component test

For now, the test does nothing helpful. Let's extend it a bit:

describe("New Arrivals Component Test", function () {
  let component;

  before(async () => {
    await browser.mockNetworkResponse("/api/books", {
      body: JSON.stringify([
        {
          id: "article_identifier_sldkfjlkouw98",
          title: "The Memory Police",
          author: "Yoko Ogawa",
          image:
              "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1544335119l/37004370.jpg",
          price: 14.72,
          currency: "$",
          category: "Science Fiction > Dystopian",
          isbn13: 9781101911815,
          description:
              "On an unnamed island, objects are disappearing: first hats, then ribbons, birds, roses...",
        }
      ])
    });

    component = await browser.mountComponent("/tests/component/Books.jsx");
  });

  it("tests the component", function (browser) {
    expect(component).to.be.visible;
    expect(component).text.toContain("The Memory Police");
    expect(component.findAll("h2 ~ div > div")).length(1);
  });

  it("logs the innerHTML property", async function (browser) {
    await browser.getElementProperty(component, "innerHTML");
    browser.assert.textContains(component, "The Memory Police");
  });
});
tests/component/arrivals.js

Here we have created a simple test that checks for the presence of elements and content on the page. We have refactored the component mounting into the before hook, so we can do the only checks in the it block.

Mocking network requests

We have to mock the /api/books network request while testing in order to ensure reliable results from the server-side component.

That's why we have used the browser.mockNetworkResponse method in that Nightwatch provides in order to fake the response, because component tests don't involve the running server.

Step 6 – Adding end-to-end tests with Nightwatch

End-to-End testing (E2E) helps with validating the most important flows in your application. Fortunately, we don't need to install additional tools to implement E2E tests since we already have Nightwatch in our project (see the previous step for installation instructions).

Nightwatch can run tests against all major browsers thanks to its integration with the W3C Webdriver API and Selenium. It also allows you to use distributed cloud testing platforms like BrowserStack, SauceLabs, CrossBrowserTesting, or LambdaTest.

⚠️
For running end-to-end tests you should build the application first and then serve it with the local Vite server. This is required to emulate the production environment as close as possible.

To build and run the application, use the netlify local dev server:

npx netlify dev

or simply:

npm start

Create your first end-to-end test

mkdir tests/e2e
touch tests/e2e/HomePage.js
describe("Homepage End-to-end Test", function() {
  it("tests if homepage is loaded", function(browser) {
    browser
      .navigateTo("http://localhost:8080")
      .assert.visible("#app h1")
      .expect.elements("#app [data-book-id]")
      .count.toEqual(1);
  });

  it("tests if anonymous user cannot add book to a cart", function(browser) {
      browser.assert.not.elementPresent('[data-book-id] button');
  });

  after(browser => browser.end());
});
test/e2e/HomePage.js

The test verifies whether the page is displayed and contains the book information that we've seen before.

Run your first end-to-end test

Run the test by using the following command:

npx nightwatch tests/e2e/HomePage.js --env firefox

You can use another browser or several browsers if you want. Just make sure that you have according drivers installed.

Run the test in Firefox, Chrome and Safari in parallel:

npx nightwatch tests/e2e/HomePage.js --env firefox,chrome,safari

Step 7 – add continuous integration with GitHub Actions

We are almost done. The last thing left is adding CI to automatically run the tests against next changes.

What we need to do is to create the main.yamlfile inside the .github/workflows directory with the following content.

We'll use Xvfb to run the tests in Chrome inside the Github Actions workflow.

name: Main CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x, 16.x, 18.x]
        
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci

      - run: mkdir -p screens logs
      - run: sudo apt-get install xvfb

      - name: Run Nightwatch component tests
        run: xvfb-run --auto-servernum npm run test:components

      - name: Build and start application
        run: npm start &

      - name: Wait until it starts
        run: sleep 30s

      - name: Run Nightwatch end-to-end tests
        run: xvfb-run --auto-servernum npm run test:e2e

.github/workflows/main.yaml

And we are done! GitHub Actions will run tests in 3 separate environments on every pull request.

Where to go from here

If you want to dive deeper you can go to the https://github.com/pineviewlabs/middlemarch-react and explore the code or clone and run locally.

You can also visit the https://middlemarch-react.netlify.app and see the result.

https://middlemarch-react.netlify.app

Getting support

React and Wouter

Netlify functions

LowDB

Nightwatch.js

For support with everything Nightwatch  related, you may visit the following channels:


Yevhen Kapelianovych is a front-end developer based in Ukraine, working at Odessa-based Halo Lab design agency.