Ifty's BlogThoughts & Ideas
Hosting Multiple React SPAs on a Single CloudFront Distribution

Hosting Multiple React SPAs on a Single CloudFront Distribution

4/6/2026, 12:00:00 AM

AWSCloudFrontReactS3DevOpsArchitecture

Running one CloudFront distribution per React app is the obvious path. It is also expensive, operationally heavy, and unnecessary. A single distribution can serve five, ten, or twenty independent SPAs — each with its own routing, its own build pipeline, and its own index.html — without any of them knowing about the others.

This post covers the full architecture: S3 layout, Origin Access Control, cache behaviors, CloudFront Function routing, and CI/CD. Two earlier posts in this series cover the deployment mechanics in depth:

This post focuses on the architectural layer: how to structure S3 and CloudFront so multiple apps coexist cleanly.


The Architecture at a Glance

CloudFront Distribution (single)
│
├── /                  → Landing (Astro — marketing site)
├── /admin/            → Admin SPA (internal tooling)
├── /creator/          → Creator SPA (content tools)
└── /organization/     → Org SPA (tenant management)

All origins point to one S3 bucket:
s3://your-bucket/
├── index.html
├── assets/
├── admin/
│   ├── index.html
│   └── assets/
├── creator/
│   ├── index.html
│   └── assets/
└── organization/
    ├── index.html
    └── assets/

One distribution. One S3 bucket. One Origin Access Control policy. Each app is isolated by path prefix and has no knowledge of the others.


S3 Bucket Structure

Each SPA lives in its own prefix (folder) in the bucket. The final dist/ output needs to match this layout exactly:

dist/
├── index.html              ← Astro landing site
├── assets/
├── admin/
│   ├── index.html
│   └── assets/
├── creator/
│   ├── index.html
│   └── assets/
└── organization/
    ├── index.html
    └── assets/

A single aws s3 sync dist/ s3://your-bucket/ then uploads everything in one pass — the folder structure becomes the S3 key structure, which becomes the CloudFront path structure.


Vite Configuration

There are two concerns to separate: development (fast iteration, HMR per app) and production build (a single dist/ with all apps under their prefixes).

Per-app dev configs

Each app gets a dedicated config used only during development. These are the files you run individually with vite dev --config vite.config.admin.ts:

// vite.config.admin.ts
export default defineConfig({
  base: "/admin/",
  plugins: [
    tsconfigPaths({ projects: ["./tsconfig.json", "./src/apps/admin/tsconfig.json"] }),
    tailwindcss(),
    tanstackRouter({
      target: "react",
      autoCodeSplitting: true,
      routesDirectory: "./src/apps/admin/routes",
      generatedRouteTree: "./src/apps/admin/routeTree.gen.ts",
    }),
    viteReact(),
  ],
  build: {
    outDir: "dist/admin",
  },
});

The base: "/admin/" setting is what makes this work at all — it tells Vite to prefix every asset URL it writes into index.html. Without it, /admin/index.html would reference /assets/main-abc123.js instead of /admin/assets/main-abc123.js, and the browser would look in the wrong place.

Combined production build config

For CI/CD you want a single vite build command that produces all apps at once. Vite's rollupOptions.input accepts multiple entry points, and custom output naming functions route each chunk to the correct prefix:

// vite.config.ts  (used by `bun run build` / CI)
import { resolve } from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
 
const getAppFromModulePath = (modulePath: string): "admin" | "creator" => {
  if (modulePath.includes("apps/admin")) return "admin";
  if (modulePath.includes("apps/creator")) return "creator";
  return "creator";
};
 
const getAppFromEntryName = (entryName?: string): "admin" | "creator" => {
  if (entryName === "admin") return "admin";
  if (entryName === "creator") return "creator";
  return "creator";
};
 
const getAppFromChunkModules = (moduleIds: readonly string[]): "admin" | "creator" => {
  if (moduleIds.some((id) => id.includes("apps/admin"))) return "admin";
  if (moduleIds.some((id) => id.includes("apps/creator"))) return "creator";
  return "creator";
};
 
export default defineConfig({
  plugins: [
    tsconfigPaths({
      projects: [
        "./tsconfig.json",
        "./src/apps/admin/tsconfig.json",
        "./src/apps/creator/tsconfig.json",
        "./src/shared/tsconfig.json",
      ],
    }),
    tailwindcss(),
    // One TanStack Router plugin instance per app — each points to its own routes dir
    tanstackRouter({
      target: "react",
      autoCodeSplitting: true,
      routesDirectory: "./src/apps/admin/routes",
      generatedRouteTree: "./src/apps/admin/routeTree.gen.ts",
    }),
    tanstackRouter({
      target: "react",
      autoCodeSplitting: true,
      routesDirectory: "./src/apps/creator/routes",
      generatedRouteTree: "./src/apps/creator/routeTree.gen.ts",
    }),
    viteReact(),
  ],
  build: {
    rollupOptions: {
      input: {
        admin: resolve(__dirname, "src/apps/admin/index.html"),
        creator: resolve(__dirname, "src/apps/creator/index.html"),
      },
      output: {
        // Route each output file to its app's prefix under dist/
        assetFileNames: (info) => {
          const app = getAppFromModulePath(info.name ?? "");
          return `${app}/assets/[name]-[hash][extname]`;
        },
        chunkFileNames: (info) => {
          const app = getAppFromChunkModules(info.moduleIds);
          return `${app}/assets/[name]-[hash].js`;
        },
        entryFileNames: (info) => {
          const app = getAppFromEntryName(info.name);
          return `${app}/assets/[name]-[hash].js`;
        },
      },
    },
  },
});

Why three naming functions? Rollup produces three categories of output files and each goes through a different callback:

  • entryFileNames — the JS bundle for each entry point (admin, creator). The entry name directly matches the key in rollupOptions.input, so a simple lookup is enough.
  • chunkFileNames — shared/split chunks produced by code splitting. These don't have an entry name, so you inspect the module IDs of the chunk's contents to determine which app they belong to.
  • assetFileNames — CSS, images, fonts, and other non-JS assets. The info.name carries the originating file path for most assets.

Shared code between apps lands in whichever app Rollup happens to assign it to — typically the first one alphabetically. If admin and creator share a component library, that chunk gets placed under admin/assets/. This is fine for S3 deployment (the file is still served correctly), but worth knowing when you read the build output.

package.json scripts

Run each config independently in development, and the combined config in CI:

{
  "scripts": {
    "dev:admin": "vite dev --config vite.config.admin.ts",
    "dev:creator": "vite dev --config vite.config.creator.ts",
    "dev:organization": "vite dev --config vite.config.organization.ts",
    "build": "bun run build:landing && bun run build:admin && bun run build:creator && bun run build:organization",
    "build:admin": "vite build --config vite.config.admin.ts",
    "build:creator": "vite build --config vite.config.creator.ts"
  }
}

The combined vite.config.ts approach works well in CI where you want one command and one dist/. Per-app configs are better for local dev because they give each app its own HMR server and avoid one app's compile errors blocking another's dev server.


Origin Access Control (OAC)

The S3 bucket should be private. CloudFront accesses it through Origin Access Control — the successor to Origin Access Identity (OAI). OAC supports all S3 operations including server-side encryption with KMS, which OAI does not.

S3 bucket policy — allow CloudFront to read from the bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DIST_ID"
        }
      }
    }
  ]
}

The AWS:SourceArn condition scopes this permission to your specific distribution — not any CloudFront distribution in your account. This is important: without it, any CloudFront distribution in your AWS account could read from this bucket.

When creating the OAC in the console or CLI, set the origin type to S3, signing behavior to Always, and signing protocol to sigv4.

When configuring the S3 origin in CloudFront, use the bucket's regional domain name (bucket.s3.REGION.amazonaws.com), not the global one (bucket.s3.amazonaws.com). The regional endpoint avoids eventual consistency issues where a newly uploaded object might not be immediately visible through the global S3 endpoint.


Cache Behaviors

CloudFront cache behaviors are evaluated in order, matched by path pattern, with a default behavior as the final fallback. This is where per-app caching customization happens.

For a multi-SPA setup, you typically need behaviors for:

  1. Hashed static assets — matched by file extension, long-lived cache
  2. index.html entry points — per-app, never cached
  3. Default — fallback for anything else

A minimal setup has three types of behaviors:

  • Hashed asset behaviors — path patterns like *.js and *.css, with a long-lived cache policy (TTL: 1 year, compression enabled, no headers/cookies/query strings forwarded).
  • Per-app index.html behaviors — explicit patterns like /app/index.html and /admin/index.html, with a no-cache policy (TTL: 0, no-store).
  • Default behavior — catches everything else, uses the no-cache policy, and has the CloudFront Function attached on viewer-request.

Cache policies replace the older ForwardedValues approach and should be defined explicitly rather than using managed policies, so you control compression and TTL settings precisely. Create two: one with DefaultTTL/MaxTTL/MinTTL = 31536000 (immutable assets) and one with all TTLs set to 0 (HTML entry points).


CloudFront Function for Multi-App Routing

Every non-asset request needs to be rewritten to the correct app's index.html. Without this, /admin/settings hits S3 at the key admin/settings — which does not exist — and returns a 403.

The routing function maps path prefixes to their index.html:

// infra/cloudfront-function.js
function handler(event) {
  var request = event.request;
  var uri = request.uri;
 
  // Static assets pass through unchanged
  if (uri.match(/\.[a-zA-Z0-9]+$/)) {
    return request;
  }
 
  // Sub-app prefix routing
  var apps = ["/app", "/admin", "/creator", "/organization"];
 
  for (var i = 0; i < apps.length; i++) {
    if (uri === apps[i] || uri.indexOf(apps[i] + "/") === 0) {
      request.uri = apps[i] + "/index.html";
      return request;
    }
  }
 
  // Root SPA fallback
  request.uri = "/index.html";
  return request;
}

This function runs on every viewer-request — before CloudFront checks its cache and before the request reaches S3. The rewrite is transparent to the browser; the address bar keeps the original URL.

See SPA Routing on CloudFront with CloudFront Functions for the full breakdown of this function, the ES5.1 constraints, and how to deploy and update it via the CLI.


Deploying Multiple Apps from One CI/CD Pipeline

With multiple apps in a monorepo, you want one pipeline that builds all apps, syncs each to its S3 prefix, and invalidates only the changed entry points.

A CodeBuild buildspec.yml that handles this:

phases:
  build:
    commands:
      - npm ci
      - npm run build:all # builds each app to dist/<app-name>/
 
  post_build:
    commands:
      # Pass 1 — hashed assets for all apps, long-lived cache
      - |
        aws s3 sync dist/ s3://$S3_BUCKET/ \
          --delete \
          --exclude "*/index.html" \
          --cache-control "public, max-age=31536000, immutable"
 
      # Pass 2 — all index.html files, never cache
      - |
        aws s3 sync dist/ s3://$S3_BUCKET/ \
          --exclude "*" \
          --include "*/index.html" \
          --cache-control "max-age=0, no-cache, no-store, must-revalidate"
 
      # Update routing function
      - |
        ETAG=$(aws cloudfront describe-function \
          --name "$CLOUDFRONT_FUNCTION_NAME" --query 'ETag' --output text)
        aws cloudfront update-function \
          --name "$CLOUDFRONT_FUNCTION_NAME" \
          --function-code fileb://infra/cloudfront-function.js \
          --function-config Comment="Updated via CodeBuild",Runtime="cloudfront-js-2.0" \
          --if-match $ETAG > /dev/null
        ETAG=$(aws cloudfront describe-function \
          --name "$CLOUDFRONT_FUNCTION_NAME" --query 'ETag' --output text)
        aws cloudfront publish-function \
          --name "$CLOUDFRONT_FUNCTION_NAME" --if-match $ETAG
 
      # Invalidate all index.html paths — discovered dynamically
      - |
        PATHS=$(find dist -name "index.html" | sed 's|^dist||' | tr '\n' ' ')
        aws cloudfront create-invalidation \
          --distribution-id $CLOUDFRONT_DIST_ID \
          --paths $PATHS

The two-pass S3 sync and dynamic invalidation are covered in detail in Deploying React SPAs to AWS S3 + CloudFront with Tiered Caching. The short version: hashed assets get a one-year cache with immutable, index.html files get no-cache, and only the entry points are invalidated — not /*.


Adding a New App

The architecture is designed so that adding a new app requires changes in exactly two places:

1. Create the app with the correct Vite base:

// apps/payments/vite.config.ts
export default defineConfig({
  base: "/payments/",
  build: { outDir: "../../dist/payments" },
});

2. Add the prefix to the CloudFront Function:

var apps = ["/app", "/admin", "/creator", "/organization", "/payments"];

That is it. The CI/CD pipeline discovers the new dist/payments/index.html automatically on the next build — no changes to the buildspec, no new cache behavior needed (the default behavior handles it), no new S3 sync commands.

The only manual step is updating the CloudFront Function's apps array, which is a one-line change. If you want to eliminate even that, the path-agnostic routing approach described in SPA Routing on CloudFront with CloudFront Functions removes the need for an explicit list entirely.


Cost Comparison: One Distribution vs. Many

Running a separate CloudFront distribution for each app is a common pattern but it adds up:

Per-app distributionsSingle distribution
Distribution charge$0/month per distribution (free tier)$0/month
Data transferSeparate monthly free tier per distributionShared across all apps
Invalidations1,000 free paths/month per distribution1,000 free paths/month shared
OAC/IAM policiesOne per distributionOne total
CertificateOne ACM cert per distribution (or wildcard)One cert, all paths covered
Operational overheadN pipelines, N invalidation targetsOne pipeline, one target

For small traffic volumes, per-app distributions can actually be cheaper if each one stays within its own free tier limits. At scale, a single distribution is simpler and the shared free tier is not a constraint.

The real argument for a single distribution is operational: one invalidation call, one function to update, one certificate to renew, one set of CloudWatch metrics to watch.


Summary

ComponentDecision
S3 structureOne bucket, one prefix per app
Vite base configMust match S3 prefix — assets reference the right paths
Origin Access ControlSingle OAC scoped to the distribution ARN
Cache behaviorsPer-app index.html behaviors (no-cache) + shared asset behaviors (immutable)
CloudFront FunctionRewrites non-asset URIs to the correct index.html per path prefix
CI/CDTwo-pass S3 sync + dynamic invalidation — zero hardcoded app names
Adding an appUpdate vite.config.ts base + one line in the routing function

The architecture has a fixed setup cost (OAC, cache policies, routing function) that amortizes across every app you add. App number ten is almost free to add.