Disclaimer: This is not something anyone would normally do, but because we are learning, this is exactly what we want to do. This will give you a better understanding of the moving parts and why you later would want to use a framework that basically do all of this for you.

Steps to take…

…to build a full-stack Node/Express app serving a Single-page application (SPA) with client-side navigation using Vite as build tool.

You can read more about each technology on the specific websites, and you can use AI-tools to help you better understand why they exist and why we use them. In this guide I’ll provide simple explanations, but the focus is on the code itself.

Let’s get started

I’m running macOS, you need to replace the terminal commands to match your OS.

  • Make sure you have Node installed on your system.
  • Make sure you have a text editor e.g. Visual Studio Code

Create a Vite project

Vite is a build tool for the JavaScript ecosystem, it transforms different types of code like TypeScript into pure Javascript that the browser and other runtimes can understand. This tool is essential for us to develop modern web applications.

We start creating a new Vite project without framework (Vanilla), with TypeScript and without rolldown-vite:

npm create vite@latest my-project

output:

β—‡  Select a framework:
β”‚  Vanilla
β”‚
β—‡  Select a variant:
β”‚  TypeScript
β”‚
β—‡  Use rolldown-vite (Experimental)?:
β”‚  No
β”‚
β—‡  Install with npm and start now?
β”‚  No

We then follow the instructions to navigate into our project, installing all dependencies, and starting the dev server:

 cd my-project
 npm install
 npm run dev

output:

  VITE v7.1.9  ready in 269 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

Open the browser and navigate to http://localhost:5173/ to view the app.

This is the default app generated by the Vite team, now we’ll begin buidling our own app.

What’s in our project so far?

We have the basic directories and files to build a JavaScript app.

The node_modules directory contains all installed dependencies that we need to develop our project, it’s to large to list but check it out for yourself, you’ll find vite in there.

.
β”œβ”€β”€ .gitignore
β”œβ”€β”€ index.html
β”œβ”€β”€ node_modules
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ public
β”‚Β Β  └── vite.svg
β”œβ”€β”€ src
β”‚Β Β  β”œβ”€β”€ counter.ts
β”‚Β Β  β”œβ”€β”€ main.ts
β”‚Β Β  β”œβ”€β”€ style.css
β”‚Β Β  └── typescript.svg
└── tsconfig.json

I’ll explain their purpose:

.gitignore
This file tells git what not to commit to our repository.
index.html
This file is the starting point for our frontend.
/node_modules
This directory is where all dependencies lives. Whenever we install a new package it ends up in here.
package-lock.json
This file keeps track of all installed dependencies, and their respective dependencies. We do not touch this file, it’s entirely managed by our package manager (npm).
package.json
This file is our projects configuration file, in here we specify what dependecies we want to include and what scripts we want to run, e.g. the dev script which starts our dev server.
/public
This directory contains images and other static files which are not HTML, CSS, or JavaScript.
/src
This directory is where we keep most of our projects source files, i.e. where we keep most of our application code.
tsconfig.json
This file is our projects TypeScript configuration file, we use this file to tell the TypeScript Server what we expect it to behave while interacting with our projects code.

Again, use AI-tools to further explain the files and directories.

Let’s do some cleaning 🧹

Before we add our own files we remove the files inside /src and /public. These files are not needed to run our application, they are there to run the current default application.

  • Remove vite.svg
  • Remove counter.ts
  • Remove main.ts
  • Remove style.css
  • Remove typescript.svg

Now we’re ready to start adding our own code. I’ll walk you through the steps.

Soon we start coding, let’s start with project structure

We want to split our backend and frontend code into two seperate directories, and to have it organised we create the directories inside our /src directory:

src/
β”œβ”€β”€ backend
└── frontend

We keep the index.html file in the root directory of our project, this is still the entry point for our frontend/client code.

Now we create the file that will act as the entry point for our backend/server code.

  • Create a file and name it server.ts and place it in the root directory of the project.

Your project structure should look like this:

.
β”œβ”€β”€ index.html
β”œβ”€β”€ node_modules
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ public
β”œβ”€β”€ server.ts
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ backend
β”‚   └── frontend
└── tsconfig.json

To explain, we run our app in developmenr by calling the server.ts file with a package called tsx that can run the TypeScript file. Later in production when the project is already transpiled into JavaScript we use Node.

# in development: 
tsx server.ts
# in production: 
node server.js

It’s inside this server.ts/js file we initialise our application and serve the client code as static files through our express web server.

On top of that we use Vite in development, as an express middleware, to get all the benefits Vite provides, e.g. HMR (Hot Module Replacement) which updates the browser output automatically on code changes in our editor.

Don’t worry, this will soon all make sense.

In order to have full TypeScript support we add our server.ts file to our tsconfig.json:

"include": ["src", "server.ts"]

Before we start coding we add some more fundamental files to our project structure.

  • Create a file named router.ts inside your /src/frontend directory.
  • Create a file named routes.ts inside your /src/frontend directory.
  • Create a directory named pages inside your /src/frontend directory.
  • Create a directory named controllers inside your /src/backend directory.
  • Create a file named db.ts inside yout /src/backend directory.

Your project structure should look like this:

.
β”œβ”€β”€ index.html
β”œβ”€β”€ node_modules
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ public
β”œβ”€β”€ server.ts
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ backend
β”‚   β”‚   β”œβ”€β”€ controllers
β”‚   β”‚   └── db.ts
β”‚   └── frontend
β”‚       β”œβ”€β”€ pages
β”‚       β”œβ”€β”€ router.ts
β”‚       └── routes.ts
└── tsconfig.json

To prevent the app from crashing when we later start it, change the src value inside the script tag inside the index.html to reference your router.ts file:

<script type="module" src="src/frontend/router.ts"></script>

Let’s build the backend and glue it to the frontend

Just to recap what happens Whenever a user decides to visit our webpage. Their browser sends a HTTP request asking for whatever we serve at the specific location, usually “/” which could be thought of as “Home”. E.g. https://example.com

When that happens we want to send back a HTTP response, and to do that we need to setup an express web server that listens to incoming requests and serves our SPA.

We install a couple of packages to get going.

The -D flag installs a package only to the devDependencies to use only in development.

To please TypeScript we install types for Node:

npm i -D @types/node 

Then the tsx package which allows us to run our server.ts file in development:

npm i -D tsx

Finally, install express and its types:

npm i express && npm i -D @types/express

Place the following code inside your server.ts file:

import express, { type Response } from "express";
import path from "path";
import { fileURLToPath } from "url";

const port = 3000;

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

async function main() {
  const app = express();
  app.get("/", ({ res }: { res: Response }) => {
    res.sendFile(path.join(__dirname, "index.html"));
  });
  app.listen(port, () => {
    console.log(`running server at http://localhost:${port}`);
  });
}

main().catch((error) => console.log(error));

Change the dev script inside package.json to run with tsx:

"dev": "tsx server.ts"

Then run the server from the terminal:

npm run dev

It should give an output like this:

> my-project@0.0.0 dev
> tsx server.ts

running server at http://localhost:3000

Visit the site, it’s only a blank page, but the title of the site should be “my-project”.

Hint: The title is visible in the tab at the top of the browser.

What’s happening right now?

Let’s break it down…

We import the express package and its types to be able to build the web server:

import express, { type Response } from "express";

We import the path package which allows us to easily write and handle path strings. This is important to tell the code inside the current module how to navigate the filesystem:

import path from "path";

The third import is a function that allows us to transform a file url into a path that the path package can understand:

# file url
file:///Users/vide/code/my-project/server.ts
# file path
/Users/vide/code/my-project/server.ts
import { fileURLToPath } from "url";

We assign the port number to a constant, and populate the current filename and dirname to two other constant:

const port = 3000; // port number for our server

const __filename = fileURLToPath(import.meta.url); // server.ts
const __dirname = path.dirname(__filename); // /Users/vide/code/my-project/
  • We wrap our express server inside a main function.
  • We create a “GET” endpoint for the express server and serve our index.html file when the site is visited.
  • We tell express to listen on our predefined port and console.log that it is running.
  • Finally, we initialise the main function with a catch handler to console.log any errors.
async function main() {
  const app = express();
  app.get("/", ({ res }: { res: Response }) => {
    res.sendFile(path.join(__dirname, "index.html"));
  });
  app.listen(port, () => {
    console.log(`running server at http://localhost:${port}`);
  });
}

main().catch((error) => console.log(error));

Time to connect Vite with Express

View the source

In order to have the benefits of Vite in our development process, we need to set it up as a Express middleware and tell it to serve our frontend code as a SPA.

We need to distinguish between dev mode and production mode, and the easiest way to do that is to set an environment variable in our package.json scripts.

There is a great package we can use to achieve this rather hassle free: cross-env. This package can be used on macOS, Windows, and Linux.

npm i cross-env

It’s ready to be used in our production script later on, for now we do not need to worry about it, because the absence of the environment variable is equal to false.

Continuing with Vite:

When we run Vite as a middleware, Vite also want to know where to find our index.html, but Vite will look for index.html by default so we do not need to specify the exact file to find, as we did above with pure a Express server, only the directory where it is.

Now we need to change the rest of our server.ts file.

import express, { type Response } from "express";
import path from "path";
import { fileURLToPath } from "url";

const port = 3000;
const isProduction = process.env.NODE_ENV === "production";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

async function main() {
  const app = express();

  let vite;
  if (!isProduction) {
    const { createServer: createViteServer } = await import("vite");
    vite = await createViteServer({
      server: { middlewareMode: true },
      appType: "spa",
      root: __dirname,
    });
    app.use(vite.middlewares);
  } else {
    app.get("/", ({ res }: { res: Response }) => {
      res.sendFile(path.join(__dirname, "index.html"));
    });
  }
  app.listen(port, () => {
    console.log(
      `running ${
        isProduction ? "PROD" : "DEV"
      } server at http://localhost:${port}`
    );
  });
}

main().catch((error) => console.log(error));

This checks if we are in dev or production then run correct code:

let vite;
  if (!isProduction) {
    const { createServer: createViteServer } = await import("vite");
    vite = await createViteServer({
      server: { middlewareMode: true },
      appType: "spa",
      root: __dirname,
    });
    app.use(vite.middlewares);
  } else {
    app.get("/", ({ res }: { res: Response }) => {
      res.sendFile(path.join(__dirname, "index.html"));
    });
  }

We also add this to print to the console what type of server we are running:

  app.listen(port, () => {
    console.log(
      `running ${
        isProduction ? "PROD" : "DEV"
      } server at http://localhost:${port}`
    );
  });

To test that everything is working as expected, we start our dev server then visit the web page in the browser, then we add a h1 tag inside our index.html file saying “Hello, World!” and save. It should render in the browser without any server restart.

Remove the h1 tag before moving on.

Client-side navigation and routing

To achieve client-side navigation without using an external framework we need to work with some properties and methods of the JavaScript Window object.

The window object, through its properties and methods, gives us access to the browser’s built-in functionalities that allow us to control navigation and manage the browser history.

Let’s walk it through to get a better understanding of how it all works.

What we need:

  1. Navigation links (anchor elements) for the user to click to visit each page.
  2. “Container” html element where we dynamically render our pages content.
  3. Custom data attribute to allow us to target our links with JavaScript.
  4. Decide on page layout that will render each page.
  5. JavaScript function that listens to link clicks and render the correct page content.
  6. JavaScript function that updates the browsers history throught the History API.
  7. JavaScript function that listens to back and forward navigations with the browser, and render the correct pages content.

Since we are using TypeScript we will type everything.

We start with adding our navigation links and the container element to the index.html file. We add them inside the already created div element that has the id=“app”.

<!-- other html code -->
<div id="app">
  <nav id="navigation">
    <a href="/" data-link>Home</a>
    <a href="/dashboard" data-link>Dashboard</a>
  </nav>
  <div id="page-content"></div>
</div>
<!-- other html code -->

Next, we define the types for our pages and routing functions.

Inside the frontend directory, create a new file called types.ts:

// this is used for our page functions
export type RouteEntry = {
  html: () => string;
  // we make this async to be sure we can run async code further down the line
  logic: async () => void;
};

// this is used by our router function
export type Routes = Record<string, RouteEntry>;

We define that our pages (RouteEntry) will return an object with two functions, one that return the html, and one that returns the page logic.

Now, let’s create our home page and our dashboard page, and while at it we also create a page for any request to a page that does not exists, i.e. “page not found, 404”.

import type { RouteEntry } from "../types";

export function HomePage(): RouteEntry {
  return {
    html: () => `
        <h1>Home Page</h1>
    `, // this is where you return your page html
    logic: async () => {
      // this is where you write your page logic
      // e.g. console.log("Hello from home page");
    },
  };
}

Create all three and put them inside the /src/frontend/pages directory:

pages
β”œβ”€β”€ 404.ts
β”œβ”€β”€ dashboard.ts
└── home.ts

Inside the routes.ts file we import the Routes type from types.ts and all the pages from their respective files, and then export our routes object, and a type for the pathName that we will use in our router function:

import type { Routes } from "./types";
import { HomePage } from "./pages/home";
import { DashboardPage } from "./pages/dashboard";
import { PageNotFound } from "./pages/404";

export const routes = {
  "404": PageNotFound(),
  "/": HomePage(),
  "/dashboard": DashboardPage(),
} satisfies Routes;

export type pathName = keyof typeof routes;

Finally we add the functionality to our router.ts module:

import { routes, type pathName } from "./routes";

// function to render the page content
async function renderContent(pathname: pathName) {
  // target the page-content element inside index.html
  const contentElement = document.getElementById("page-content");
  if (contentElement) {
    // render the page html
    contentElement.innerHTML = routes[pathname].html();
    // render the page logic
    await routes[pathname].logic();
  }
}

// function to navigate to a page
function navigateTo(pathname: string) {
  // check if pathname is part of our routes object
  if (pathname in routes) {
    // render page content
    renderContent(pathname as pathName);
  } else {
    // render 404 page
    renderContent("404");
  }
  // push new state to browser history to enable browser back and forward press
  history.pushState({ pathname: pathname }, "", pathname);
}

// target all the links inside our index.html file
document.querySelectorAll<HTMLAnchorElement>("a[data-link]").forEach((link) => {
  // add event listener for click
  link.addEventListener("click", (e) => {
    // prevent from navigating with full page reload
    e.preventDefault();
    // extract the url from the target href
    const url = new URL(link.href);
    // provide the pathname
    navigateTo(url.pathname);
  });
});

// handle back and forward navigations from the browser
window.addEventListener("popstate", (e) => {
  // check that state is valid
  if (e.state && "pathname" in e.state) {
    if (e.state.pathname in routes) {
      // then render correct page
      renderContent(e.state.pathname);
    } else {
      // or 404
      renderContent("404");
    }
  }
});

// Initial site render when the JavaScript is loaded into the browser
const initialPath = window.location.pathname;
// set the history state to the current path
history.replaceState({ pathname: initialPath }, "", initialPath);
// render first page that user visits
if (initialPath in routes) {
  renderContent(window.location.pathname as pathName);
} else {
  renderContent("404");
}

Now we can use our client-side navigation πŸŽ‰

Writing page logic and corresponding Express routes

Let’s write a small form that can create and edit blog posts and place it in dashboard.ts:

<form method="post" id="blog-form">
  <label for="blog-title">Blog Title</label>
  <input type="text" name="blog-title" id="blog-title">
  <label for="blog-text">Blog Text</label>
  <textarea name="blog-text" id="blog-text" rows="4" cols="12"></textarea>
  <button id="submit-button">Create Post</button>
</form>

We create the JavaScript logic that handle the form submit:

// we add an event listner to the submit button
blogForm.addEventListener("submit", async (e) => {
  // prevent the form submit
  e.preventDefault();
  // we add the input values to an object 
  const formData: BlogPostFormData = {
    blogTitle: blogTitle.value,
    blogText: blogText.value,
  };
  // and send the data to the backend
  try {
    const response = await fetch("http://localhost:3000/dashboard", {
      // using post method
      method: "post",
      // declare we use json format
      headers: {
        "content-type": "application/json",
      },
      // then parse our data into json
      body: JSON.stringify(formData),
    });
    // read response status, and alert success message if blog post created
    if (response.status === 200) {
      alert("Blog post created!");
    }
    // then remove value from form input fields
    blogTitle.value = "";
    blogText.value = "";
  } catch (error) {
    console.log(error);
  }
});

It’s time to create the backend route to receive and save the data, including the database logic. This also gives us a good opportunity to write some tests!

These are the steps to take:

  1. Install MongoDB, MongoDB client, and mongodb-memory-server (to test our code)
  2. Write MongoDB logic
  3. Write the Express route to receive incoming form data
  4. Write a data validation function to validate incoming form data
  5. Install Vitest and test the validate function
  6. Install Playwright and test our form including saving to the test database

Install the MongoDB community edition.

Install the MongoDB shell and use it to create a new database named “my-project”.

Install the Node native MongoDB client, and the mongodb-memory-server:

npm i mongodb && npm i -D mongodb-memory-server

Install the dotenv package that let’s us use environment variables inside .env file. We use this for our production database connection string, because it will contain credentials that we want to keep secret.

npm i dotenv

Load dotenv in the top of the server.ts file, right below the imports:

import dotenv from "dotenv";
dotenv.config();

Write the following code inside the already created db.ts file:

import * as mongoDB from "mongodb";
import { MongoMemoryServer } from "mongodb-memory-server";

// TypeScript type for form data
export type BlogPostFormData = {
  // we get back to this property later, when we call objects from the database
  // but for now, in our forms, it does not matter, I just want to prepare you
  blogId?: string;
  blogTitle: string;
  blogText: string;
};

// TypeScript interface for our blog post database entry
export interface BlogPost {
  // https://www.mongodb.com/docs/drivers/node/current/typescript/#working-with-the-_id-field
  // we have it here to give our database collection the entire object including id property
  _id?: mongoDB.ObjectId;
  blogTitle: string;
  blogText: string;
}

// clean way to have types for our database collections
export const collections: {
  // https://www.mongodb.com/docs/drivers/node/current/typescript/#working-with-the-_id-field
  blogPosts?: mongoDB.Collection<mongoDB.OptionalId<BlogPost>>;
} = {};

// we initialise the db and decide if we are testing
export async function connectToDatabase(isTesting: boolean) {
  // this is the connection string to connect to our database
  let connectionString;
  // if testing we use mongodb-memory-server
  if (isTesting) {
    const mongod = await MongoMemoryServer.create({
      instance: {
        dbName: "my-project",
      },
    });
    connectionString = mongod.getUri();
  } else {
    // else we use a real database
    connectionString = process.env.MONGODB_CONNECTION_STRING as string;
  }
  // we initialise the client
  const client: mongoDB.MongoClient = new mongoDB.MongoClient(connectionString);
  // and connect to the database server
  await client.connect();
  // we assign our database to a variable
  const db: mongoDB.Db = client.db("my-project");
  // and our blog post collection to another variable
  const blogCollection = db.collection<mongoDB.OptionalId<BlogPost>>("posts");
  // then assign it to our collections object
  collections.blogPosts = blogCollection;
  // logging that we are up and running
  console.log(
    "succefully connected to database with mongoDB client and app collections..."
  );
}

Let’s move on and create two files, one to hold the Express route logic for our dashboard page, and one for our validation logic to validate our blog post form data.

  • Create dashboardController.ts inside your /src/backend/controllers directory
  • Create validate.ts inside your /src/backend directory

The file tree should look like this:

src/backend/
β”œβ”€β”€ controllers
β”‚   └── dashboardController.ts <- newly created
β”œβ”€β”€ db.ts
└── validate.ts <- newly created

Put this code inside validate.ts:

import type { BlogPostFormData } from "./db";

// simple validation function just to have something to test
export function validateBlogPostFormData(formData: BlogPostFormData) {
  const blogTitle = formData.blogTitle;
  const blogText = formData.blogText;
  // we validate if blog title and blog text are strings with >0 characters
  if (typeof blogTitle === "string" || typeof blogText === "string") {
    if (blogTitle.length > 0 && blogText.length > 0) {
      return true;
    }
  }
  // otherwise the validation fails
  return false;
}

And this code inside the dashboardController.ts:

import type { Request, Response } from "express";
import { collections } from "../db";
import type { BlogPostFormData } from "../db";
import { validateBlogPostFormData } from "../validate";

// simple async function to load into our express route
// since we export it as default, we import it like so: dashboardController
export default async function(req: Request, res: Response) {
  // we extract the form data from the request
  const formData: BlogPostFormData = req.body;
  // validate form data
  if (!validateBlogPostFormData(formData)) {
    // send error message for invalid form data
    return res.status(400).send("Bad Request");
  }
  try {
    // try save to the database
    await collections.blogPosts?.insertOne(formData);
    // send success message
    res.sendStatus(200);
  } catch (error) {
    // send error message for unknown error
    res.status(500).send("Internal Server Error");
  }
}

Now we can edit the server.ts file. Add the database connection to the top of the main() function, and import and add the Express route logic for our dashboardController below the express initialization.

await connectToDatabase(process.env.NODE_ENV === "test");
app.post("/dashboard", dashboardController);

Add the page logic to dashboard.ts inside the logic() function:

// this gets the form element 
const blogForm = document.getElementById("blog-form") as HTMLFormElement;
// this gets the input element for blog title
const blogTitle = document.getElementById(
  "blog-title"
) as HTMLInputElement;
// this gets the input element for blog text
const blogText = document.getElementById("blog-text") as HTMLInputElement;
// we add an event listener to the form for submit actions, i.e. pressing submit button
blogForm.addEventListener("submit", async (e) => {
  // and prevent form submission
  e.preventDefault();
  // place form data into an object
  const formData: BlogPostFormData = {
    blogTitle: blogTitle.value,
    blogText: blogText.value,
  };
  try {
    // then send it to our backend
    const response = await fetch("http://localhost:3000/dashboard", {
      method: "post",
      headers: {
        "content-type": "application/json",
      },
      // we conver the data into json 
      body: JSON.stringify(formData),
    });
    // if blog post created log success message
    if (response.status === 200) {
      console.log("Blog post created!");
    }
    // remove values from form inputs to have a clean page
    blogTitle.value = "";
    blogText.value = "";
  } catch (error) {
    console.log(error);
  }
});

We just have to add one more thing, in order for our backend to receive the data we send from our frontend, we need to use a middleware that handles json data.

Place this line of code below your express initialization:

app.use(express.json());

That’s enough to start writing some tests:

We use Vitest to unit test, and Playwright to end-to-end test.

To keep good organisation of our project we create a directory that will hold all our tests. We also want to differantiate between unit tests and e2e (end-to-end) tests.

First create a common test directory in our project root called “tests”. Then inside this directory we create two seperate directories: unit and e2e:

tests
β”œβ”€β”€ e2e
└── unit

Install the required packages:

npm i -D vitest && npm init playwright@latest

Choose the following for the Playwright installation:

βœ” Where to put your end-to-end tests? Β· tests/e2e
βœ” Add a GitHub Actions workflow? (Y/n) Β· false
βœ” Install Playwright browsers? (Y/n) Β· true

Remove the default generated test file example.spec.ts inside the tests/e2e directory, we soon write our own tests.

Playwright installed a configuration file in our root directory:

β”œβ”€β”€ playwright.config.ts

Because Playwright tests are end-to-end we need to have a web server running. We can configure this inside the configuration file by removing the comments around the web server configuration option at the bottom of the page:

/* Run your local dev server before starting the tests */
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },

And because we plan to use mongodb-memory-server while testing we need to write a custom package.json script to give the Playwright web server. This script should start our ordinary dev server but with NODE_ENV=test.

Add the script to package.json, we use cross-env to play it safe:

"playwrightServer": "cross-env NODE_ENV=test tsx server.ts"

Then change the webServer command:

command: 'npm run playwrightServer',

Whenever we run our Playwright tests it will start its internal web server using our command, setting the NODE_ENV=test, and use mongodb-memory-server.

Make a package.json script to run Playwright tests:

"test:e2e": "npx playwright test"

You can read more about Playwright and how to write tests here.

Moving on to Vitest:

Since we use a single tests directory with two seperate sub directories we need to tell Vitest what tests to run, otherwise it will try run all tests, including our Playwright tests.

To tell Vitest what files to run we create a small configuration file: vitest.config.ts.

Inside the configuration file we export our configurations and specify what directories and files to include when running our tests:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    // glob pattern matching all .test/.spec JS/TS files 
    // (js, jsx, ts, tsx) in /tests/unit, with optional c/m module prefix
    include: ["./tests/unit/*.{test,spec}.?(c|m)[jt]s?(x)"],
  },
});

Make a package.json script to run Vitest:

"test:unit": "npx vitest"

Running Vitest with default options will place it in watch mode, waiting for file changes. You can look into Vitest and more of its options here.

We might want to run our entire test suite, but make sure to set watch mode to false for Vitest, otherwise it will get stuck:

"test:all": "vitest --watch=false && npx playwright test"

Create a file unit.test.ts inside /tests/unit, and test the validation function:

import { test, expect } from "vitest";
import { validateBlogPostFormData } from "../../src/backend/validate";

// this is just a simple example test
// and the methods are pretty self explanatory
test("test validate formData is string", () => {
  expect(
    validateBlogPostFormData({
      blogTitle: "asd",
      blogText: "asd",
    })
  ).toBe(true);
});

Run it:

npm run test:unit

Output:

 βœ“ tests/unit/unit.test.ts (1 test) 1ms
   βœ“ test validate formData is string 1ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  18:41:08
   Duration  165ms (transform 21ms, setup 0ms, collect 16ms, tests 1ms, environment 0ms, prepare 37ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

Nothing special, we are just getting to know the technology, but in real world applications you probably want to do some more extensive testing.

To write a simple Playwright test, we test if possible to navigate to our dashboard page, submitting the form, and save an item to the database.

If successful we should read “Blog post created!” from the console.

Create a file e2e.spec.ts inside the tests/e2e directory, and place this into it:

import { test, expect } from "@playwright/test";

test("create a blog post", async ({ page }) => {
  // navigate to correct page
  await page.goto("http://localhost:3000/dashboard");
  // fill blog title field
  await page.getByLabel("Blog Title").fill("Test Blog Title");
  // fill blog text field
  await page.getByLabel("Blog Text").fill("Test Blog Text");
  // expect the console.log success message before we submit form
  const successMessagePromise = page.waitForEvent("console");
  // click the submit button
  await page.getByRole("button", { name: "Create Post" }).click();
  // load the success message
  const successMessage = await successMessagePromise;
  // expect the success message to be correct
  expect(successMessage.text()).toMatch("Blog post created!");
});

Running the test should give us this output:

Running 3 tests using 3 workers
  3 passed (4.5s)

To open last HTML report run:

  npx playwright show-report

Good job!

Now we finish the project

We will add the necessary code to build our project and make it ready for deployment. We then add code to perform CRUD operations on our blog posts. We begin update the backend logic, then update the frontend code.

Inside the scripts section, inside your package.json file, add the following scripts:

// this is used to initiate the build step
// it calls the two following specific build scripts 
"build": "npm run build:client && npm run build:server",
// this is the specific build step for the client code 
"build:client": "vite build --outDir dist/client",
// this is the specific build script for the server code 
"build:server": "vite build --ssr server.ts --outDir dist/server",

We then update our server.ts file to match the build process, and also add a route to retrieve all blog posts:

import express, { type Response } from "express";
import { fileURLToPath } from "url";
import path from "path";
// this is the updated imports from our dashboardController
// it also add the functionality to retrieve all blog posts
// we later update its exports and functionality
import {
  dashboard,
  getBlogPosts,
} from "./src/backend/controllers/dashboardController.js";
import { connectToDatabase } from "./src/backend/db.js";

import dotenv from "dotenv";
dotenv.config();

const port = process.env.PORT || 3000;
const isProduction = process.env.NODE_ENV === "production";

// this files filename, same as before 
const __filename = fileURLToPath(import.meta.url);
// the directory of this file i.e. project root, same as before
const __dirname = path.dirname(__filename);
// new code for our build process:
// relative to the built server.js file, i.e. this file
const __distPath = path.join(path.dirname(__dirname));
// new code for our build process:
// the built frontend path relative to the built server.js
const __distFrontendPath = path.join(__distPath, "frontend");

// we wrap our server functions in a main function
async function main() {
  await connectToDatabase(process.env.NODE_ENV === "test");

  const app = express();
  app.use(express.json());

  // we add a route to retrieve all blog posts 
  app.get("/posts", getBlogPosts);
  // this is still the route for our blog dashboard
  app.post("/dashboard", dashboard);

  // we use the same vite / express setup as earlier 
  let vite;
  if (!isProduction) {
    const { createServer: createViteServer } = await import("vite");
    vite = await createViteServer({
      server: { middlewareMode: true },
      appType: "spa",
      root: __dirname,
    });
    app.use(vite.middlewares);
  } else {
    app.use(express.static(__distFrontendPath));
    app.get("/*splat", ({ res }: { res: Response }) => {
      res.sendFile(path.join(__distFrontendPath, "index.html"));
    });
  }

  app.listen(port, () => {
    console.log(
      `running ${
        isProduction ? "PROD" : "DEV"
      } server at http://localhost:${port}`
    );
  });
}

// we run the main function and catch any errors
main().catch((error) => console.log(error));

In our src directory, create a file called constants.ts, and place this inside it:

// we add this to be able to filter our incoming form requests
// and now if we want to create, edit, or delete a blog post 
export const blogPostFormSubmitType = {
  create: "create",
  edit: "edit",
  delete: "delete",
};

Now update the BlogPostFormData type in the db.ts file:

// we also need to update the formData type 
export type BlogPostFormData = {
  blogId: string;
  blogTitle: string;
  blogText: string;
  // to include the form submit type
  submitType:
    | blogPostFormSubmitType.create
    | blogPostFormSubmitType.edit
    | blogPostFormSubmitType.delete;
};

We now update the code in our dashboardController.ts:

import type { Request, Response } from "express";
import { collections } from "../db";
// this is the updated BlogPostFormData type with the form submit types
import type { BlogPostFormData } from "../../types/bitkrets";
import { validateBlogPostFormData } from "../../frontend/utils/validate";
import { ObjectId } from "mongodb";
// we import the blogPostFormSubmitType we just created
import { blogPostFormSubmitType } from "../../constants";

// new function to return all blog posts
export async function getBlogPosts({
  req,
  res,
}: {
  req: Request;
  res: Response;
}) {
  // we try retrieve all blog posts from the database 
  try {
    const blogPosts = await collections.blogPosts?.find({}).toArray();
    if (blogPosts) {
      // if we have any blog posts we return them to the client
      return res.status(200).send(blogPosts);
    } else {
      // else we send a message saying we do not have any blog posts
      return res.status(204).send("There is not any blog posts to load");
    }
  } catch (error) {
    // if any errors we send a error message to the client
    return res.status(500).send();
  }
}

// we update the dashboard function with a name instead of being the default
export async function dashboard(req: Request, res: Response) {
  // we retrieve the form data
  const formData: BlogPostFormData = req.body;
  // and validate it
  if (!validateBlogPostFormData(formData)) {
    return res.status(400).send("Invalid form data!");
  }
  // we then make checks for what CRUD action to perform
  // we begin to check if we want to create a blog post 
  if (formData.submitType === blogPostFormSubmitType.create) {
    try {
      // then try to create a blog post to the database
      await collections.blogPosts?.insertOne(formData);
      // and send a success message to the client
      return res.send("created blog post");
    } catch (error) {
      // else log the error
      console.error("failed to create a blog post", error)
      // and send error message to the client 
      return res.send("Failed to create blog post");
    }
  // if not creating a blog post, we check if we want to edit the blog post 
  } else if (formData.submitType === blogPostFormSubmitType.edit) {
    try {
      // we try retrieving the blog post from the database 
      const filter = { _id: new ObjectId(formData.blogId) };
      // and create a updateDoc to inset into the database 
      const updateDoc = {
        $set: {
          blogTitle: formData.blogTitle,
          blogText: formData.blogText,
        },
      };
      // we update the blog post 
      await collections.blogPosts?.updateOne(filter, updateDoc);
      // and send a success message to the client 
      return res.send("updated to db");
    } catch (error) {
      // otherwise log the error 
      console.log("failed to edit the blog post", error)
      // and send error message to the client 
      return res.send("Failed to edit post");
    }
    // lastly we check if incoming request is to delete a blog post 
  } else if (formData.submitType === blogPostFormSubmitType.delete) {
    try {
      // we try delete it from the database 
      collections.blogPosts?.deleteOne({ _id: new ObjectId(formData.blogId) });
      // and send a success message to the client 
      res.status(200).send("deleted blog post");
    } catch (error) {
      // otherwise catch and log any error
      console.log("failed to delete the blog post", error)
      // and send error message to the client 
      return res.send("failed to delete blog post");
    }
  }
}

Now we need to adjust the client code to handle the different cases for our blog posts. We make the following edits to our dashboard.ts file:

// we import the newly created blogPostFormSubmitType
import { blogPostFormSubmitType } from "../../constants";
import type { BlogPost, BlogPostFormData } from "../../types/bitkrets";

// we edit our html to include the new placeholder where we will display all the blog posts 
function html() {
  return `
        <div id="blog-page">
            <h1>Dashboard</h1>
            <div id="blog-posts" style="display:flex;gap:2em">
                Loading blog posts...
            </div>
            <h3>Write new blog post:</h3>
            <form method="post" id="blog-form" style="display:flex;flex-direction:column;">
                <input type="text" id="blog-id" value="" hidden>
                <label for="blog-title">Blog Title</label>
                <input type="text" name="blog-title" id="blog-title">
                <label for="blog-text">Blog Text</label>
                <textarea name="blog-text" id="blog-text" rows="4" cols="12"></textarea>
                <button id="submit-button" data-submit-type="create">Create Post</button>
            </form>
        </div>
    `;
}

// we also update the logic to display the retrieved blog posts
// and to have the updated form submission types in our blog post form
async function logic() {
  // we fetch all blog posts
  const response = await fetch("http://localhost:3000/posts");
  // and assign them to an array
  const blogPosts: BlogPost[] = await response.json();
  // then fetch the div to show the blog posts
  const blogPostsDiv = document.getElementById("blog-posts");
  // if we have any blog posts, we map them to individual elements 
  // and place them inside the blog posts placeholder element
  if (blogPostsDiv) {
    blogPostsDiv.innerHTML = blogPosts
      .map(
        (post) =>
          `
                <div class="post" style="border:1px dotted">
                    <h5 data-title=${post._id}>${post.blogTitle}</h5>
                    <p data-text=${post._id}>${post.blogText}</p>
                    <button data-edit=${post._id}>Edit</button>
                    <button data-delete=${post._id}>Delete</button>
                </div>
            `
      )
      .join("");
  } else {
    // if we do not have any blog posts we log a message saying there is no posts
    console.log("Could not find blogPostDiv");
  }

  // blog post form inputs stay the same 
  const blogForm = document.getElementById("blog-form") as HTMLFormElement;
  const blogId = document.getElementById("blog-id") as HTMLInputElement;
  const blogTitle = document.getElementById("blog-title") as HTMLInputElement;
  const blogText = document.getElementById("blog-text") as HTMLInputElement;
  const submitBtn = document.getElementById(
    "submit-button"
  ) as HTMLButtonElement;

  // we add the action to delete a blog post
  // we fetch all the delete buttons for our rendered blog posts
  const deleteBtnList = document.querySelectorAll(
    "[data-delete]"
  ) as NodeListOf<HTMLButtonElement>;
  // if there is any delete buttons we add the logic
  if (deleteBtnList) {
    // we loop over each button
    deleteBtnList.forEach((deleteBtn) => {
      // and add event listener
      deleteBtn.addEventListener("click", async (event) => {
        // prevent default 
        event.preventDefault();
        // fetch the blog post id from our dataset
        let postId = deleteBtn.dataset["delete"];
        if (postId) {
          try {
            // we then create a form data object 
            const fd: BlogPostFormData = {
              blogId: postId,
              blogTitle: "delete",
              blogText: "delete",
              // with correct form submit type
              submitType: blogPostFormSubmitType.delete,
            };
            // then make the request to the server 
            const res = await fetch("http://localhost:3000/dashboard", {
              method: "post",
              headers: {
                "content-type": "application/json",
              },
              body: JSON.stringify(fd),
            });
            // wee show the response message
            alert(await res.text());
            // and reload the page 
            window.location.reload();
          } catch (error) {
            // or catch any error and display a message
            alert("Error while trying to delete a post, try again");
          }
        }
      });
    });
  }

  // logic to edit a blog post
  // we need to make sure the blog form is available, since its used to edit 
  // we also check for the specific form inputs
  // we use them to populate with the blog post to edit 
  if (blogForm && blogId && blogTitle && blogText && submitBtn) {
    // we fetch all edit buttons
    const editBtnList = document.querySelectorAll(
      "[data-edit]"
    ) as NodeListOf<HTMLButtonElement>;
    if (editBtnList) {
      // then loop over each
      editBtnList.forEach((editBtn) => {
        // and add event listener 
        editBtn.addEventListener("click", (event) => {
          // prevent default 
          event.preventDefault();
          // fetch the blog post id 
          let postId = editBtn.dataset["edit"];
          // then assign the blog post title to the blog post form 
          // or show error message 
          blogTitle.value =
            document.querySelector(`[data-title="${postId}"]`)?.innerHTML ||
            "Could not load the post";
          // same with blog text
          blogText.value =
            document.querySelector(`[data-text="${postId}"]`)?.innerHTML ||
            "Could't load the post";
          // we then set correct form submit type 
          submitBtn.setAttribute("data-submit-type", "edit");
          // and change the button text to save instead of create
          submitBtn.innerText = "Save Post";
          // then set the blog post id, or an error message 
          blogId.value = postId || "Could not load the post";
        });
      });
    } else {
      // else log an error message 
      console.log("Could not find etidBtnList");
    }
  }

  // logic to submit the blog post form 
  // we add an event listener to the blog post form of form submit 
  blogForm.addEventListener("submit", async (e) => {
    // prevent the default 
    e.preventDefault();
    const submitType = submitBtn.getAttribute("data-submit-type");
    // make sure the submit type is one of our specified types 
    if (
      submitType === blogPostFormSubmitType.create ||
      submitType === blogPostFormSubmitType.edit ||
      submitType === blogPostFormSubmitType.delete
    ) {
      // create a form data object to send to the server 
      const formData: BlogPostFormData = {
        blogId: blogId.value,
        blogTitle: blogTitle.value,
        blogText: blogText.value,
        submitType: submitType,
      };
      try {
        // then send the request to the server
        const response = await fetch("http://localhost:3000/dashboard", {
          method: "post",
          headers: {
            "content-type": "application/json",
          },
          body: JSON.stringify(formData),
        });
        // we then display the response message 
        alert(await response.text());
        // then clear blog post form input values
        blogTitle.value = "";
        blogText.value = "";
        // and refresh the page
        window.location.reload();
      } catch (error) {
        // otherwise catch any errors and display an error message
        alert("Error submiting the form");
      }
    } else {
      // if not any correct submit type, display error message 
      alert("Invalid submit type.");
    }
  });
}

// export the dashoard page logic 
export function DashboardPage() {
  return {
    html: html,
    logic: logic,
  };
}

Now we can run the application and build for production

Good job!