Skip to content

Web hosting and the React admin console

Give the SaaS provider a web UI instead of curl: a React admin console where system admins sign in to the control plane, onboard and offboard tenants, and manage other system admins. It’s hosted on S3 + CloudFront, and the same hosting construct is reused for the tenant-facing app in step 11.

client/
├── admin/ React + Vite + TypeScript app (this step)
└── cdk/ separate CDK app: stack "serverless-saas-client"
└── lib/static-site.ts private S3 bucket + CloudFront (OAC) + deploy dist/ + config.json

The client CDK app is separate from the server one on purpose, as in the original. The server app is also synthesized inside the provisioning CodeBuild job (step 09), which doesn’t have the web apps’ build output.

Runtime config instead of build-time config

Section titled “Runtime config instead of build-time config”

The original Angular build bakes the API URL and Cognito ids into the bundle by writing environment.ts inside a Docker build. Here the app is built once, and CDK writes a config.json next to it:

Source.jsonData('config.json', { controlPlaneApiUrl, userPoolId, appClientId })

At startup, main.tsx fetches /config.json, configures Amplify Auth and the API client, and then renders. The same dist/ works in any account, and no Docker is needed for the front end.

  • Private bucket (block all public access, SSL only), auto-deleted with the stack.
  • CloudFront Distribution with S3BucketOrigin.withOriginAccessControl, so only this distribution can read the bucket.
  • 403/404 → /index.html with status 200, so deep links like /tenants/new reach the React router.
  • BucketDeployment uploads dist/ plus config.json and invalidates CloudFront’s cache.
Screen Calls (control plane API, ID token from the control plane pool)
Sign in (Amplify signIn, SRP). Handles the “set a new password” challenge for invited admins. Cognito
Tenants: list, tier badge, status, auto-refresh while provisioning, Offboard GET /tenants, DELETE /tenant-registrations/{id}
Onboard tenant: name, admin email, tier POST /tenant-registrations {tenantData:{tenantName,email,tier}, tenantRegistrationData:{registrationStatus}}
System admins: list, add GET /users, POST /users {userName,email,userRole:"SystemAdmin"}

Files:

Terminal window
./scripts/deploy.sh you@example.com # everything
./scripts/deploy-client.sh # just the web apps

Output: serverless-saas-client.AdminSiteUrl = https://<id>.cloudfront.net

Terminal window
cd client/admin
cp public/config.example.json public/config.json # fill in the ControlPlaneStack outputs
npm install && npm run dev # http://localhost:5173

The control plane API’s CORS config already allows http://localhost:5173 (set in step 01).

Done on the test deployment (https://dthjv4e2gzxl4.cloudfront.net):

  • Opening the deep link /tenants/new serves the app (SPA fallback) and shows the sign-in page, so config.json loaded and Amplify was configured.
  • config.json = {"controlPlaneApiUrl":"https://w2g4jn8evg.../","userPoolId":"us-east-1_NJ8yJK7e9","appClientId":"2b4l..."}.
  • CORS from the CloudFront origin: preflight OPTIONS /tenant-registrations returns 204 with access-control-allow-origin: https://dthjv4e2gzxl4.cloudfront.net, and GET /tenants with an admin token returns 200 with the same header.
  • The API calls behind each screen were verified with curl in steps 01 and 08 (GET /tenants, POST and DELETE /tenant-registrations, GET /users).

Try it yourself: open the Admin site URL, sign in as admin with the password you set (./scripts/admin-token.sh '<password>' --set-password), onboard a tenant, and watch its status change from “In progress” to “Complete”.

Step 11 adds the tenant-facing SaaS app: users type their tenant name, the app looks up that tenant’s user pool and API, and they manage products, orders and users.