Fullstack React TS App on GitHub Pages and render.com
TL;DR: We set up a boilerplate for a full-stack SPA using VITE hosted on GitHub pages and an Express.js server hosted on Render.
Our end goal: Website and finalized GitHub repository with a monorepo for both the frontend and backend.
We may want to split the hosting rather than have Express.js serve all the content because I will use the free backend server from Render, which can take 50 seconds or more to ‘wake up.’ Github pages have no such limitations so that we will load the SPA from Github pages. While the user is looking at the SPA, we will send a request to the backend to wake it up, and hopefully, the user won’t notice any slowdown due to it waking up.
This article assumes you are familiar with git, Github, typescript, and javascript.
Let us begin, shall we?
Set up a new project on GitHub and select a git ignore template for node; the rest of the settings are up to you. Once you have done that, clone the project to your machine and open it in your favorite editor. Let’s specify the node version for this project. At the time of writing, Node 20.17.0 was the latest LTS version, so I’ll use that.
Create a nvmrc file with the node version discussed above: echo 20.17.0 >.nvmrc
if you have nvm installed, you should install that node version (if not already installed) and then nvm use
to select the correct version.
Let us create the folders for both the frontend and backend: mkdir -r frontend backend backend/src
Let us set up the backend first.
Backend setup
In the backend folder, initialize a new NPM package.json and install some required packages
npm init -y ─╯
npm i typescript ts-node nodemon @types/cors @types/express -D
npm i express cors
let us create a .env file specifying the backend port to run on 3001 echo PORT=3001 >.env
Let’s set up a typescript config file in the backend folder. tsconfig.json
:
{
"compilerOptions": {
"target": "ES2020", // Specifies what version of ECMAScript the code should be compiled to.
"module": "NodeNext", // Specifies what module code should be generated.
"moduleResolution": "NodeNext", // Specifies how TypeScript should resolve module imports.
"outDir": "./dist", // Redirect output structure to the directory.
"rootDir": "./",
"strict": true, // Enable all strict type-checking options.
"esModuleInterop": true, // This will allow you to use default imports with CommonJS modules.
"skipLibCheck": true // this option will skip type checking all .d.ts files.
},
"ts-node": {
"esm": true
},
"include": ["src/**/*"], // Specifies which files to include when compiling your project.
"exclude": ["node_modules", "**/*.spec.ts", "frontend"] // Specifies which files to exclude when compiling your project.
}
We need to update the package.json. First, update the script section. Then, add the key type
and set it to module
{
"scripts": {
"dev": "nodemon -e js,mjs,cjs,json,ts --watch ./ --exec node --env-file .env --no-warnings=ExperimentalWarning --loader ts-node/esm src/index.ts",
"build": "npm install && tsc",
"start": "node dist/src/index.js"
},
"type": "module",
}
Finally, let us create index.ts
, which should be placed in the backend/src
folder:
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json()); // for parsing application/json responses
if (process.env.FRONTEND_URL) {
// If provided,only allow connections from the frontend website to be
// specified in an environment variable file
const corsOptions = {
origin: process.env.FRONTEND_URL,
};
app.use(cors(corsOptions));
} else {
// if a server is not specified in environment variables,
// allow connections from all origins
app.use(cors());
}
app.get("/api/helloworld", (req, res) => {
res.send("Hello World! From the backend!");
});
app.get("/api/wake-up", (req, res) => {
res.send("I am awake!");
});
const { PORT = 3000 } = process.env;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
Test the server: run npm run dev
Then, assuming you followed my example, use a browser to test the following 2 URLs: http://localhost:3001/api/helloworld and http://localhost:3001/api/wake-up.
We should now commit and push up the changes as we have completed the backend server portion and will set up the hosting next.
Head over to Render. If you haven’t already done so, create an account and link your Render account to GitHub.
- Click on new, and deploy a web service
- Connect the source code to your repo on GitHub
- Give it an appropriate name
- Make sure the language is set to
Node
- Choose your main branch name by default
main
- Pick an appropriate region
- The root directory should be left blank
- Build command is
npm run build
- Start command is
npm start
- Choose the server instance type; for me, I’ll choose the free one
- Set up an environment variable called (case sensitive)
PORT
with a value of80
- Add a second environment variable called
FRONTEND_URL
with a value of your eventual GitHub pages URL root — by default, unless changed to use DNS, is https://<username>.github.io/ - Under advanced, set a build filter with the following two included paths
backend/**
&.nvmrc
- Finally, deploy the web service and wait until it is completed. You’ll know this because it will display the message “Your service is live” and detect that we chose port 80.
- We should now validate that the production server is working appropriately. You will find a link to your URL on your web service’s dashboard page. Click it. You’ll get an error that we expect.
cannot get /
- add
/api/helloworld
and validate you get a response - do the same except for the URL
/api/wakeup
and validate you get a response
You have completed the backend setup
Frontend time
Navigate to the frontend
folder and run npm create vite@latest .
Then choose react
followed by typescript
next, let us install the npm modules npm i
Let’s then test the server npm run dev
then connect to the URL provided, and you should see the default Vite page:
Let’s git commit here, and then we can modify the front end to our needs.
Assuming the project will live in the default <github repo>
subfolder as in https://<username>.github.io/<repo name>/ we must make a couple of changes.
In your vite.config.ts
file, add the key base
with the /<repo name>/
as the value, so it should look something like this:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/fullstack-ts-with-render-and-gh-pages/'
})
Add the homepage
key to your package.json set to your eventual resting place of the site, which by default is https://<github username>.github.io/<repo name>
let us create a defaults file for configs under frontend/src/utils/config.defaults.ts
with the following code, adjusting for the port you’ve specified in the backend’s .env
file.
export const backendUrl =
import.meta.env.VITE_BACKEND_URL || "http://localhost:3001";
Now, we should update app.tsx
to demonstrate that we can communicate with the backend.
Add the following to the imports and update the react import to include useEffect
import { backendUrl } from './utils/config.defaults'
import { Link } from "react-router-dom";
before the return
of the App
function, add:
const [serverResponse, setServerResponse] = useState("Loading...");
useEffect(() => {
const fetchResponse = async () => {
try {
const res = await fetch(`${backendUrl}/api/helloworld`);
console.log(backendUrl);
setServerResponse(await res.text());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setServerResponse(`Error fetching from server: ${error.message}`);
}
};
fetchResponse();
}, []);
right above the closing fragment,</>
, add <p>{serverResponse}</p>
<p>{serverResponse}</p>
<Link to={"/helloworld"}>Go to HelloWorld</Link>
Now, make sure your local backend and frontend servers are running. You should see a “Hello World” at the bottom of the page.
This is a good place to commit.
It’s time to add react routing. In the frontend
folder, run npm i react-router-dom
and create the folder,frontend/src/pages
. Move the App.tsx into that folder and rename the file, and function name to MainPage
create a new frontend/src/App.tsx
file and past the following into it:
import { useEffect } from "react";
import { backendUrl } from "./utils/config.defaults";
import { Route, Routes } from "react-router-dom";
import MainPage from "./pages/MainPage";
function App() {
useEffect(() => {
const fetchResponse = async () => {
// lets wake up the backend while the user is interacting with the front end
try {
await fetch(`${backendUrl}/api`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error(`Failed to connect to ${backendUrl}\n${error.message}`);
}
};
fetchResponse();
}, []);
return (
<Routes>
<Route path="/" element={<MainPage />} />
<Route
path="/helloworld"
element={<p>Hello world from the frontend</p>}
/>
</Routes>
);
}
export default App;
Now update main.tsx
to the following:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { BrowserRouter } from "react-router-dom";
// only needed if you are using the default github page path for the rep
import { frontendBaseFolder } from "./utils/config.defaults.ts";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
{/*
basename is only needed if you are using the default github path
for the repo
*/}
<BrowserRouter basename={frontendBaseFolder}>
<App />
</BrowserRouter>
</StrictMode>
);
Lastly from config.defaults.ts
create and export a variable, if needed for the repo name for example: export const frontendBaseFolder = “/fullstack-ts-with-render-and-gh-pages/”
Navigate to your frontend and test that it still works, then navigate to your root plus helloworld/ and tests that that route also works.
Time for another commit
Time to prep github for deployment of the frontend
Head to the settings of the github repo head to the pages
section and set the source to be Guthub Actions
Head to within settings, security>Secrets and Variables>Actions then click on the variables tab create a new repository variable with the name VITE_BACKEND_URL
and set it to the value of your backend server.
Now in the repo create .github/workflows/deploy-frontend.yml
with the following contents
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch and the frontend folder
push:
branches: ["main"]
paths:
- "frontend/**"
- ".github/workflows/deploy-frontend.yml"
- ".nvmrc"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
env:
VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }}
# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Build
run: |
cd frontend
echo VITE_BACKEND_URL=$VITE_BACKEND_URL > .env
ls
npm run build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload dist folder
path: "./frontend/dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
commit and push up the new changes, a github action should be triggered.
Once the action completes go ahead and check your new page (the URL can be found under settings>pages
Now, let's test the helloworld route using the link at the bottom of the page. This will work, but we will get a 404 error once we refresh. Github is looking for a file that isn’t present. We will now fix this.
Create frontend/public/404.html
updating the URL param with your repo name if needed
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Page Not Found</title>
<script>
sessionStorage.redirect = location.href;
</script>
<meta
http-equiv="refresh"
content="0;URL='/<repo name>'"
/>
</head>
<body></body>
</html>
Within index.html add the following right before the closing body
tag
<script>
(() => {
const redirect = sessionStorage.redirect;
delete sessionStorage.redirect;
if (redirect && redirect !== location.href) {
history.replaceState(null, null, redirect);
}
})();
</script>
Commit and push up again. Once the action is complete refresh the helloworld route and you should see it working.
Congratulations and I hope you have found this helpful in your endeavors.