Crisp React can optionally split a monolithic React app into multiple Single Page Applications (SPAs) and selectively prerender the landing/index page of any SPA at the build time. This innovative flexibility can offer the best performance for many websites, more about it later. Yet it doesn't come at the price of complicating the solution which remains as simple as a React application could possibly be.
For example, the Router is arguably one of the most important SPA components because it drives the application by controlling its page switching. Crisp React doesn't use a custom Router, in each SPA the routing is managed by a separate instance of React Router which is the de facto standard for React SPAs.
The value offered by Crisp React is not limited to performance. On the one hand, the value comes from what this solution doesn't bring about. There is no need to learn a framework, depend on it and use non-portable programming constructs in the React components you write, therefore no vendor lock-in. On the other hand, the list of useful features includes:
On the contrary to popular belief that SEO requires SSR, this solution innovatively demonstrates how to get all SPA pages indexed by Google and specific, targeted SEO advice on how to achieve indexing is provided.
Sample websites:
- Demo - Full stack. Automated build performed by Heroku from this repository. All pages, including internal SPA pages, are noted in sitemap and indexed by Google.
- Demo - Jamstack. Automated build performed by Cloudflare Pages from the same repository. All pages, including internal SPA pages, are mentioned in sitemap and indexed by Google.
- Production. Based on Crisp React. All pages, including internal SPA pages, are noted in sitemap and indexed by Google.
If the websites built and deployed automatically from GitHub repository are indexed, then there is no any inherent reason why your website, built using the same boilerplate, won't be indexed as well provided you keep following the guidelines this solution already adheres to. No matter which build option, Jamstack or full stack, you choose.
Google has spent considerable effort on educating developers about Structured Data and made the Rich Results Test tool available online. Again, this solution assists by helping to embed one or several pieces of such data (static or dynamic) into the <head>
element of your website. The existing sample data can be used as a placeholder and replaced with your own data.
More details are available from the SEO section.
The performance is achieved by:
- Splitting React app into multiple SPAs, each rendered by its own and smaller script bundle that can be downloaded faster.
- The landing/index page of any SPA can be prerendered at the build time to let a browser build the DOM without waiting for the bundle to be downloaded, parsed and executed.
- Standard Webpack techniques such as tree shaking, script bundle minification and compression.
- Long term caching of script bundles in the browser cache.
To achieve smooth rollout of versioning changes, only bundles are cached client-side. HTML files are not. The risk of old bundles getting stuck in the cache is eliminated by bundle fingerprinting (its name includes the build hash).
This topic is covered further in the separate section. It provides more insight into the performance related issues along with benchmarking results.
The solution provides full stack deployments with several cloud vendors yet again avoids vendor lock-in by reusing the same Docker container. This is complemented by Jamstack deployments that aim for simplicity and speedy production release. All deployments achieve high test scores from reputable online testing tools like web.dev/measure and webpagetest.org.
Build option | What it does | Sample builds |
---|---|---|
Full stack | Builds React app and backend. The latter serves static app files and API responses to end users. Although in many production deployments the static files would be served to a CDN instead. Having one server instead of two (one for frontend, another for backend) reduces the attack surface of the deployment, brings down its costs, complexity and delivers other benefits like CORS avoidance. |
|
Jamstack | Builds the React app only. The build artifacts are served to end users by the webserver supplied by a Jamstack vendor. Since the demo project has no backend, the API calls go directly to the cloud service. In production the API calls could target endpoints implemented by AWS API Gateway or similar infrastructure. |
|
Based on the ability to optionally split your React application into multiple Single Page Applications (SPA). For example, one SPA can offer an introductory set of screens for the first-time user or handle login. Another SPA could implement the rest of the application, except for Auditing or Reporting that can be catered for by yet another SPA. This approach would be beneficial for medium-to-large React applications and can be combined with other code splitting techniques - see Q & A section.
Smaller codebase and slim building machinery translate into less maintenance burden and easy troubleshooting. The other (e.g. development) side of this coin is also bright: Adding third-party libraries will less likely cause incompatibility with existing software. Simplicity coupled with absence of non-portable properties, opinionated data fetching hooks etc. means there is less room for the incompatibility issues to develop, less potential friction points.
The amount of code that any Github repository contains can be measured using the link: https://img.shields.io/github/languages/code-size/<owner-name>/<repo-name>
. For Crisp React, the amount of code is currently around 135 kB and is shown by the code size badge. The client and the server subprojects each contribute ~50% of the codebase. The latter is used in full stack builds only.
The following CSS handling approaches can be used:
Approach | Advantages | Disadvantages |
---|---|---|
Plain CSS | Simplicity. Performance. |
Several React components can have rules with the same class selector names. It creates collisions that need to be tracked manually. |
CSS Modules | Performance. Name collisions resolved automatically. Support for rule reuse via composition. |
Rule duplication (if undetected) increases the size of the resulting stylesheet. |
LESS | Rich functionality. | Learning curve exists. Potential for overengineering. |
CSS-in-JS | Productivity. Learning curve minimal with basic CSS knowledge. Helps to create self-contained components. CSS handling logic could be sophisticated, depend on run-time data etc. |
Script dependency results in loading delays. If used unwisely (e.g. for a rule that affects page layout), can trigger Cumulative Layout Shift (CLS). The CLS could be visible to users as a sudden and unpleasant page movement or layout adjustment. Loss of JSX code portability. The chosen library, namely Emotion, is not Atomic (see the review), so rule duplication is an issue. |
The CSS handling techniques can be combined in order to maximise advantages of each approach while trying to minimise its downside. More details are available under the CSS Handling heading.
The options include:
- Intellectual Property protection,
- RBAC augmentation,
- Website partitioning for development in parallel by several teams or developers,
- Maintenance self-sufficiency.
The first two options are based on making the script bundle download conditional. This functionality is not implemented to keep the codebase slim but can be added for both Jamstack and full stack builds by utilising solutions like Auth0 or PassportJS.
The third option uses a SPA as a unit of planning, development and acceptance. For example, you can have a Login SPA, Main SPA, Reporting SPA, Audit SPA, Admin SPA, etc.
The last option is based on Simplicity and means that when a dependency is updated, for example to fix a security vulnerability, you don't have to wait for an external maintainer to include the updated version into Crisp React. You can execute cd server; yarn upgrade xxx --latest
on the cloned repository yourself.
Debug a minified/obfuscated, compressed production bundle and put breakpoints in its TypeScript code using both VS Code and Chrome DevTools. Development build debugging: put breakpoints in the client and backend code and debug both simultaneously using a single instance of VS Code. The specific instructions are available under the Scenarios heading.
The addendum lists a few cases that answer the question โWhen use this solutionโ. It also addresses the opposite e.g. โWhen not to use itโ.
- Getting Started
- Features
- Testing
- Usage - Jamstack
- Usage - Full Stack
- Scenarios
- Custom Domain and CDN
- SEO
- What's Next
- Pitfall Avoidance
- Q & A
- License
Install yarn
if not already installed: npm install yarn -g
With VS Code
Prerequisites: Chrome and VS Code with 'Debugger for Chrome' extension.
- Clone the
crisp-react
repository:
git clone https://github.com/winwiz1/crisp-react.git
cd crisp-react
- Install dependencies:
yarn install
- Open the workspace file in VS Code:
code ./crisp-react.code-workspace
- Start the debugging configuration
'Debug Client and Backend (workspace)'
. - Wait until an instance of Chrome starts. You should see the Overview page:
- Choose the
NameLookup
component from the menu. You should see its page:
- Stop the running debugging configuration (use the 'Stop' button on VS Code Debugging toolbar two times or press
Control+F5
twice).
Without VS Code
After executing the following commands:
git clone https://github.com/winwiz1/crisp-react.git
cd crisp-react
yarn install && yarn start:prod
you will have a running instance of backend (e.g. Express) serving the newly built React app that can be seen by pointing a browser to
localhost:3000
.Terminate the backend by pressing
Control+C
.
The section can be concluded by optionally renaming the solution. Rename the top-level directory from crisp-react
to your-project
and set the SPAs.appTitle
variable in the spa.config.js
file accordingly. Ignore the rest of the file for a moment, it's covered in depth in the SPA Configuration section.
Use the configuration page presented by Cloudflare Pages to let Cloudflare build and deploy the solution. The details are provided under the Cloudflare Pages heading.
The optional splitting of a React application into multiple SPAs (each rendered by its own bundle) improves the application loading time. The vendor
bundle contains node_modules/
dependencies and is reused between SPAs so that there is no need to download it again when switching from one SPA to another.
Every SPA has a landing page displayed during initial rendering by the component included into the SPA. In webpack terminology such a component is called entry point. An SPA (and its bundle) is comprised of this component, the components it imports and their dependencies. Let's see how Crisp React defines the SPAs.
The client subproject builds an application with SPAs defined by the SPA Configuration block in the spa.config.js
file:
/****************** Start SPA Configuration ******************/
var SPAs = [
new SPA({
name: "first",
entryPoint: "./src/entrypoints/first.tsx",
ssr: true,
redirect: true
}),
new SPA({
name: "second",
entryPoint: "./src/entrypoints/second.tsx",
ssr: false,
redirect: false
})
];
SPAs.appTitle = "Crisp React";
/****************** End SPA Configuration ******************/
Each SPA is defined using 4 pieces of data: name, entry point (e.g. the landing page component) and two boolean flags. Ignore the flags for a moment. There is also an appTitle
, it provides the application-wide default setting for the <title>
tag in the <head>
section of all pages. The title can be easily overwritten as needed.
SPA's name "first" is used to define the SPA's landing page e.g. /first.html
and name the bundle that renders the SPA: first<hash>.js
. More information about all the configuration data pieces is provided in the configuration file comments. The file is copied during the backend build from the client subproject and used to configure the client, the backend and the unit tests.
To reconfigure the application to have a separate SPA for login and another one for the rest of the application, change the SPA Configuration block as follows:
/****************** Start SPA Configuration ******************/
var SPAs = [
new SPA({
name: "login",
entryPoint: "./src/entrypoints/login.tsx",
ssr: true,
redirect: false
}),
new SPA({
name: "app",
entryPoint: "./src/entrypoints/app.tsx",
ssr: false,
redirect: true
})
];
SPAs.appTitle = "DemoApp";
/****************** End SPA Configuration ******************/
and then follow the instructions provided in the configuration file comments.
Since any SPA is comprised of the landing page component (entry point) and its imports, the coding to support the SPA reconfiguration can start by making login.tsx
render the login page: either directly or maybe with the help of an imported component that will ask for user credentials. Another component could render a page asking for alternative credentials e.g. biometrics or ask for multifactor authentication (MFA).
The entry point app.tsx
would import the component responsible for rendering the page presented to the user after logging in. Express could potentially be modified to ensure only authenticated users can download the bundle for this SPA.
The newly written app.tsx
should verify the client is logged in (for example by checking the cookie set by backend after successful login) and if not redirect to the landing page of the 'login' SPA: /login.html
. In the same manner login.tsx
should check if the client has been authenticated and if so redirect to /app.html
.
No modifications are required for the backend. It will be reconfigured to serve the two HTML pages, namely /login.html
and /app.html
, which are the landing pages of our two SPAs.
To turn off code splitting, simply leave one SPA in the SPA Configuration block.
Tip: Let's assume over the time the application has grown and acquired extensive reporting capabilities, perhaps with a reporting dashboard that imports many components. In this case the third SPA and its entry point
reporting.tsx
can be added to the SPA Configuration block. The entry point would import the dashboard and use it for rendering. Such an addition would take little time but bring performance and development/testing benefits. For example, some tests can focus on a React application that has the reporting SPA as the only entry in the SPA Configuration block thus taking the rest of the application out of the testing scope.
SSR is enabled for production builds. In order to turn it off rename the postbuild:prod
script in package.json
, for example prepend an underscore to the script name. This will reduce the build time.
By default SSR is enabled for the first
SPA and disabled for the second
SPA. To toggle this setting follow the instructions provided in the respective file comments.
The solution allows to use each CSS handling approach as a sole CSS handling technique or combine it with any or all of the remaining approaches - with no configuration effort.
To take this approach create a file with the .css
extension and a name that doesnโt end with .module.css
. Place it anywhere under the src/
subdirectory, for instance under src/css/
or next to your component under src/components/
.
Multiple files can be created. At the build time all imported .css
files will be combined into a single stylesheet with class selectors left intact. The stylesheet, created under client/dist/
, will be downloaded and cached by a browser.
The solution uses this approach for two purposes:
-
To put frequently used CSS rules on the global scope and share it among components to avoid duplication caused by each component having its own similar rule. Suppose you are creating an accessible webapp so each component has lots of
<span>
elements with various screen reader prompts and the same class name:class=โsr-onlyโ
. The relevant CSS rule in a plain CSS file like this one can be easily shared. -
To modify styling of an existing or third party library component that expects predefined class selectors. For example, like in that file.
The filename of a module must end with .module.css
. At the build time all such files will be combined into the single stylesheet mentioned above with class selectors mangled to ensure uniqueness.
To embed class selectors into JSX code, the mangled names are required. These names are not available until a build is completed. Therefore this helper object is used to map unmangled class selectors (e.g. .left_component
) into the mangled ones (returned by cssStyle.left
).
In order to improve loading performance, the solution uses this approach for the CSS that determines the overall layout of a page (or a major component) while leaving more subtle/detailed and numerous CSS rules for the CSS-in-JS library.
LESS is like plain CSS but on steroids. Lots of extra features are available. The solution uses one such feature: 'parent selector' denoted by ampersand.
Multiple .less
files can be created. At the build time all imported .less
files will be combined into the single stylesheet along with the rules produced by plain CSS files and CSS modules.
The @emotion/react
package of the Emotion library is used. To take this approach follow the documentation and search for the css({
pattern in .tsx
files located under src/components/
.
The class selectors are generated at run-time in browser's memory and combined into a separate stylesheet. The stylesheet is then programmatically inserted into the <head>
element of the DOM tree.
The insertion is delayed by script bundles processing and execution. Other drawbacks of this approach include possible CLS and loss of JSX code portability. Also the Emotion library is not Atomic (see the review) so rule duplication is an issue.
The solution contains debuggable test cases written in TypeScript. It provides integration with React Testing Library on the client and Supertest on the backend. Both using Jest as the test engine.
The client and the backend can be tested independently by executing the yarn test
command from their respective subdirectories. Alternatively the same command can be executed at the workspace level.
The repository is integrated with Travis CI and Heroku for CI/CD and with Cloudflare Pages for CD. Every push to the repository causes Travis and Pages to start a VM, clone the repository and perform a build. Then Travis runs tests while Pages deploys the build to jamstack.winwiz1.com. This is followed by Heroku deployment, also automated and delayed until Travis tests finish successfully.
The test outcome is reflected by the test badge and is also shown by the icon located after the last commit description and next to the last commit hash. To access more information, click on the icon. The icon is rendered as the check mark โ๏ธ only if all the CI/CD activities performed by Travis CI, Heroku and Cloudflare Pages were successful. Otherwise an icon with the cross mark โ is shown.
Jamstack deployments do not use the Express backend. Static React files are served to clients by a server supplied by Jamstack provider. Therefore all Jamstack deployments are vendor-specific.
You might prefer to simplify deployments by having a single SPA called "index". This is achieved by having the following SPA configuration block:
/****************** Start single SPA Configuration ******************/
var SPAs = [
new SPA({
name: "index",
entryPoint: "./src/entrypoints/first.tsx",
ssr: true,
redirect: true
}),
];
SPAs.appTitle = "Crisp React";
/****************** End single SPA Configuration ******************/
This SPA configuration block is a simplified version of the default one. It ensures the automatically generated HTML file is called index.html
and makes integration with some vendors more straightforward. However this simplification is optional, Jamstack build supports multiple SPAs as demonstrated in the Cloudflare Pages example below.
The following command is used to build a Jamstack client:
yarn build:jamstack
After the command finishes, the build artifacts are located in the client/dist/
directory ready to be served by the webserver supplied by a Jamstack provider.
Use the yarn dev
and yarn lint
commands executed from the client/
directory to debug and lint Jamstack client.
If you have the SPA configuration block as shown at the beginning of this section, then a new website will be built and deployed to *.pages.dev
domain after the steps listed below are completed. The follow-up SEO section is optional.
If your SPA configuration block is different, then the newly built and deployed website will not work until the SEO section is completed.
-
Clone Crisp React repository.
git clone https://github.com/winwiz1/crisp-react.git cd crisp-react
-
Create a new GitHub repository by visiting repo.new.
-
Point the cloned repository to the newly created one and push it there.
git remote set-url origin https://github.com/your-github-username/your-newly-created-repo git push
-
Deploy to Cloudflare Pages by logging into the Cloudflare dashboard and creating a Cloudflare Pages project. Use
Menu > Pages > Create a project
. You will be asked to authorize read-only access to your GitHub repositories with an option to narrow the access to specific repositories.Select the repository which you pushed to GitHub at the previous step and on the "Set up builds and deployments" screen, provide the following information:
Configuration option Value Production branch master
Build command yarn build:jamstack
Build output directory client/dist
Add the following environment variable:
Environment variable Value NODE_VERSION 16.14.0
Optionally, you can customize the "Project name" field. It defaults to the GitHub repository name, but it does not need to match. The "Project name" is used to create a unique
*.pages.dev
subdomain. If the name is unique, it will be used as is, otherwise it will be altered a bit to ensure uniqueness. The resulting subdomain will be referred to as 'per-project subdomain' e.g.<per-project>.pages.dev
.After completing the configuration, click on the "Save and Deploy" button. You will see the deployment pipeline in progress. When it finishes, a website similar to this can be found on the per-project subdomain. If there is no SPA named "index", you will need to navigate to "/your-spa-name". In addition, you will have the new Project visible under the top level 'Pages' menu in Cloudflare dashboard.
-
Adding a domain that you own.
If you have the SPA configuration block as shown at the beginning of this section, then add a domain by following the instructions. Otherwise follow the steps described under the SEO heading instead.
Each subsequent push into the repository will trigger the pipeline. If it finishes successfully, a new website deployment is created and made available on both per-project and per-deployment subdomains. The latter comes in the form of <per-deployment>.<per-project>.pages.dev
.
You can rollback to any of the previous deployments anytime. Those are still available to users at the older per-deployment subdomains. A rollback ensures that a website available at the created earlier per-deployment subdomain becomes accessible on the per-project subdomain as well.
If you have the SPA configuration block as suggested at the beginning of this section, then getting metrics for the new website is only a few clicks away with the cloud instance of Lighthouse ran by Google and available at this page. The metrics should be similar to jamstack.winwiz1.com
:
The report generated by web.dev has "CPU/Memory Power" metric at the bottom. It reflects the power of the hardware used by Lighthouse to emulate a mid-range ๐ฑ phone, currently Moto G4. This metric affects the performance score. Cloud instance of Lighthouse at web.dev runs on a shared cloud VM and the metric additionally depends on the current workload. It varies from time to time.
In case the SPA configuration block is different, the metrics can be obtained after completing the SEO section.
Follow the steps described in the AWS document.
The "index document name" (mentioned at the steps 2.7 and 5) depends on the name of the SPA that has the redirect
flag set to true
. If the simplified SPA configuration block shown at the beginning of this section was used, then the "index document name" is index.html
otherwise (in case the SPA block was left intact) the name resolves to first.html
.
Execute the build command shown at the beginning of this section and copy all the files from the client/dist/
directory (except for maps) to the cloud bucket at the steps 5.6 and 5.7.
AWS CloudFront or Cloudflare CDN can optionally be used in front of the S3 bucket. In this case it's essential to ensure the CDN cannot by bypassed. The Stack Overflow answer shows how to restrict access to Cloudflare only.
All full stack deployments use the same Docker container to avoid vendor lock-in. A container acts as a mini operating system providing your code with the same run-time dependencies no matter where the container runs. Another benefit of this approach is that your programs are less likely to break during deployments due to differences between your run-time environment and the one supplied by hosting provider. It makes a container (represented by the sequence of build instructions in Dockerfile
to be a robust deployment vehicle.
Docker multi-staged build is used to ensure the backend run-time environment doesn't contain the client build-time dependencies e.g. client/node_modules/
. It improves security and reduces container's storage footprint.
In the current section we will build and run the container locally using Docker. This is followed by cloud deployments to Heroku, Google Compute Engine (GCE) and Cloud Run.
Install Docker. To perform full stack build of Crisp React as a Docker image and start a container, execute Windows command file start-container.cmd
or Linux shell script start-container.sh
. Then point a browser to localhost:3000
.
Both the command file and the shell script can also be executed from an empty directory in which case uncomment the two lines at the top of each file. Moreover, the files can be copied to a computer or VM that doesn't have NodeJS installed. The only prerequisites are Docker and Git.
Pre-built Docker image is available at Docker Hub.
Clone the repository:
git clone https://github.com/winwiz1/crisp-react.git
cd crisp-react
Now decide which of the following two options to choose.
Recommended if you have fast Internet and a powerful development machine or notebook and want to reduce the build time.
Install Docker and Heroku CLI. Then execute heroku login
command.
Perform a full stack build to create a Docker image and deploy it to Heroku:
heroku container:login
heroku create <app-name>
heroku stack:set container -a <app-name>
heroku container:push web -a <app-name>
heroku container:release web -a <app-name>
Replace the <app-name>
placeholder with your Heroku app name. The app will have the URL: <app-name>.herokuapp.com
.
Recommended if you don't have a powerful development machine and/or cannot install Docker and Heroku CLI. It takes much longer than other build options.
Perform the following steps after having cloned the repository:
- Create a new GitHub repository by visiting repo.new.
- Point the cloned repository to the newly created one and push it there.
git remote set-url origin https://github.com/your-github-username/your-newly-created-repo git push
- Login to Heroku and create a new app. At this stage it has no content. Use the Settings tab to set the app's stack to 'container'. Then switch to the Deploy tab and choose GitHub as the deployment method. Finally trigger a manual build. Optionally enable automated builds - this is how the demo website was deployed.
If you own a domain name, then it's recommended to add a CDN by implementing the optional steps described in the Custom Domain and CDN section. It will significantly boost performance and improve security to some extent. The extent is limited due to the fact that the DNS record for your app e.g.xxxxxx.herokudns.com
is public so the CDN can be bypassed with a potential attacker accessing your app directly.
In this section we are going to create a VM instance with OS optimised by Google (performance and security wise) to run a container. The container will be built using the Dockerfile
.
This section can be completed without installing any software.
Currently a single e2-micro
VM instance is conditionally free. That is, there is no charge for one such VM per billing account provided it has been deployed in one of the few selected regions including us-west1
. We will deploy in this region.
The cost of a static IP address and charges for egress (network traffic from our VM to Internet) are specified there. The static IP address cost is around $3 USD per month and will initially be charged against 90 days, $300 free credit offered to new customers. The egress charges are perceptible if there are Gigabytes of traffic which is unlikely for our trial deployment with Cloudflare CDN - it offers unlimited cache bandwidth and free DDoS protection on all plans including the Free Plan.
As an extra precautionary measure against an unexpected bill, you can create a throw-away project at the first Deployment step described below and dispose of the project at the end of this section.
-
To get started with GCE complete the Before you begin section. Accept the suggestion to create a throw-away project at the step 1 and ensure this project is selected as the default.
-
If you prefer to work with your local development environment then install Cloud SDK by following instructions on this page. Alternatively start Cloud Shell which is a free VM with Cloud SDK and other software preinstalled.
-
Enable Artifact Registry and Cloud Build for your project by executing the commands:
gcloud services enable artifactregistry.googleapis.com gcloud services enable cloudbuild.googleapis.com
-
Ask Artifact Registry to create a Docker repository called
crisp-react-repo
:gcloud artifacts repositories create crisp-react-repo --repository-format=docker --location=us-west1 --description="My test Docker repository"
-
Give
docker
access to this repository and all other repositories (if any) in theus-west1
region:gcloud auth configure-docker us-west1-docker.pkg.dev
-
Clone Crisp React repository:
git clone https://github.com/winwiz1/crisp-react.git cd crisp-react
The subsequent commands have to be executed from
crisp-react/
directory.Optionally make some code changes.
Since containers facilitate full stack builds, consider various building and debugging scenarios provided under the Scenarios heading. As a very minimum, consider
yarn start:prod
followed by pointing a browser to http://localhost:3000 to check the React app is working. -
Decide if you prefer to build a Docker image locally or in the cloud. For the local option, the following commands will build the image and push it to
crisp-react-repo
repository:docker build -t us-west1-docker.pkg.dev/your-throw-away-project-name/crisp-react-repo/crisp-react-image:1.0 . docker push us-west1-docker.pkg.dev/your-throw-away-project-name/crisp-react-repo/crisp-react-image:1.0
Alternatively achieve the same in the cloud (Docker doesn't need to be installed):
gcloud builds submit --tag=us-west1-docker.pkg.dev/your-throw-away-project-name/crisp-react-repo/crisp-react-image:1.0 .
-
Create a static IP address
crisp-react-ip
:gcloud compute addresses create crisp-react-ip --network-tier STANDARD --region us-west1
Note there is a charge (it was mentioned above) for the address that is higher if the address is not assigned to a VM.
-
Create a Container-Optimized OS VM instance
crisp-react-vm
to run a container created from our Docker image held incrisp-react-repo
repository:gcloud compute instances create-with-container crisp-react-vm \ --project your-throw-away-project-name \ --container-image us-west1-docker.pkg.dev/your-throw-away-project-name/crisp-react-repo/crisp-react-image:1.0 \ --container-stdin \ --container-tty \ --machine-type=e2-micro \ --zone=us-west1-b \ --scopes=cloud-platform \ --tags=crisp-react-tag \ --network-interface=network-tier=STANDARD,address=crisp-react-ip \ --metadata=cos-update-strategy=update_disabled,google-logging-enabled=false \ --metadata-from-file=startup-script=./deployments/gcp/startup.sh
-
Create a firewall rule to allow incoming HTTP traffic:
gcloud compute firewall-rules create crisp-react-rule \ --description="Allow incoming HTTP traffic" \ --direction=INGRESS \ --allow tcp:80 \ --target-tags=crisp-react-tag \ --priority=1000 \ --enable-logging
-
Find out the external IP address (referred to below as
external-address
):gcloud compute addresses describe crisp-react-ip --region us-west1
Use a browser to check if the React app is working:
http://external-address
. You should see a page similar to this.
If you own a domain name, then itโs highly recommended to add a CDN. It will significantly boost both performance and security. The steps are:
-
Login to Cloudflare dashboard and add a site. In response, Cloudflare will provide you with the names of two DNS nameservers.
-
Login to the domain registrar account and change the nameservers from the ones supplied by the registrar to the nameservers provided at the previous step.
-
In Cloudflare dashboard add a DNS record:
Type Name IPv4 address A
your-domain
external-address
Replace
your-domain
with either a subdomain name (e.g.crisp-react
for crisp-react.winwiz1.com) or apex (e.g.@
) if you use the root domain. Ensure "Proxy status" of this record is set to "Proxied". -
Perform the Step 6 located under the Custom Domain and CDN heading to exempt API calls and responses from caching.
-
Verify that integration with Cloudflare was successful by checking the page
https:/your-domain/cdn-cgi/trace
. It should resemble the content ofhttps:/crisp-react.winwiz1.com/cdn-cgi/trace
. -
Whitelist the IP addresses used by Cloudflare to ensure that CDN cannot be bypassed and no one can access the
external-address
directly. To effect this restriction add the--source-ranges=<csv-list>
command-line option to thegcloud compute firewall-rules create
command shown before. The<csv-list>
placeholder needs to be replaced by the comma-separated list of IP ranges used by Cloudflare. -
The
crisp-react-vm
impersonates the default GCE service account and uses the default VPC. Consider replacing both resources with their non-default counterparts to improve security. If you need to ssh into the VM, usegcloud compute ssh --zone <zone-name> <vm-name> --tunnel-through-iap ...
and ensure the VM has no ports opened for access from Internet except for whitelisted Cloudflare IP ranges. -
The
crisp-react-vm
has its logging disabled to save memory and reduce workload on CPU. Consider upgrading the VM frome2-micro
to a more potent type in order to enable COS updates and logging. -
Despite of having the CDN and GCE firewalls effectively chained, it's still imperative to have a reverse proxy (in front of Express server) that has been specifically hardened to be exposed to Internet directly or via a firewall. Nginx is written in C and would be a good candidate being available as a Docker image. It should sanitise all the API requests and clamp down on HTTP verbs, request size, limit ingress rate (in addition to rate limiting already implemented in Express) etc.
-
In case of cost-conscious or demo deployment, delete the throw-away project created earlier. This will clean up the VM and other resources created throughout this section.
As a single-click container deployment option, you can build and deploy the container on Cloud Run. The prerequisites are to have a Google Cloud account with at least one project created and billing enabled.
A single click on the button below is all what is required to create and start the service:
The build will take a while due to free Cloud Shell using a free cloud VM with modest specs. After the build and deployment are finished you can click on the provided link and see the page rendered by the client.
โ It is highly recommended to either add a firewall protection to the created service or to delete it if the deployment was used as a demo or proof of concept. The explanation why this is needed can be found under the Considerations heading.
Delete the service using the command:
gcloud run services delete crisp-react --platform=managed --region=us-central1 --project=<project-name>
It can be conveniently executed from the Cloud Shell session opened during the deployment. Update the region
with the one chosen during the deployment and replace <project-name>
with your project name. Alternatively delete the service using Cloud Run Console.
The remainder of this section contains additional considerations are not specific to this solution and would be relevant for any React SPA.
Although Cloud Run provides an ample free tier usage in terms of bandwidth and number of requests, you are billed for the incoming requests once the free usage threshold, 2 million calls per month, is exceeded. This scenario wouldnโt be infeasible if the service URL is discovered and used to mount a Layer 7 DDoS attack (or come close to it by emulating a significant workload). There is an additional cost for the running time exceeding its free threshold which can be exacerbated by the service scaling itself up under attack.
Therefore adding a firewall with IP address black/whitelisting and rate limiting or deleting the service promptly after a demonstration helps to mitigate this risk. Alternatively a Cloud Run service can be deployed in non-public access mode (--ingress internal
), however this is outside of our scope aimed at a public website creation.
It would appear that using ingress control or rate limiting was not possible in the past with Cloud Run in public access mode. This functionality is currently available however it's not added directly to Cloud Run. Rather it requires a serverless network endpoint group (NEG). Once a NEG has been created, you can use other GCP products e.g. external HTTPS load balancer to implement rate limiting or Cloud Armor to control ingress. You might want to check if the price of either product fits into your budget.
The scenarios are grouped depending on whether the client or the backend subproject is used. The Client Usage Scenarios apply to both Jamstack and full stack builds. The Backend Usage Scenarios apply to the full stack build only.
This section complements the deployment described under the Heroku heading. It maps Heroku app URL to a custom domain you own. After that, Cloudflare CDN is added in front of Heroku servers
to take advantage of the distributed cache provided by Cloudflare and achieve much better performance with improved security. Both custom domain and CDN are optional. If you haven't used Cloudflare previously this answer could be useful.
Prerequisites:
- Domain name ownership,
- Cloudflare account. It's free and can be created by following this link.
The steps:
-
Log to the registrar of your custom domain e.g.
yourdomain.com
and create a subdomain, for examplecrisp-react.yourdomain.com
. While you can choose any valid name for the subdomain, 'crisp-react' will be assumed for the next steps. -
Click on the "Add domain" button on Heroku Settings page at
https://dashboard.heroku.com/apps/<app-name>/settings
to addcrisp-react.yourdomain.com
as a custom domain to your app. Heroku will provide a name of a host similar toxxxxx.herokudns.com
. Copy this name to the clipboard. -
Use the DNS settings provided by the registrar to add a DNS record:
Record Type Host or Name Value or Points To or Content CNAME
crisp-react
xxxxx.herokudns.com
It will take some time for the new record to propagate across the globe. Once this delay is over, the Heroku app will be available from
crisp-react.yourdomain.com
. -
Log to Cloudflare dashboard and add the root domain
yourdomain.com
as a site to your account. Choose either the Free or a paid Cloudflare plan. As for SSL settings, select the Full option if the Heroku app is accessible via HTTPS protocol and the Flexible option if it uses HTTP.At the end Cloudflare will provide you with the hostnames of the two of its name servers. Use the DNS settings provided by the registrar (some registrars keep name servers under different menu/setting) to replace the name servers of your registrar with the ones provided by Cloudflare.
-
Adjust Cloudflare DNS setting for the newly added site. During the step (4) Cloudflare likely have discovered most of the DNS records applicable to the domain
yourdomain.com
from the registrar and automatically imported those. However theCNAME
record added at the step (3) could be missing in which case you will need to add it again, this time to Cloudflare DNS settings.In case there is no site or app mapped to
yourdomain.com
or in case there is one but you are not interested in it being processed by Cloudflare, change 'Proxy Status' of Cloudflare DNS entries from 'Proxied' to 'DNS only' leaving only theCNAME
record created at the step (3) or (5) in the 'Proxied' state:
The setting can be toggled by clicking on the orange Cloudflare icon.
-
Use Cloudflare Page Rules to add the following two rules:
The order of the rules is important. Since only one page rule is applied, the more specific API rule should be on the top.
The maximum cache duration was limited to 2 hours on the Free plan, however Cloudflare has removed this restriction. For example, you can set the duration to 7 days and ensure the subsequent re-caching occurs every week for all .html pages, script bundles etc.
After the steps are completed the Heroku app will be using distributed caching and a free SSL certificate for the custom domain. Also the cache related statistics, monitoring and the breakdown of incoming requests by country will be available from Cloudflare even on the Free plan.
Verify that integration with Cloudflare was successful by checking the page https:/crisp-react.yourdomain.com/cdn-cgi/trace
. It should resemble the content of https:/crisp-react.winwiz1.com/cdn-cgi/trace
.
This topic has been debated at extraordinary length in countless articles and videos. In this section we are going to touch mostly on one facet: SPA and SEO.
This section uses Cloudflare Workers. Cloudflare Free Plan offers unlimited CDN bandwidth while being literally packed with other goodies and freebies that translate into a truly outstanding value offered by the Plan. However Workers are not included into it and come with a separate Workers Plan. It does have a free option but there is no offer that is both free and unlimited. Basically there is a choice between having a free option with 100,000 daily limit on the Workers invocation count and switching to a paid Workers Plan. Any Workers Plan is fully compatible with Cloudflare Free Plan and its unlimited CDN bandwidth.
โ If you are on a paid Workers plan and are concerned about incurring charges as a result of Level 7 DDoS attack, consider asking Cloudflare Support about possible mitigations. Also you might want to set the DDoS rule calledHTTP requests causing a high request rate to origin
toBlock
(or other action) with sensitivity set toHigh
.
Claims that SPA is bad for SEO are common while proof and evidence are scarce. Let's reverse this trend by providing a specific technical advice on how to get Google to:
- Confirm that each SPA page can be indexed,
- Accept a request to index each SPA page. Both confirmation and acceptance will be obtained by using Google URL Inspection Tool which is a part of Google Search Console (GSC). The tool will be used to Inspect a live URL (that points to a SPA page) and request it to be indexed.
- Domain name ownership.
- Completing a deployment: either full stack (with Heroku or GCE) or Jamstack with Cloudflare Pages.
- Adding Cloudflare CDN as described earlier. Applies to full stack deployments only.
- Creating an original content that meets Google requirements (e.g. avoid duplicate content etc.) and adding it to your website.
This sub-section uses Cloudflare Workers to achieve SEO for a React app that was split into more than one SPA.
Regardless of the build type (full stack or Jamstack), there is no need to use Workers if you have one SPA only and your SPA customisation block is identical to the one shown at the beginning of that section.
Therefore use Workers as described below if you already have more than one SPA or are planning to have several SPAs in the future. Otherwise skip this sub-section and proceed to the next one.
- Create a Cloudflare Worker (in simple terms it's a cloud function) by visiting workers.new and replace the auto-generated code with the content of this file. Modify the Worker Customisation block at the top of the code by following suggestions in the comments and click on the "Save and Deploy" button.
- Unmap the worker from the
*.workers.dev
domain it was automatically deployed to. Map it to your custom domain or subdomain instead to ensure the Worker is invoked to handle each request. For example, the Worker for the full stack demo website was mapped to the pathcrisp-react.winwiz1.com/*
.
-
Let Cloudflare manage DNS for the domain you own and want to use for the deployment. Login to Cloudflare dashboard and add a site. In response, Cloudflare will provide you with the names of two DNS nameservers. Login to the domain registrar account and change the nameservers from the ones supplied by the registrar to the nameservers provided by Cloudflare.
-
In Cloudflare dashboard add a DNS record:
Type Name IPv6 Address AAAA
subdomain
100::
Replace
subdomain
with either a subdomain name e.g.jamstack
to deploy tojamstack.<your-domain>.com
or apex e.g.@
if you prefer to use the root domain. Check the "Proxy status" of the record is set to "Proxied".The "Proxied" status ensures the DNS record won't become public. Cloudflare will create another public DNS record that ensures the requests for
subdomain
are routed to Cloudflare datacenters. Once handled there, the requests would have been dropped since the address100::
is used to discard traffic. But in reality the requests will be handled by the Worker we are about to create at the next step. -
Create a Cloudflare Worker by visiting workers.new and replace the auto-generated code with the content of this file. Modify the Worker Customisation block at the top of the code by following the suggestions in the comments and click on the "Save and Deploy" button.
-
Unmap the Worker from the
*.workers.dev
domain it was automatically deployed to. Map it to your custom domain or subdomain instead to ensure the Worker is invoked to handle each request. For example, the Worker for the Jamstack demo website was mapped to the pathjamstack.winwiz1.com/*
.
- Review the codebase to ensure each page correctly sets the
<title>
HTML element and the canonical<meta>
tag. This can be done by searching all.tsx
files for the<Helmet>
pattern and reviewing the relevant code. - Add your custom domain to GSC using the "+ Add Property" menu.
For example, the production website was added as a domain property
virusquery.com
whereas for the demo websites the root domain was added first as a domain propertywinwiz1.com
. Once Google has verified the domain ownership, both demo websites were added separately as a URL property each e.g.https://crisp-react.winwiz1.com/
andhttps://jamstack.winwiz1.com/
. - Click on the "URL Inspection" menu to activate the URL Inspection Tool and type the path to the page you would like to index. You can copy the path from
sitemap.xml
and paste it. The response will state that "URL is not on Google" telling you the page hasn't been indexed yet. - Click on the
"TEST LIVE URL"
link to get a confirmation the page can be indexed. Optionally review the screenshot of the page rendered by GSC. - Request indexing for the page by clicking on the
"REQUEST INDEXING"
link. The response should say your request has been added to the priority crawl queue.
The last 3 steps will have to be repeated for all the pages of each SPA.
You can use the "URL Inspection" menu to monitor if the page was indexed. It can take from a few days to a couple of weeks for the page to be added to Google index. At which time the response will state: "URL is on Google". When that happens, you can double-check that the page was indexed by performing Google search for site:<your-domain>.com
. The result should list the indexed page(s) similar to this search.
Finally, it will take up to another week for the indexed pages to appear in the indexing report under the GSC "Coverage" menu:
SPA Website | GSC Screenshot |
---|---|
crisp-react.winwiz1.com |
Link |
jamstack.winwiz1.com |
Link |
virusquery.com |
Link |
The following options to embed Structured Data into the HTML <head>
element are available and can be used independently or in conjunction with each other:
- Append a static piece of structured data to the
head-snippet
file. The data is visible in a browser using the "View page source" menu. This is how the structured data typeSoftwareApplication
was added to the production website. - Modify the
postProcessHeader
function located in this file. The currently used typeWebSite
is not specific enough for Google to be taken into account and serves as a placeholder. You can replace it with another, less generic type and set some attributes dynamically during build-time. The data is present in both demo websites and is visible in a browser using the "View page source" menu. - Add structured data at run-time using a component. The structured data type
WebPage
is added to the demo websites and can be observed in Chrome DevTools on the Elements tab next to the closing</head>
tag. This type is also a placeholder that is not specific enough for Google and can be replaced with a less generic type that more accurately describes the content of each page.
The Rich Results Test can be used to validate data prior to a deployment and test a page with embedded data after the deployment. The structured data has to meet Google guidelines.
Consider the following steps to add the desired functionality:
- Start with Client Usage Scenarios to develop UI in absence of API data. For example, develop the initial look and feel of the login page. Take advantage of the Live Reloading to speed up the development. The client scenarios ensure the backend is not started needlessly.
- Implement an API endpoint in the backend, in addition or instead of the existing sample API endpoint. For example, a login endpoint. Technically it can be done by renaming the
SampleXxx
pattern in the names of source files and classes withLoginXxx
, then modifying the classes as needed. This approach can be observed in the sibling Crisp BigQuery repository, theSampleXxx
pattern was replaced withBigQueryXxx
. - Switch to Backend Usage Scenarios to consume the API endpoint in the client. Modify the API related classes
BackendManager
andBackendRequest
as needed. Keep taking advantage of Live Reloading that is supported for client and backend code.
- Start with Client Usage Scenarios as described above for Full stack build.
- Implement a 3rd party API endpoint(s) and consume it.
One of the goals pursued by the Backend Usage Scenarios is to avoid the following common pitfalls:
- ๐ฅ Running
webpack-dev-server
in production. โ ๏ธ Getting CORS security violations triggered by the browser which detects that script bundles were downloaded from one server (frontend) and then the code from the bundles attempts to call API endpoints provided by another server (backend). There are CORS related HTTP headers that exist to water down or cancel completely the security which browsers implement for a reason. In some cases using such headers is unavoidable, for example the code from some Google SDKs is embedded into clients at the build time and not downloaded from Google at run-time, so many Google APIs wouldn't work without CORS headers. But its usage is best to be kept to the minimum because it weakens security and raises the requirements for backend hardening.- ๐ Using two webservers in full stack production deployments: one as a frontend to serve static React files, another as a backend to receive API requests and send API responses to end users. Having two servers instead of one causes needless complexity, increases costs, doubles the attack surface of the deployment and necessitates CORS which further impacts security.
The full stack build and the Backend Usage Scenarios ensure the client gets everything (React app build artifacts including script bundles, API responses) from the backend only. It leaves no room for CORS issues. The webpack-dev-server
is never started in production. This is hardly a good idea. The server, as its name suggests, is meant to be used in development only.
Most production deployments use a CDN that caches React static files. Since the number of CDN datacenters is much less than the userbase count, serving API responses directly to end users becomes the major part of the backend workload. Sending React static files to the CDN adds very little to it.
The list of questions:
- Modifying SPA Configuration block
- IE version support
- Dynamic Imports
- Dynamic Imports vs SPAs
- Tailoring Auto-generated HTML
- Fixing TypeScript Errors
- Debugging Using Chrome DevTools
- Debugging Using VS Code
- Customising VS Code Debugging Session
- Adding Redux
Crisp React solution with its 'server' and 'client' subprojects is open source software licensed as MIT.