Skip to main content
The CGIAR Risk Intelligence frontend is a Next.js 15 application deployed as a static site to S3 with CloudFront CDN.

Prerequisites

  • AWS CLI configured with valid credentials
  • pnpm installed
  • jq installed
  • CloudFormation stack AllianceRiskStack already deployed

Quick Start

From the project root:
pnpm deploy:web          # Deploy to dev environment
pnpm deploy:web staging  # Deploy to staging
This runs scripts/deploy-web.sh, which handles the full deployment pipeline.

Deployment Pipeline

The deploy-web.sh script performs the following steps:

1. Fetch Stack Outputs

The deployment script queries CloudFormation stack outputs to get:
  • WebBucketName — S3 bucket for static files
  • CloudFrontDistributionId — CloudFront distribution ID for cache invalidation
  • ApiUrl — API Gateway endpoint URL
  • CognitoUserPoolId — Cognito User Pool ID
  • CognitoClientId — Cognito App Client ID
STACK_OUTPUTS=$(aws cloudformation describe-stacks \
  --stack-name AllianceRiskStack \
  --query "Stacks[0].Outputs" \
  --output json)

WEB_BUCKET=$(echo "$STACK_OUTPUTS" | jq -r '.[] | select(.OutputKey=="WebBucketName") | .OutputValue')
CF_DIST_ID=$(echo "$STACK_OUTPUTS" | jq -r '.[] | select(.OutputKey=="CloudFrontDistributionId") | .OutputValue')

2. Set Build-Time Environment Variables

Next.js bakes NEXT_PUBLIC_* environment variables into the static build:
export NEXT_PUBLIC_API_URL="${ApiUrl}"
export NEXT_PUBLIC_COGNITO_USER_POOL_ID="${CognitoUserPoolId}"
export NEXT_PUBLIC_COGNITO_CLIENT_ID="${CognitoClientId}"
Important: These values are embedded in the JavaScript bundle at build time. If infrastructure changes (new Cognito pool, new API Gateway), you must rebuild and redeploy the frontend.

3. Build Static Export

pnpm --filter @alliance-risk/shared build
pnpm --filter @alliance-risk/web build
The Next.js build produces a static export in packages/web/out/:
out/
  index.html
  login.html
  dashboard.html
  404.html
  _next/
    static/
      chunks/
      css/
      media/
  favicon.ico
Configuration (packages/web/next.config.ts):
const nextConfig = {
  output: 'export',        // Static export (no Node.js server)
  images: {
    unoptimized: true,     // No image optimization (static export limitation)
  },
  trailingSlash: false,    // /login not /login/
};
No Dynamic Routes: Static export requires all paths to be known at build time. The app uses query params (?id=xxx) instead of dynamic [id] routes.

4. Sync Static Assets to S3 (Immutable Cache)

Files with content-hashed filenames (JS, CSS, images) are uploaded with a 1-year cache:
aws s3 sync packages/web/out/ "s3://${WEB_BUCKET}" \
  --exclude "*.html" \
  --exclude "*.json" \
  --exclude "*.txt" \
  --cache-control "public, max-age=31536000, immutable" \
  --delete
Cache Strategy:
  • max-age=31536000 — 1 year (365 days)
  • immutable — Browser never revalidates (saves bandwidth)
Content Hashing: Next.js generates filenames like _next/static/chunks/app-123abc.js. When code changes, the hash changes, so old files are never served.

5. Sync HTML/JSON to S3 (No Cache)

HTML and JSON files are uploaded with no caching to always serve the latest version:
aws s3 sync packages/web/out/ "s3://${WEB_BUCKET}" \
  --exclude "*" \
  --include "*.html" \
  --include "*.json" \
  --include "*.txt" \
  --cache-control "public, max-age=0, must-revalidate" \
  --delete
Cache Strategy:
  • max-age=0 — Don’t cache locally
  • must-revalidate — Always check with server
.txt Files: Next.js App Router generates .txt files for RSC (React Server Components) payloads. These are used for client-side navigation.

6. Create CloudFront Invalidation

CloudFront caches files at edge locations. After uploading new files, we invalidate the entire cache:
INVALIDATION_ID=$(aws cloudfront create-invalidation \
  --distribution-id "${CF_DIST_ID}" \
  --paths "/*" \
  --query "Invalidation.Id" \
  --output text)
Propagation Time: 1-2 minutes for the invalidation to reach all edge locations. Cost: First 1,000 invalidation paths per month are free, then $0.005 per path. Using /* counts as 1 path.

CloudFront Configuration

URL Rewrite Function

Next.js static export generates .html files (/login.html, /dashboard.html), but users navigate to extensionless URLs (/login, /dashboard). A CloudFront Function rewrites requests:
function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // Block direct browser navigation to .txt (RSC payload) files
  // Next.js client-side navigation sends RSC: 1 header with these requests
  if (uri.endsWith('.txt')) {
    var rscHeader = request.headers['rsc'];
    if (!rscHeader || rscHeader.value !== '1') {
      request.uri = uri.slice(0, -4) + '.html';
    }
    return request;
  }

  // Handle trailing slash: /login/ → /login.html
  if (uri.endsWith('/') && uri !== '/') {
    request.uri = uri.slice(0, -1) + '.html';
    return request;
  }

  // Skip files with extensions (.js, .css, .png, etc.)
  if (uri.includes('.')) {
    return request;
  }

  // Rewrite extensionless paths: /login → /login.html
  request.uri = uri + '.html';
  return request;
}
Result:
  • User navigates to /login → CloudFront requests /login.html from S3
  • User navigates to /dashboard/ → CloudFront requests /dashboard.html from S3
  • User requests /_next/static/chunks/app-123abc.js → No rewrite (has extension)

Error Responses (SPA Routing)

CloudFront is configured to serve index.html for 403/404 errors:
CustomErrorResponses:
  - ErrorCode: 403
    ResponseCode: 200
    ResponsePagePath: /index.html
    ErrorCachingMinTTL: 0
  - ErrorCode: 404
    ResponseCode: 200
    ResponsePagePath: /index.html
    ErrorCachingMinTTL: 0
Why? When a user navigates directly to a client-side route (e.g., /dashboard?id=123), CloudFront doesn’t find /dashboard?id=123.html in S3 and returns 404. We intercept this and serve index.html, which boots the React app and handles the route. Error Caching: ErrorCachingMinTTL: 0 ensures 404s are not cached (important during development).

Build-Time Environment Variables

These variables are baked into the JavaScript bundle:
VariableSourceUsed By
NEXT_PUBLIC_API_URLCloudFormation output ApiUrlAPI client (lib/api-client.ts)
NEXT_PUBLIC_COGNITO_USER_POOL_IDCloudFormation output CognitoUserPoolIdCognito auth (lib/cognito.ts)
NEXT_PUBLIC_COGNITO_CLIENT_IDCloudFormation output CognitoClientIdCognito auth (lib/cognito.ts)
Example (packages/web/src/lib/api-client.ts):
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';

export const apiClient = {
  async get(path: string) {
    const response = await fetch(`${API_URL}${path}`);
    return response.json();
  },
};

Deployment Order

When deploying from scratch:
1. Deploy infrastructure:    pnpm --filter @alliance-risk/infra cfn:deploy dev
2. Run database migrations:  pnpm migrate:remote
3. Deploy API:               pnpm deploy:api
4. Deploy web:               pnpm deploy:web
For routine code changes (no infrastructure changes), only step 4 is needed. If infrastructure changes (new Cognito pool, new API Gateway), you must:
1. Deploy infrastructure:    pnpm --filter @alliance-risk/infra cfn:deploy dev
2. Deploy web:               pnpm deploy:web  # Rebuilds with new env vars

Troubleshooting

Build did not produce out/ directory

Cause: next.config.ts is missing output: 'export' Solution: Ensure next.config.ts has:
const nextConfig = {
  output: 'export',
};

CloudFront serves old content after deployment

Cause: Invalidation not yet propagated (takes 1-2 minutes) Solution: Wait 1-2 minutes, or check invalidation status:
aws cloudfront get-invalidation \
  --distribution-id ${CF_DIST_ID} \
  --id ${INVALIDATION_ID}

404 on client-side routes

Cause: CloudFront error responses not configured Solution: Ensure the CloudFormation template has CustomErrorResponses for 403/404 → /index.html

API calls fail with CORS errors

Cause: NEXT_PUBLIC_API_URL not set or incorrect Solution: Verify the build logs show the correct API URL:
echo $NEXT_PUBLIC_API_URL
# Should output: https://xxx.execute-api.us-east-1.amazonaws.com

Authentication fails (Cognito errors)

Cause: NEXT_PUBLIC_COGNITO_USER_POOL_ID or NEXT_PUBLIC_COGNITO_CLIENT_ID not set or incorrect Solution: Verify the build logs show the correct Cognito IDs:
echo $NEXT_PUBLIC_COGNITO_USER_POOL_ID
echo $NEXT_PUBLIC_COGNITO_CLIENT_ID

.txt files return 404 in browser (but work in Next.js)

Expected behavior. The CloudFront Function blocks direct browser navigation to .txt files and serves .html instead. Next.js client-side navigation includes the RSC: 1 header, so .txt files work correctly during navigation.

Performance Optimization

  • CloudFront CDN: Global edge locations reduce latency
  • Gzip Compression: Enabled by default on CloudFront
  • Immutable Cache: 1-year cache for hashed assets (JS, CSS, images)
  • HTTP/2: Enabled for multiplexing
  • Price Class 100: Cheaper edge locations (North America + Europe)

Security Best Practices

  • HTTPS Only: CloudFront redirects HTTP → HTTPS
  • S3 Private: Bucket accessible only via CloudFront OAI (Origin Access Identity)
  • No Secrets in Frontend: All sensitive data (DB credentials, API keys) lives in the backend
  • CORS: API Gateway configured to allow requests from CloudFront origin

Cache Strategy Summary

File TypeCache-ControlWhy
*.js, *.css, *.png, etc.public, max-age=31536000, immutableContent-hashed filenames, never change
*.html, *.json, *.txtpublic, max-age=0, must-revalidateAlways serve latest version

Manual S3 Sync (for debugging)

If you need to manually sync files without the deployment script:
# Get bucket name
WEB_BUCKET=$(aws cloudformation describe-stacks \
  --stack-name AllianceRiskStack \
  --query "Stacks[0].Outputs[?OutputKey=='WebBucketName'].OutputValue" \
  --output text)

# Sync all files
aws s3 sync packages/web/out/ "s3://${WEB_BUCKET}" --delete

# Invalidate CloudFront cache
CF_DIST_ID=$(aws cloudformation describe-stacks \
  --stack-name AllianceRiskStack \
  --query "Stacks[0].Outputs[?OutputKey=='CloudFrontDistributionId'].OutputValue" \
  --output text)

aws cloudfront create-invalidation \
  --distribution-id "${CF_DIST_ID}" \
  --paths "/*"

Rollback

To rollback to a previous deployment, you must rebuild and redeploy from a previous git commit:
git checkout <previous-commit>
pnpm deploy:web
Consider maintaining separate branches for each environment (dev, staging, production) for easier rollback.

Build docs developers (and LLMs) love