Deploying a React App to GitHub Pages automatically

It’s still a little tricky to deploy a react application to GitHub Pages in 2022. So I will explain some of the nuances for an app created by the create-react-app tool, but you could use the same principles with any other setup.

I assume you are familiar enough with git to commit and push. If not yet, please consider reading The Pro Git Book or any other git or GitHub-explaining book, e.g. GitHub Desktop Docs. I also assume that you already have a GitHub Account.

Ok, so you have written a React App, and it works perfectly on your computer when you run npm start and go to http://localhost:3000. Now you would like to share it with the world.

If you want to move fast, here is the steps:
  1. Enable Pages Deployment. Go to your Repository Settings, then the “Pages” tab. In the section “Build and deployment” select “Source”: GitHub Actions (screenshot).
  2. Copy the .github/workflows/deploy.yml file from my demo repository and put it into your project to the same place: .github/workflows/deploy.yml.
  3. Replace the PUBLIC_URL on the 28th line with the name of your repository. Perhaps, your repository is called game, so you should write:
    PUBLIC_URL: /game
    
  4. If you don’t have a react-router or other routing solution, change the 26th line from this:
    run: npm run build && npm run gh-pages:404
    
    to this:
    run: npm run build
    
  5. If you do have react-router or something, add gh-pages:404 line to your package.json scripts section:
    "gh-pages:404": "cp build/index.html build/404.html"
    
  6. Go to the “Actions” page of your repository, select the ”Deploy” action, and if it is green, check the https://<owner>.github.io/<project> page, where <owner> is your GitHub username and the <project> is the name of your repository. E.g. https://isqua.github.io/demo-routing-in-typescript-react-app/.

For those who want to understand, I will describe the meaning of these actions below.

GitHub Pages is a static hosting

You cannot run any server-side code in GitHub Pages, because it’s static-only free hosting. So you can deploy only some HTML, CSS, JavaScript, and media files that will be loaded in users’ browsers. Only the front-end part, you know.

You should also consider the URL where your project will be deployed. You have mainly two free options:

  • https://<owner>.github.io, so you have to create a repository named <owner>.github.io in your profile or organization, e.g. my is called isqua.github.io
  • https://<owner>.github.io/<repository>, so if you have a repository named game and your username is michel99, the URL of the hosted project will be https://michel99.github.io/game/

And yes, you can deploy a repository for <owner>.github.io and as many repositories for your projects as you want.

Build Your App with its URL in mind

So, you run npm start and open http://localhost:3000, and your app works. By this address, your browser downloads some http://localhost:3000/index.html file. If you look at this source code (right-click on the web page in the browser, and select “View Page Source”), you can see that there are some resources whose URLs start with a slash. It may be something like:

  • /favicon.ico
  • /manifest.json
  • /static/js/bundle.js

If you deploy this app to <owner>.github.io repository, it will work without additional effort. But if you deploy the app as is to <owner>.github.io/<project>, it may be broken:

  1. You go to https://<owner>.github.io/<project>, and the index.html file will be loaded fine.
  2. But it has the <script src="/static/js/bundle.js"></script> line.
  3. So the file will be requested from https://<owner>.github.io/static/js/bundle.js, but there is no such file. The right URL should contains project name: https://<owner>.github.io/<project>/static/js/bundle.js

So when you build your app, you need the file paths to include the project name.

For the bundle.js this is the wrong URL:

/static/js/bundle.js

And this is the right one:

/<project>/static/js/bundle.js

To achieve this, set the PUBLIC_URL environment variable when you build the project. Try to run these commands locally (replace the /game with the name of your repository):

PUBLIC_URL='/game' npm run build

It will produce some files in the ./build directory. Check the source code of the build/index.html file. All the reosurce links must be prefixed with /game:

<link rel="icon" href="/hello/favicon.ico" />
<script defer="defer" src="/game/static/js/main.81e90d6d.js"></script>
<link href="/game/static/css/main.9d5bf3df.css" rel="stylesheet">

Webpack will automatically add the /game prefix to the resources it produces. But, if there are some dynamically built paths in your code, the webpack won’t handle it. E.g., if you have code like this:

function getUserAvatar(username) {
   return `/assets/avatars/${username}.jpg`
}

You have to add the prefix by yourself. It may look like this:

const PUBLIC_URL = process.env.PUBLIC_URL || '';

function getUserAvatar(username) {
   return `${PUBLIC_URL}/assets/avatars/${username}.jpg`
}

If you use react-router, ensure you provide a proper basename to you router:

import React from 'react';
import { createBrowserRouter } from 'react-router-dom';

const PUBLIC_URL = process.env.PUBLIC_URL || '';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <MainPage />,
  },
  // ... other routes
], {
  // provide your PUBLIC_URL as a basename to the router,
  // so it may handle all links relative to the PUBLIC_URL
  basename: PUBLIC_URL,
});
How it works
  1. If you used create-react-app for starting your app, your app is built with react-scripts.
  2. react-scripts takes the PUBLIC_URL environment variable.
  3. react-scripts uses webpack behind the scenes and sets the output.publicPath option of webpack config to the value it gets from PUBLIC_URL environment variable.
  4. The process.env.PUBLIC_URL variable will be also available in your own code thanks to webpack.DefinePlugin.

So, if you do not use react-scripts, you have to set the publicPath option in webpack output by yourself according to webpack documentation. Also, consider using DefinePlugin if needed.

How to check if it works

Perhaps, you run this command to build your app:

PUBLIC_URL='/game' npm run build

Now you have the ./build directory with all the files. Copy it to a folder named as the project name, e.g. if your build it with /game, copy it to the game folder:

cp build game

Then run any static server:

npx serve -p 3001

Then go to http://localhost:3001/game/ and check that your app works.

Don’t forget to remove the game directory to avoid committing it.

Set up GitHub Actions

After you have got a workable build, it’s time to set up automatic deployment.

First of all, enable deployment to GitHub Pages in the Settings of your repository. Go to the “Pages” section and select “GitHub Actions” as a source of deployment.

Go to “Pages” section of your repository and select “Github Actions” as a source of deployment.
Enable Pages deployment with Actions

Next, you have to create a GitHub Workflow that builds and publishes your project.

Create a directory .github/workflows at the root of your repository. Then create a file with any name and the .yml extension. I prefer to name it deploy.yml. So I create a file .github/workflows/deploy.yml.

Let’s start to describe our workflow:

name: Deploy

on:
  push:
    branches: [ main ]

We just named the workflow “Deploy”. The name will be used in the “Actions” tab of your project.

And we also defined, that the workflow should start on every push event to the branch called main. If the branch you push to is called develop, you should write develop instead of main. You may also define your own trigger, please read the GitHub Workflows Docs for information.

Then, you have to define the jobs to be done to deploy your project:

name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    permissions:
      pages: write
      id-token: write
    steps:
      - name: checkout
        uses: actions/checkout@v3
      - name: setup node
        uses: actions/setup-node@v3
        with:
          node-version: 18.x
      - name: install dependencies
        run: npm ci
      - name: build app
        run: npm run build
        env:
          PUBLIC_URL: /game
      - name: upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          path: ./build
      - name: deploy to github pages
        id: deployment
        uses: actions/deploy-pages@v1

Take a closer look at the green chunks. Replace it with your values:

  1. Which version of nodejs do you use? Set it in the node-version parameter.
  2. What package manager do you use? If you use npm, leave the npm ci command. Otherway, replace npm ci with the command to install dependencies, e.g. pnpm install.
  3. What is the command to build your application? If you are using create-react-app, leave the npm run build.
  4. What the PUBLIC_URL should be? Use your repository name here. If the repository is called my-awesome-project, set PUBLIC_URL: /my-awesome-project. If the repository is <owner>.github.io, remove the line with PUBLIC_URL completely, because it’s not needed and your app will be served on https://<owner>.github.io/.
  5. What is the directory with application artifacts? What does npm run build produce? If you are using create-react-app, leave the ./build directory.

That’s it. Commit this file and push it to your repository.

How it works

So, we’ve just created a GitHub workflow. We set up the trigger that runs the workflow and the actions to perform in the workflow.

The workflow has a single job called deploy.

jobs:
  deploy:
    ...

The job references the environment called github-pages, which was created automatically when you set up GitHub Pages deployment source in repository settings. And set the URL of the environment to the output parameter called page_url of the deployment step, I will explain it a little further.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}

The job also has permission to write to the GitHub Pages of your repository. And write a GITHUB_TOKEN environment variable. These settings is required by the official deploy-pages action.

jobs:
  deploy:
    ...
    permissions:
      pages: write
      id-token: write

Then there are steps of the job, that define what to do. These two steps clone the repository and install a target nodejs version:

jobs:
  deploy:
    ...
    steps:
      - name: checkout
        uses: actions/checkout@v3
      - name: setup node
        uses: actions/setup-node@v3
        with:
          node-version: 18.x

These are the official actions by GitHub, you can even check its source code: actions/checkout, actions/setup-node.

The next steps install project dependencies and build the app:

jobs:
  deploy:
    ...
    steps:
      ...
      - name: install dependencies
        run: npm ci
      - name: build app
        run: npm run build
        env:
          PUBLIC_URL: /game

You can find that there is no uses parameter there. The steps just execute the commands in the run parameter in a shell, with the environment variables defined in the env section. So, after running these steps, you’ll have the build artifact with your app.

The last two steps deploy the artifact to GitHub Pages hosting:

jobs:
  deploy:
    ...
    steps:
      ...
      - name: upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          path: ./build
      - name: deploy to github pages
        id: deployment
        uses: actions/deploy-pages@v1

The actions/upload-pages-artifact actually just creates an archive of the folder you set in the path (./build folder in our case) and uploads the archive to some internal GitHub storage, so other steps can fetch the archive to work with it.

And the last actions/deploy-pages takes the archive generated by upload-pages-artifact and deploys it directly to GitHub Pages hosting. Notice the id: deployment line in this action. Due to this id, we can reference the step in other parts, like… in the environment section:

jobs:
  deploy:
    ...
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    ...
    steps:
      ...
      - name: deploy to github pages
        id: deployment
        uses: actions/deploy-pages@v1

This is how we set up the environment URL to the value, that the step with id deployment returns in its outputs.

How to check if it works

There is only way: write the .github/workflows/deploy.yml, commit, push and wait.

In about a minute you can go to your repository “Actions” tab and find a workflow there:

Go to “Actions” section of your repository
If you see the green checkmark sign, the deployment was successful

You can click on the deployment and see its jobs. In our case there is single job called “deploy”. You can also see the deployment URL:

Go to “Actions” section of your repository
If you see the green checkmark sign, the deployment was successful

Let’s click on the URL and check if your application is working correctly! Now the app is available to the world.

Handle 404 errors with react-router

If you have client routing in your app, built with react-router or something, you may face another issue. You navigate to some page in your app, like /game/player/:playerId, and it works perfectly. But when you refresh the page, you get the 404 error.

That’s why it happens:

  1. When you go to https://<owner>.github.io/<project>/, a browser fetches index.html and other resources, like main.js and main.css.
  2. Then, when you click on the links inside your app. Perhaps the link href is /game/player/foo react-router pushes a new state to browser history, and react re-renders your app. But there is no page refresh. It is why it’s called Single Page App, you know.
  3. But then you perform a refresh of the page (or open any link in a new tab), and a browser requests a page by the address /game/player/foo/index.html. And there is no index.html file there. Your app just serves /game/index.html and some JS and CSS files. So you get the 404 error.

There are some ways to fix it. The Create React App Documentation suggests these two:

  1. To use hashHistory, so instead of /game/player/foo, your URLs will be like /game/#/player/foo.
  2. The trick with a redirect from any 404 page to the index.html page. It is better in the aspect of displaying URLs, they will be like /game/player/foo. But it also causes an extra redirect: when you go to an internal URL of your app, a browser fetches 404.html, then the page redirects to the index.html with some parameters, and then index.html renders the app. This option also requires you to inject some scripts into your index.html file to handle the redirect.

But I prefer the third way. If you add the 404.html file to your website, GitHub Pages will show this page for any 404 error. But we want to render our react app there. What if we make the 404.html totally the same as the index.html file? It will work:

  1. If the browser requests some URL like /game/player/foo, the GitHub Pages answer with the content of 404.html.
  2. The 404.html includes main.js and main.css files, so the app will be rendered and work as expected.
  3. If we go to a different route, it will be captured by react-router and work.
  4. If we refresh the page or open some links in a new tab, it will be captured by the 404.html again.

So, the only thing we need to do is copy build/index.html to build/404.html before deploying the app. I prefer to do it in the following steps:

  1. Add a script that copies the file in the package.json:
    "scripts": {
      "start": "react-scripts start",
      "build": "react-scripts build",
      ...
      "gh-pages:404": "cp build/index.html build/404.html"
    }
  2. Run the scripts just after the building part in the deploy.yml:
    jobs:
      deploy:
        ...
        steps:
          ...
          - name: build app
            run: npm run build && npm run gh-pages:404
            env:
              PUBLIC_URL: /game
          ...
    

Then commit and push the changes in package.json and deploy.yml and wait for the deployment finish.

Now your app is perfectly deployed!

You can find all source code in one place at github.com/isqua/demo-routing-in-typescript-react-app.