Blog

Shipping Create React App on Azure DevOps: A Pipeline That Does Not Fight You

Create-React-App is the default starting point for a lot of front-end work, but shipping it through Azure DevOps trips up plenty of developers. A guide published on rahulpnath.com in February 2020 walks through a complete build and release pipeline using the YAML format, plus two gotchas that always bite: client-side routing on IIS and per-environment config.

The piece uses Node 10.x and the original build id naming, but the pipeline shape and the routing fix have aged well. Below is the condensed version, updated where it matters.

The short version:

  • A YAML build pipeline runs `npm install` and `npm run build`, then archives the `build` folder as a zip artefact.
  • A release pipeline deploys the artefact to an Azure Web App via the built-in Azure Web App task.
  • A `web.config` with URL Rewrite is needed so deep React Router links do not 404 on direct hits.
  • Per-environment variables are injected via a script tag in `index.html`, kept in an Azure DevOps Variable Group.
  • The whole setup uses the Node.js with React template as a starting point, with the build id baked into the artefact name.

The build pipeline, stripped down

The original YAML defines four steps after a trigger on `master`. A `variables` block points `uiSource` at `src/ui` and `uiBuild` at the build output folder. Jobs run on the hosted Ubuntu agent (`ubuntu-latest`).

  • Install Node.js using `NodeTool@0` with version `10.x`.
  • `npm install` and `npm run build` inside the UI source folder.
  • Archive the build output with `ArchiveFiles@2` as a zip named `ui-$(Build.BuildId).zip`.
  • Publish the zip as a build artefact named `drop`.

If you are setting this up today, bump Node to a currently supported LTS. Node 10 is long dead and will not install cleanly on newer agents. The rest of the structure, including the `$(Build.BuildId)` substitution, is unchanged.

The routing trap and the web.config fix

React Router handles URLs in the browser. IIS does not know about your routes and will return a 404 on any deep link someone pastes in. The fix is a `web.config` in the public folder that URL-rewrites everything to `/`, with three clear exceptions: actual files (CSS, JS, images), real directories, and any `/api` prefix that the same host serves.

The end result: a route like `/customer/1223` hits IIS, gets rewritten to `/`, the SPA loads, and React Router renders the right component. The `/api` path is left alone so backend calls still hit their handler. The config also maps `.otf` files to a font MIME type so custom fonts are served correctly.

If you are not on Azure Web App for Windows, swap `web.config` for the equivalent in your host (Netlify `_redirects`, Nginx `try_files`, etc.). The idea is identical.

Per-environment config without rebuilding

Hard-coding API URLs in a CRA build means rebuilds for every environment. The piece dodges that with a script tag placed in `index.html` that sets a `window.BookingConfig` object before the app boots, holding values like `searchUrl`, `bookingUrl`, `isDevelopment` and `imageServer`. Azure DevOps Variable Groups hold the values per stage. A replace step in the release pipeline swaps the placeholder values before the artefact is deployed.

That keeps the build artefact identical across Test, Staging and Production. Only the config gets swapped. Fewer surprises, faster promotions.

Our analysis

The interesting tension here is between CRA's "just deploy the build folder" simplicity and Azure's IIS-default reality. A plain drag-and-drop into a Linux Web App would skip the web.config dance entirely. Windows-hosted Azure Web Apps do exist and many enterprise teams are stuck on them, which is exactly why this post still gets traffic years after publication.

The pipeline shape (build artefact, release stage, swap-in variables) is the standard Azure DevOps pattern that has barely changed. What has changed is the agent image defaults and the supported Node versions. If you are following an older tutorial, update the runtime versions first, then follow the rest of the steps verbatim.

What it means for you: if you are deploying CRA to Azure Web App on Windows, the web.config is non-negotiable. Skip it and every shared link becomes a 404 the first time someone refreshes.

A worked example: promoting from Test to Production

Say you have merged a feature into `master`. The build pipeline runs, bundles the React app, and drops `ui-<Build.BuildId>.zip` into the artefact store. The release pipeline picks it up. For the Test stage, the variable replacement step swaps in a Test `searchUrl`. After smoke tests pass, you kick off the Production stage; the same artefact is deployed, but the swap step replaces the URLs with their production values. No rebuild, no risk of Test and Production drifting apart.

What to watch next

Bottom line: the pipeline is solid, the Node version is not. Update to a currently supported runtime, keep the web.config in place if you are on Windows, and use Variable Groups for any environment-dependent config. Watch for two things: a possible move to Azure Static Web Apps (which makes the web.config moot for new projects) and the gradual deprecation of older Node runtimes on hosted agents. Either of those will force a revisit.

Key takeaways

  • Use the Node.js with React template in Azure DevOps as a starting point, then bump Node to a current LTS.
  • Add `web.config` with URL Rewrite to handle React Router deep links on IIS-based Azure Web Apps.
  • Keep build artefacts identical across environments and swap config via Variable Groups.
  • Exclude `/api` and static assets from the rewrite rule so backend calls still work.
  • Prefer Azure Static Web Apps for new projects to skip the web.config entirely.

Frequently asked questions

Do I still need web.config on Azure Static Web Apps?

No. Static Web Apps handle SPA routing by default. The web.config trick is only needed on Azure Web App for Windows.

What Node version should I use now?

Use a current LTS. The original guide pins to 10.x, which is end-of-life and will fail to install on newer agents.

Why put config in a script tag instead of an environment variable?

CRA builds are static. Process env values are not injected into the front-end bundle. A global JS object set before app boot is the simplest, portable trick.

Can I skip the archive step?

You can, but zipping the build folder makes release stages faster to copy and easier to retain for rollback.

Is the master branch trigger still right?

If your default branch has been renamed to `main` since 2020, update the trigger accordingly.

Related