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:
- Deploying React SPAs to AWS S3 + CloudFront with Tiered Caching — two-pass S3 sync strategy and surgical CloudFront invalidation
- SPA Routing on CloudFront with CloudFront Functions — writing the routing function, the ETag lifecycle, and ES5.1 constraints
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 inrollupOptions.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. Theinfo.namecarries 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:
- Hashed static assets — matched by file extension, long-lived cache
index.htmlentry points — per-app, never cached- Default — fallback for anything else
A minimal setup has three types of behaviors:
- Hashed asset behaviors — path patterns like
*.jsand*.css, with a long-lived cache policy (TTL: 1 year, compression enabled, no headers/cookies/query strings forwarded). - Per-app
index.htmlbehaviors — explicit patterns like/app/index.htmland/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 $PATHSThe 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 distributions | Single distribution | |
|---|---|---|
| Distribution charge | $0/month per distribution (free tier) | $0/month |
| Data transfer | Separate monthly free tier per distribution | Shared across all apps |
| Invalidations | 1,000 free paths/month per distribution | 1,000 free paths/month shared |
| OAC/IAM policies | One per distribution | One total |
| Certificate | One ACM cert per distribution (or wildcard) | One cert, all paths covered |
| Operational overhead | N pipelines, N invalidation targets | One 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
| Component | Decision |
|---|---|
| S3 structure | One bucket, one prefix per app |
Vite base config | Must match S3 prefix — assets reference the right paths |
| Origin Access Control | Single OAC scoped to the distribution ARN |
| Cache behaviors | Per-app index.html behaviors (no-cache) + shared asset behaviors (immutable) |
| CloudFront Function | Rewrites non-asset URIs to the correct index.html per path prefix |
| CI/CD | Two-pass S3 sync + dynamic invalidation — zero hardcoded app names |
| Adding an app | Update 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.