Quick and dirty: GitHub Pages hosting
Or complete guide how to host your simple PoC SPA application on GitHub Pages without the damn DevOpsity. Plus a pinch of homegrown philosophy.
TL;DR: Please read, it can’t just be a matter of fork and use — you have to understand what you are doing. After all, only the repository name has to be configured in just two files, but you have to understand where and why.
You know what? Complexity. Complexity is everywhere. As an architect, sorry, Architect, I’m in a constant struggle with it.
Don’t be afraid; that’s it. This article is definitely not for or about pathos, and I’ll explain why.
This week my imaginary friend (ok, it was just a student; and more than one) asked me: all the time he needs to build small Proof-of-Concept web applications, as architects tend to do, and demonstrate them to teams, stakeholders, product owners, and any other interested parties like teacher.
Usually in large companies this is not a problem, they have their own hosting (or they provide a cloud account for experiments), but if you are working with low-budget projects and startups…
So the exact requirements were:
- Small React-based single-page app should be publicly available;
- Easy deploy, preferably automatic;
- Optional multipage;
- Just for show. Quick and dirty. Create, demo and forget, but keep it available for a while. Turn it on and off at any time.
Of course I’ve proposed GitHub pages. Sure enough he said he has paws and so needs step-by-step instruction, the best — the repository with a starter-pack.
That’s it. And you know what? I couldn’t help it. I’ve completely forgotten the topic; the last time I did something that simple was 3 years ago. You know, Terraform, DevOps, AWS, Azure, GCP, other stuff like that… sometimes you have to go back to your roots…
Clearly, I promised to help. And I did help; but I was surprised at the number of little things a newcomer should consider. So: step-by-step instructions on how to do an incredibly simple thing. Shame on everyone who has to read this, except, of course, myself.
Step 0. Create the local project
Our stack will be
- Vite
- React
- Typescript (optional, but why should it be any different here in 2024?)
because it’s quick, easy, and convenient for prototyping.
Starting with https://vitejs.dev/guide/. Or with any of guides, like this one.
npm create vite@latest gh-example-app
Choose “React” and “Typescript”. Follow instructions.
cd gh-example-app
npm install
npm run dev
See the result in browser:
Well, okay. Let’s break this impressive example down a bit to add something useful.
Step 0.1. Adding pages and routing
Disclaimer: that’s exactly what this guide is designed to do. It’s pretty easy to get results without routing and multipage and SPAs — surprisingly, just using GitHub Pages the way they were designed — but that’s not the way of the samurai.
Let’s install React Router. Let’s assume we are in our project folder:
npm install -S react-router-dom
Ok, then create a simple routing and several pages. I also did some changes here:
- Removed unnecessary styles and files,
- Created dedicated
pages
andcomponents
folders with indicesindex.ts
Just for the sake of clean architecture. I prefer to follow minimum quality standards even for dirty fire-and-forget PoC projects.
Well, here we have super-simple application with three pages: Home, Profile and About. You can use it as a starter kit, filling it with the features you want to demonstrate urbi et orbi.
import './HomePage.css'
import {AppMenu} from "../../components"
export const HomePage = () => {
return (
<>
<AppMenu/> <-- Did you notice?
<h1>Home page</h1>
... content
</>
)
}
Note: the main menu is just added to every page. In a more or less real application there should be a router with outlets, page guards, lazy loading, but man, this is a PoC of some feature, not an application. I mean, you probably only want to demonstrate one thing, not the whole app with real complicated navigation and stuff. So screw DRY; god-blessed copy-paste and KISS are our best friends.
Press Ctrl+C|V to pay respect.
The routing:
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import {createBrowserRouter, RouterProvider,} from 'react-router-dom'
import {AboutPage, HomePage, ProfilePage} from "./pages"
const router = createBrowserRouter([
{
path: '/profile',
element: <ProfilePage/>,
},
{
path: '/about',
element: <AboutPage/>,
},
{
path: '*', // <-- for any not specified routes (404)
element: <HomePage/>,
index: true
},
])
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<h2>Simple GitHub Pages-hosted SPA PoC</h2>
<RouterProvider router={router}/>
</React.StrictMode>,
)
And a menu that couldn’t be simpler:
import './AppMenu.css'
import {NavLink} from "react-router-dom"
export const AppMenu = () => {
return (
<div className={'app-menu'}>
<NavLink to={'/home'}>Home</NavLink>
<NavLink to={'/profile'}>Profile</NavLink>
<NavLink to={'/about'}>About</NavLink>
</div>
)
}
Screenshot:
As a result we have
Step 0.2. Fix invalid routing highlights
Well it works? Not exactly.
Let’s tye profilesss
in the address bar:
Router redirected us to Home page because we’ve provided for this moment –
// main.tsx
{
path: '*', // <-- for any not specified routes (404)
element: <HomePage/>,
index: true
},
– but the menu item is not highlighted. It looks a bit sloppy, let’s fix it.
As a part of my famous quick and dirty “copy-paste” (aka QDCP) approach, let’s do it in just a CSS:
// app-menu.css
.app-menu {
// ...
a {
// ...
&.active { // <-- regular highlight of react-router .active class
background-color: #646cff;
color: #fff;
}
}
}
// if nothing is highlighted with .active class...
.app-menu:not(:has(a.active)) {
// ...highlight the same the first menu item
a:first-of-type
background-color: #646cff;
color: #fff;
}
}
By the way, you’d be surprised how Copy+Paste can be more effective in real life than a pure, reusable, abstract generic approaches. I remember when we spent two weeks of a couple of senior developers’ time implementing a server-side abstract preprocessing approach and generating a specific page by JSON configurations that never happen instead of just writing a simple instruction for a junior developer. Like, “Take this template. Change these values. Save the static HTML page here.” Once or twice a year. 20–30 minutes of cheap manual operation per year instead of 160 hours of work by highly paid professionals.
And it happens all the time: premature ejacu… sorry, scalability, maintainability, sustainability and other -ilities. If your abstractions can be replaced by a cheap junior who has nothing to do anyway, that’s cheaper and better.
Step 0.3. Let’s publish!
Go to GitHub, create a repo:
Next, we initialize Git in our local project. Of course, it should be the other way around, but personally I usually create (when it’s from scratch) a minimal setup, see how viable it is, and only then run Git. I just don’t see the point of doing git for the initial install with just npm install -s …
and a couple of minor changes, but it’s up to you.
git init
git add .
git commit -m 'Initial'
Return to GitHub, copy the instructions and follow in your terminal:
git remote add origin https://github.com/%YOUR REPO%
git branch -M main
git push -u origin main
Well, we have our repository successfully pushed to GitHub.
Step 1. GitHub Pages
Now we need to get our brilliant app not only hosted as a source code, but also available on the Web as a site.
Let’s click:
and
We need a Classic Pages experience.
What’s better than a good old classic? Nothing.
Of course, we can use GitHub Actions. But we need to write CI/CD pipeline and all this YAMLs and this damn DevOps… If I was going to do that, I’d rather use AWS S3 static hosting.
I want to believe… no… I want to break free… no… I want to make it as simple as possible. And simplify it even more.
I don’t need to automatically run CI/CD conveyor on each main/master
commit. I don’t need complex actions, branches policies and release tags. I know a lot of buzzwords, and there are enough of them in my work hours.
I want to run the deployment manually. Come on, it’s a PoC! It’s never going to be a real production application!.. His destiny is to demonstrate something and die. Live fast, die young.
It’s amazing how many deadly serious programs have come to the same finale. A lot of effort, very clean code, the best of the best practices, NX/Lerna from the very beginning, and the same oblivion… But the good thing is that you don’t have to be Google to have your own graveyard of abandoned projects.
It should be as easy as uploading a pre-built site using FTP.
Step 1.1. gh-pages
To be able to automatically… ok, semi-automatically, publish our app we need to create a special branch called gh-pages
and push the changes there.
The most simple way to do that is manually create the branch and manually commit changes from main
to gh-pages
.
This is not the way of the samurai. Even the Mandalorians wouldn’t do that.
We will automate that. Let’s open our package.json
and add a command:
git subtree push --prefix dist origin gh-pages
// package.json
{
"scripts": {
"deploy": "npm run build && git subtree push --prefix dist origin gh-pages",
},
}
Which should it do? It will build our app and push the extracted results (dist
folder only) to GitHub to the gh-pages
branch.
Ok? No, it does not work:
This is because our dist
folder is in .gitignore
file by default. Let’s comment it:
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
# dist <-- here
dist-ssr
*.local
...
Commit + push, then run npm run deploy
again:
Let’s go to our GitHub and check:
Nice. Let’s go back to the “Settings/Pages” page:
See “gh-pages”? Select that, root, click “Save”.
Nothing happens.
Don’t worry, you just need to wait a minute or two. After that, refresh the pages and take a look:
Let’s click Visit site!
It’s not really working. Just a blank page. Well OK, let’s check the console… it can’t find the resources in assets
folder.
Step 1.2. Fixing the resources
We need to set up base
parameter to our resources. It is in the vite.config.ts
in the root folder –
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '' // <-- add this
})
Run npm build
, Commit, push, run npm deploy
again… push it… nothing changes? You need to wait a bit. You can refresh the “pages” page till see ‘now’:
And voilá –
Every time you run deploy, you have to wait, usually less than a minute, and you’ll get a result.
Nice, it’s really impressive — I usually have do a lot of CI/CD scripting to have the same result. And need to maintain and actualise the scripts, especially with Docker.
Step 1.3. Fixing the routes
Well, it is here. It works. But what if we start navigating? Click the menu –
Everything seems to be in order… but do you catch a slight difference?
Yes, that’s “QDCP” on the first screenshot.
By the way, without Step 0.2, we would have noticed the problem instantly — because “Home” would not have been highlighted after loading.
No big deal, everything works… until you refresh the page.
So, there we have two options…
- Just put it as is. No big deal, it’s just that users should always use the root link you specified. It’s a PoC, isn’t it? The user has to struggle. Otherwise, it is a paid commercial product. Well, sometimes.
- Custom 404 page + fixing the routing.
Step 1.3.1 Environment variable and base name
First we need to fix that “QDCP” error. What is this? Well, that’s a root path of the route.
Where in local environment we have something like “http://localhost:5173/about”, on GitHub we need to use our repository name: “https://lexey111.github.io/QDCP/about”:
It means, on GitHub hosting we have non-root slash “/” routing. We need to change basename
of React Router to handle that.
Ok, with Vite we can solve that.
Vite has different environments support out of the box. dev
for npm run dev
which we are using for local development and production
which we have on build + deploy by npm run deploy
.
First we need to create file .env.production
// .env.production
VITE_SITE_BASE='/QDCP'
This “/QDCP” — especially note the leading “/”, it’s important — is the name of our repository.
This variable will be available automatically during the build. Let’s use it in our routing:
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import {createBrowserRouter, RouterProvider,} from 'react-router-dom'
import {AboutPage, HomePage, ProfilePage} from "./pages"
const baseName = import.meta.env.VITE_SITE_BASE // <-- note that
const router = createBrowserRouter([
{
path: '/profile',
element: <ProfilePage/>,
},
// ...
], {basename: baseName || ''}) // <-- and that. Can be just baseName, btw.
ReactDOM.createRoot(document.getElementById('root')!).render(
//...
For normal local development builds it will be empty; for production (deploy-able) builds we will have “/QDCP” value substituted. If I wanted something more complex, I would use named environments and specific build commands in package.json
but for PoC purposes it is more than enough.
Build, commit, deploy — wow, it works:
Here we have router links fixed: when user clicks on navigation, he/she has “/QDCP/profile” instead just “/profile” in navigation bar.
All set?
Well, not quite. If the user refreshes the page, he/she still gets a 404 message. Crap.
This is because we only have one page for GitHub pages, “QDCP/index.html”. It is what it is: A single-page application, SPA. For GutHub route “QDCP/profile” means “QDCP/profile/index.html”, which is absent. Understandable, but it’s a shame.
But it’s free, which makes the situation a little more bearable.
Step 1.3.2 Custom 404
Well, GitHub pages automatically process file 404.html
or 404.md
in the root folder ( dist
). Let’s add the page there.
To do that we will add a custom 404.html
file and force Vite to copy that to output dist
folder because by default Vite processes just single index.html
Let’s install special plugin for Vite from here: https://www.npmjs.com/package/vite-plugin-static-copy
npm i -D vite-plugin-static-copy
Update vite.config.ts
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
import {viteStaticCopy} from 'vite-plugin-static-copy'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: './src/assets/404.html', // <-- pretty straightfoward, yeah?
dest: './'
}
]
})
],
base: ''
})
And, of course, let’s create the static HTML file which will redirect users to home page:
<!-- ./src/assets/404.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Simple GH Pages Hosting SPA starter pack</title>
</head>
<body>
Redirecting...
<script>
setTimeout(() => {
const pathname = window.location.pathname
window.location.href = 'https://lexey111.github.io/QDCP/?path=' + pathname
}, 1000)
</script>
</body>
</html>
Here we have a page that will display “Redirecting…” message, and, well, redirect us to the home page after 1s. Also it adds a param to be used later:
?path=' + pathname
Why? Well, because the redirect will take us to the “Home” page all the time. If a user clicks the “Refresh” button on the profile page — the “Home” page will appear instead, i.e. a redirect to the root page. It’s a little ungodly.
I’ll use my favourite QD method to get to the desired route:
// AppMenu.tsx
import './AppMenu.css'
import {NavLink, useNavigate, useSearchParams} from 'react-router-dom'
import {useEffect} from 'react'
const baseName = import.meta.env.VITE_SITE_BASE
export const AppMenu = () => {
const [searchParams] = useSearchParams() // <-- get the "path"
const navigate = useNavigate()
useEffect(() => {
// check is there any redirect?
if (searchParams?.get('path')?.includes(baseName)) {
const redirectUrl = searchParams?.get('path')?.split('/').pop()
if (redirectUrl) {
navigate('/' + redirectUrl) // redirect
}
}
}, [])
return (
<div className={'app-menu'}>
<NavLink to={'/home'}>Home</NavLink>
<NavLink to={'/profile'}>Profile</NavLink>
<NavLink to={'/about'}>About</NavLink>
</div>
)
}
This does very simple thing. If the application has been restored after a refresh, and the path url parameter is added to the redirect (by 404.html
) — it will redirect to that path.
AppMenu
is included to all the pages, including the first “Home” page, so it will always work. In a more mature solution this should be a separate hook/page guard — to be honest, pre-guard to do not render “stub” page at all, — but for PoC this is sufficient.
That’s it.
We have just two files to set up: 404.html
with root URL to redirect; and .env.production
with base path. Btw, it is quite simple to automate that to have single file (and with GitHub actions we can even do it during server-side build) but it violates CP of QDCP principle.
Keep It More Than Simple, Stupid, you will throw it away in a week.
Simple and efficient; the only drawback is that you will have build artefacts in the main branch. Well, this can be avoided if you manage gh—pages
branch manually: create a branch, merge the main branch there, handle .gitignore
, build, push, etc., but I think for PoC this tradeoff is quite tolerable.
Enjoy!
PS: GitHub repo: https://github.com/lexey111/QDCP