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.
- Pick a project you want to build e.g. a blog or a portfolio.
- Use Node as backend environment.
- Use Vite as build tool.
- Use TypeScript.
- Use Express as web server.
- Use Fetch (Fetch API).
- Use Vitest to unit test.
- Use Playwright to end-2-end test.
- Use GitHub and GitHub Actions.
- Use MongoDB with Node native MongoDB client driver.
- Host the production app on Render.com.
- Host the production MongoDB on MongoDB Atlas.
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:
- Navigation links (anchor elements) for the user to click to visit each page.
- “Container” html element where we dynamically render our pages content.
- Custom data attribute to allow us to target our links with JavaScript.
- Decide on page layout that will render each page.
- JavaScript function that listens to link clicks and render the correct page content.
- JavaScript function that updates the browsers history throught the History API.
- 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:
- Install MongoDB, MongoDB client, and mongodb-memory-server (to test our code)
- Write MongoDB logic
- Write the Express route to receive incoming form data
- Write a data validation function to validate incoming form data
- Install Vitest and test the validate function
- 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!