> ## Documentation Index
> Fetch the complete documentation index at: https://alliancebioversityciat-alliance-risk-analysis-tool.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploying the Next.js Web App

> Deploy the Next.js static site to S3 and CloudFront

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:

```bash theme={null}
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

```bash theme={null}
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:

```bash theme={null}
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

```bash theme={null}
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`):**

```typescript theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```javascript theme={null}
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:

```yaml theme={null}
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:

| Variable | Source | Used By |
| - | - | - |
| `NEXT_PUBLIC_API_URL` | CloudFormation output `ApiUrl` | API client (`lib/api-client.ts`) |
| `NEXT_PUBLIC_COGNITO_USER_POOL_ID` | CloudFormation output `CognitoUserPoolId` | Cognito auth (`lib/cognito.ts`) |
| `NEXT_PUBLIC_COGNITO_CLIENT_ID` | CloudFormation output `CognitoClientId` | Cognito auth (`lib/cognito.ts`) |

**Example (`packages/web/src/lib/api-client.ts`):**

```typescript theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```typescript theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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 Type | Cache-Control | Why |
| - | - | - |
| `*.js`, `*.css`, `*.png`, etc. | `public, max-age=31536000, immutable` | Content-hashed filenames, never change |
| `*.html`, `*.json`, `*.txt` | `public, max-age=0, must-revalidate` | Always serve latest version |

## Manual S3 Sync (for debugging)

If you need to manually sync files without the deployment script:

```bash theme={null}
# 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:

```bash theme={null}
git checkout <previous-commit>
pnpm deploy:web
```

Consider maintaining separate branches for each environment (dev, staging, production) for easier rollback.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.