Chapter 22

SPA Deployment and Production Workflows

Builds, environments, CI/CD, hosting and monitoring.

22. Production Builds and Deployment

A front-end project is not production-ready merely because it runs on a developer's computer. A production build transforms source code, packages assets, and relies on a hosting platform to serve the right response for each request. A dependable release process also needs automated checks, protected environment variables, a correctly configured domain, and monitoring that tells the team when users cannot access the application.

This chapter follows a front-end application from source to a live deployment. Examples use Vite-style build output, Vercel, and Cloudflare Pages, but the principles apply to other build tools and static hosts.

Learning objectives

By the end of this chapter, you will be able to:

  1. Explain production transforms, tree-shaking, minification, and source maps.
  2. Configure SPA route fallbacks on Vercel and Cloudflare Pages without intercepting assets.
  3. Build a GitHub Actions workflow for linting, typechecking, tests, and preview deploys.
  4. Store environment-specific configuration without leaking secrets.
  5. Connect a domain, verify HTTPS, and set up error and uptime monitoring.

What a production build does

Development servers optimize for feedback. They may serve individual source modules, provide hot updates, and include helpful diagnostics. A production build instead emits browser-ready files and, in a full-stack framework, server artifacts. The exact pipeline depends on the toolchain, but common transformations include:

  • Transpilation: Convert TypeScript, JSX, and newer JavaScript syntax to output compatible with configured targets. Transpilation is not necessarily type checking. Run a separate typecheck such as tsc --noEmit when the project needs one.
  • Bundling and chunking: Follow imports, combine modules, and split code into files loaded on demand. Route-level lazy loading can reduce initial JavaScript, while too many tiny chunks can add request overhead.
  • Tree-shaking: Remove code that is provably unused from the final dependency graph. Static ES module imports and exports help. Package sideEffects metadata must be accurate; falsely declaring side-effectful files as side-effect-free may cause required code to disappear.
  • Minification: Shorten JavaScript, CSS, or HTML by removing whitespace, shortening identifiers where safe, and simplifying expressions. Minification reduces bytes but does not make code secret or secure.
  • Asset processing: Optimize images and fonts when configured, fingerprint files, and create references between HTML and hashed assets.
  • Environment replacement: Build tools may inline explicitly public variables into browser code. Treat every value included in a client bundle as public.

For example, a source import may produce a fingerprinted output such as assets/index-D4f8k2.js. A content hash lets the browser and CDN cache an immutable file for a long time. When the code changes, the filename changes, so the HTML points to a new asset. Keep HTML caching shorter or revalidated, and avoid applying an unnecessarily long immutable policy to un-hashed files.

Tree-shaking works best when modules declare imports and exports statically. Dynamic access to module names, CommonJS dependencies, top-level side effects, or inaccurate package metadata can limit removal. Do not import a whole utility library if a small function import is supported. Inspect the actual production bundle before optimizing: a large dependency may matter, or it may be behind a route the user never loads.

Use source maps to map minified stack traces back to original source. Source maps are valuable for debugging but can reveal source code if served publicly. A common production approach is to generate maps during the build, upload them to an error-monitoring service using a protected CI token, and prevent .map files from being publicly served. If the project intentionally publishes maps, treat that as an explicit source-disclosure decision. Never embed source-map upload credentials in frontend code.

Build, inspect, and preview locally

Read the output directory and build command from the project configuration rather than guessing. A typical Vite project uses:

sh
npm ci
npm run build
npm run preview

npm ci installs the dependency versions recorded by the lockfile and fails if the manifest and lockfile disagree. build generally emits a static directory such as dist/. Vite's preview server serves that built output locally; it is useful for checking the production bundle but is not a replacement for validating the target host's routing, caching, environment, and TLS behavior.

Check the browser console and network panel after previewing. Confirm that chunks load, deep links resolve, images have expected sizes, runtime environment configuration is present, and no development-only source files are referenced. Compare compressed bundle sizes, not only raw source size. Set budgets for the initial route and important interactions, then investigate regressions using build analysis or browser performance tools.

SPA fallback and static routing

A client-rendered single-page application may use routes such as /settings/profile even though no physical file exists at that path. The host must serve the app entry document for application routes so the client router can choose the screen. A fallback should apply only when the request is an application route. Missing JavaScript, images, and API endpoints should not silently return the HTML shell with status 200; that hides broken links and causes confusing parse errors.

Frameworks that render routes on the server or produce per-route HTML need framework-aware routing instead of a blanket SPA fallback. Do not copy an SPA rewrite into a Next.js, Nuxt, or server-rendered deployment.

Vercel

Vercel's framework integrations usually detect the framework and configure route handling automatically. A static Vite SPA without framework-aware routing can define a rewrite in the project's root vercel.json:

json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

This sends unmatched paths to the SPA entry point, whose client router must display a not-found screen for unknown application routes. Confirm this behavior against the Vercel routing configuration and the output type for the project. A blanket rewrite may also turn a missing static asset into HTML, depending on route and asset handling. Keep API routes and server functions out of the fallback; if necessary, scope rewrites to application paths and verify missing-asset requests still return a genuine 404. Vercel's own rewrite rules and framework output take precedence over assumptions copied from another platform.

Vercel project settings should agree with the framework's build command and output. For example, a Vite static site typically builds to dist, while an SSR framework may emit platform-specific output. Do not set an output directory merely because another tool uses it.

Cloudflare Pages

Cloudflare Pages has a conventional built-in SPA behavior: when the deployed project has no top-level 404.html, Pages assumes SPA routing and serves the root entry point for requests that do not match a static asset. The client router then handles those routes. For a plain SPA, no special catch-all rule is usually required. A top-level 404.html changes that behavior: Pages treats it as a site with explicit not-found behavior and serves the 404 page instead of applying the automatic SPA fallback.

If the site is intended to have real static 404 behavior, include the top-level 404.html and test it deliberately. If it is a SPA, do not add a top-level 404 file merely to customize the visual not-found screen; render the app's not-found route instead. Pages handles matching assets and serves the root document for other paths under its SPA convention. Custom Pages Functions, redirects, and cache rules can change the request path, so test them together rather than assuming the default still applies.

Cloudflare Pages deploys the configured output directory, often dist for Vite, and project name. Use the Pages documentation for current build settings and Wrangler versions. Cloudflare recommends avoiding unnecessary custom caching on a Pages domain because static assets are already cached and additional caching can interfere with deployments, redirects, or Functions.

CI: checks before a preview deployment

Continuous integration should repeat the same quality checks for every proposed change. A preview deployment gives reviewers a working build before it reaches production. The example below assumes package.json defines lint, typecheck, test, and build scripts, and that the Vercel CLI is pinned as a project development dependency. Adapt script names to the repository. Pin Actions and tool versions according to the organization's update policy.

yaml
# .github/workflows/preview.yml
name: Validate and preview

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run test -- --run
      - run: npm run build

  preview:
    if: github.event_name == 'pull_request'
    needs: quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - name: Link Vercel project
        env:
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
        run: |
          mkdir -p .vercel
          printf '{"orgId":"%s","projectId":"%s"}' \
            "$VERCEL_ORG_ID" "$VERCEL_PROJECT_ID" > .vercel/project.json
      - name: Create preview deployment
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
        run: |
          npx vercel pull --yes --environment=preview --token="$VERCEL_TOKEN"
          npx vercel build --token="$VERCEL_TOKEN"
          npx vercel deploy --prebuilt --token="$VERCEL_TOKEN"

The quality job gates the preview job, so failed checks cannot receive a review deployment. In a repository where Vercel's Git integration already creates previews, the second job may be unnecessary; avoid deploying the same pull request twice. The example uses a token stored in repository or environment secrets, not a literal credential. Validate Vercel CLI commands and project linking against the installed CLI version before adopting the workflow.

Pull-request code is untrusted input. Do not expose deployment secrets to workflows triggered by forked pull requests. Standard pull_request workflows do not receive repository secrets for forks, which is a useful protection. Do not replace it with pull_request_target and then check out and execute untrusted code with privileged secrets. If preview deployment from external forks is required, use a trusted provider integration or an explicit, least-privilege design that does not expose credentials to the proposed code.

For Cloudflare Pages, a deployment job can use a scoped Cloudflare API token and Wrangler's Pages deploy command to upload the build output to a preview branch. Give the token only the permissions required for the target account/project, store account and project identifiers as configuration, and keep the token in Actions secrets. Do not run both Vercel and Pages deploy commands unless the project intentionally publishes to both providers.

Environment variables and secrets

Configuration differs by environment: local development, preview, and production may use different API base URLs or feature settings. Secrets include private API credentials, signing keys, and deployment tokens. Keep them in the host's protected environment-variable settings or a secrets manager. A checked-in .env.example may document variable names and safe placeholders, but never real credentials.

Build tools commonly expose explicitly prefixed variables to browser code. In Vite, VITE_ variables are available to client-side modules; in Next.js, NEXT_PUBLIC_ variables are bundled for the browser. These prefixes indicate public values, not encrypted values. A public analytics ID may be suitable; a private API key is not. Server environment variables should only be read in server-only code and should not be returned in HTML, serialized props, logs, or error reports.

Make missing required configuration fail early with a clear message. Validate URLs and required names at application startup or during the build, and document which environment owns each value. Restrict who can edit production secrets. Rotate credentials when exposure is suspected, and avoid logging complete environment objects or request authorization headers.

Domains, DNS, and HTTPS

A custom domain makes a deployment stable and recognizable. Add the domain in the hosting provider, then follow the exact DNS records it provides. The record type depends on whether the host asks for a CNAME, A/AAAA record, or platform-specific target. Do not guess a record from a tutorial that may describe a different provider or domain configuration.

Choose a canonical hostname such as www.example.com or example.com, then redirect the alternate hostname to it. Verify DNS propagation and ownership in the provider dashboard. Most modern deployment platforms provision and renew TLS certificates after the domain is validated. Confirm that the certificate covers every hostname, HTTPS works, and HTTP redirects to HTTPS. Do not publish mixed-content assets or use a certificate exception as a deployment workaround.

Test both the apex and subdomain, root and nested routes, redirects, and preview hostname behavior. If DNS is managed through a proxy/CDN, confirm that proxy mode and origin settings are compatible with the hosting platform. A DNS change can take time to propagate; check authoritative DNS records before changing application code to compensate.

Error reporting and uptime monitoring

Client-side monitoring captures runtime exceptions, rejected promises, and performance issues that users experience. Sentry and similar services can associate minified stack traces with releases when source maps are uploaded and release identifiers match. Filter personally identifiable or sensitive data; configure scrubbing before sending user context, request payloads, or breadcrumbs. Monitoring should not become an unintended data export.

Serverless and SSR applications also need server-side error reporting. Client monitoring cannot observe failed server rendering, API timeouts, or deployment boot errors. Capture structured logs and exceptions at the server/runtime layer, include a release identifier, and avoid logging secrets. Alert on actionable symptoms rather than every transient event.

Uptime monitoring periodically requests the public site or a health endpoint from outside the deployment. A health check should indicate meaningful availability; a static file returning 200 does not prove the API or database is healthy. Separate simple liveness checks from deeper readiness checks so a third-party dependency outage does not cause pointless restart loops. Monitor important user journeys and API dependencies where possible.

Define response ownership: who receives an alert, where deployment logs are found, and how to roll back. A preview link helps catch visual and routing defects before release; an automatic rollback or previous deployment redeploy provides a recovery path. Monitor release health after rollout instead of treating a green build as proof that users are unaffected.

Troubleshooting and best practices

Deep links return a host-level 404. Confirm the framework and output type first. For an SPA, configure the platform fallback; on Cloudflare Pages verify that no top-level 404.html disables automatic SPA behavior. For a server-rendered framework, deploy the correct runtime rather than adding an SPA rewrite.

A JavaScript request receives HTML. A fallback may be rewriting a missing asset path to the app shell. Inspect the network request and response content type. Correct the asset base path or narrow the rewrite; missing chunks should be visible as 404 errors.

The page works locally but fails after deployment. Compare runtime and build environment variables, base paths, case-sensitive filenames, output directory, trailing-slash rules, and API origins. Reproduce using a production build and preview deployment rather than relying only on the dev server.

A secret appears in the browser bundle. Remove it from client-prefixed variables and client imports. Rotate the credential immediately, audit build artifacts and logs, and route the operation through a server endpoint that enforces authorization. Removing it from the source code alone does not revoke an already exposed secret.

The deployment workflow leaks a token. Do not print environment variables or enable shell tracing. Store credentials only in GitHub/host secret storage, limit scope and lifetime, and avoid triggering privileged workflows on untrusted code.

New release errors have unreadable stack traces. Confirm source maps were generated, uploaded for the exact release identifier, and not deleted before upload. Check artifact paths and release naming. Keep public source-map access consistent with the project's disclosure policy.

A change is deployed but not visible. Check whether the domain points to the intended deployment, whether an HTML or CDN cache is stale, whether assets are immutable hashed files, and whether the user is viewing an older service worker cache. Purge only the layer that is stale; indiscriminate cache purges can hide the underlying cache-key issue.

Exercises

Exercise 1: Diagnose an SPA deployment failure

The home page works, but refreshing /account/preferences returns a 404. A missing assets/app.js request instead receives index.html with status 200. Explain how to fix both problems for a Vercel SPA and a Cloudflare Pages SPA.

Solution

On Vercel, add a root vercel.json rewrite to the SPA entry point for application routes, and verify the rule does not redirect API or missing asset requests into HTML. Scope or order rules so existing static assets and API/function routes remain correctly handled. The client router should render a not-found view for an unknown application path. On Cloudflare Pages, a plain SPA normally gets automatic fallback to the root document when there is no top-level 404.html; remove an unintended top-level 404 file if the app needs SPA fallback. Check asset build paths and deployment output in both cases. A missing JavaScript asset must remain a 404, not a successful HTML response. If the path still gets the shell, adjust fallback matching and verify the emitted index.html asset references match the deployed output.

Exercise 2: Add a safe CI preview deployment

Design a GitHub Actions change that runs lint, typecheck, tests, and a production build before publishing a Vercel preview for a pull request. List which credentials are secrets and which data can be ordinary configuration.

Solution

Create a quality job with checkout, the chosen Node version, lockfile-based installation, and sequential npm run lint, npm run typecheck, npm run test -- --run, and npm run build steps. Use a preview job with needs: quality and a pull-request event condition. Link the CLI to the project using VERCEL_ORG_ID and VERCEL_PROJECT_ID; protect VERCEL_TOKEN as an Actions secret. Run vercel pull for preview environment variables, then vercel build and vercel deploy --prebuilt. Ensure the Vercel CLI is pinned in the lockfile and validate options against that version. A project name, non-secret public API origin, and Node version may be repository configuration; the deployment token and any API credentials must not be. Do not expose secrets to code from forked pull requests. If the host's Git integration already creates previews after required checks, use that integration or configure required status checks rather than creating a second deploy path.

Key takeaways

  • A production build optimizes and packages code; transpilation alone does not typecheck or secure it.
  • Tree-shaking depends on static modules and correct side-effect metadata; source maps should be uploaded securely.
  • SPA fallbacks are platform-specific. Cloudflare Pages automatically supports SPA paths without a top-level 404.html; Vercel can use a rewrite for a static SPA.
  • CI should gate previews, use lockfiles, and keep deployment secrets away from untrusted pull-request code.
  • A safe release includes environment discipline, verified DNS and HTTPS, observability, and a tested rollback path.