> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fjall.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Static Site Pattern

> Serve a pre-built static site from a private S3 bucket behind CloudFront, with clean URLs, security headers, an optional custom domain, and an SES-backed contact form.

## Overview

The static-site pattern deploys a folder of built files — a Next.js export, a
Vite build, an Astro site, hand-written HTML — to AWS with production
hardening you would otherwise assemble by hand:

* **Private S3 bucket** — no public bucket policy; CloudFront reaches it via
  Origin Access Control (OAC)
* **CloudFront CDN** — HTTPS, HTTP/2+3, global edge caching
* **Clean URLs** — `/about` serves `about.html` (or SPA fallback to
  `index.html`)
* **Security headers** — HSTS, `nosniff`, frame denial, referrer policy
* **Custom domain** — ACM certificate and Route53 alias record, created
  automatically
* **Contact form** — an optional `/api/contact` endpoint (Lambda Function URL
  → SES) so a static site can still receive enquiries

There is no VPC, no database, no container and no compute stack — the whole
app lands in a single CDN stack, and a low-traffic site costs approximately
nothing to run.

<Note>
  The pattern serves files your build produces; it does not run a server. If
  your site needs SSR or API routes beyond the contact form, use the
  [Payload pattern](/patterns/payload-pattern) instead.
</Note>

## Quick Start

### 1. Have a build that emits static files

Any tool works, as long as one command produces a folder of files:

| Tool                         | Build command   | Output folder |
| ---------------------------- | --------------- | ------------- |
| Next.js (`output: "export"`) | `npm run build` | `out`         |
| Vite / Astro                 | `npm run build` | `dist`        |
| Eleventy                     | `npm run build` | `_site`       |
| Plain HTML                   | `true` (no-op)  | `.`           |

### 2. Create the Fjall app

```bash theme={null}
cd my-site
fjall create app \
  --name my-site \
  --pattern staticsite \
  --source ../.. \
  --build-command "npm run build" \
  --output-dir out
```

Or run `fjall create app` with no flags and choose **Static Site** in the
interactive picker.

<Note>
  Paths are written into `fjall/my-site/infrastructure.ts` and resolved from
  that directory at deploy time — `--source ../..` points at the repo root,
  and `--output-dir` is relative to `--source`.
</Note>

### 3. Deploy

```bash theme={null}
fjall deploy
```

Fjall runs your build command in `source`, synthesises the CDN stack, and
uploads the contents of the output folder to S3. The deploy output includes
the CloudFront URL.

## What's Included

| Resource                    | Purpose                                           |
| --------------------------- | ------------------------------------------------- |
| **S3 Bucket (private)**     | Site assets; readable only by CloudFront via OAC  |
| **CloudFront Distribution** | HTTPS, edge caching, clean-URL routing            |
| **CloudFront Function**     | Request rewriting (`multipage` clean URLs or SPA) |
| **Response Headers Policy** | Security headers (when `security.headers` is set) |
| **ACM Certificate**         | TLS for the custom domain (when `domain` is set)  |
| **Route53 A Record**        | Alias to CloudFront (when `domain` is set)        |
| **Lambda + Function URL**   | Contact-form endpoint (when `forms` is set)       |

No Docker image is built and no ECR repository is created — the deploy
artefact is the static files themselves.

## Routing Modes

| Mode                    | Behaviour                                                                |
| ----------------------- | ------------------------------------------------------------------------ |
| `multipage` *(default)* | Clean URLs: `/about` → `about.html`, trailing directories → `index.html` |
| `spa`                   | Every non-file path falls back to `index.html` for client-side routing   |

Set with `--routing spa` at create time, or `routing: "spa"` in
`infrastructure.ts`.

## Deployment Process

### First Deploy

```bash theme={null}
fjall deploy
```

1. Detects `type: "staticsite"` in `infrastructure.ts`
2. Runs your build command in `source` (10-minute budget)
3. Synthesises and deploys the single CDN stack
4. Uploads the build output to S3 (stale files from previous builds are
   pruned)
5. Prints the CloudFront (and custom-domain) URL

### Subsequent Deploys

The same command rebuilds and re-uploads. Only changed files transfer, and
pruning keeps the bucket in lockstep with the latest build — a deleted page
disappears from S3 rather than lingering.

## Custom Domain

```bash theme={null}
fjall create app --name my-site --pattern staticsite \
  --source ../.. --build-command "npm run build" --output-dir out \
  --pattern-domain example.com
```

When `domain` is set, the pattern looks up the Route53 hosted zone, creates a
DNS-validated ACM certificate in `us-east-1`, attaches it to CloudFront, and
creates an alias A record.

**Requires:** a Route53 hosted zone for the domain in the deployment account.
See [Domain](/resources/networking/domain) for delegating or importing zones.

## Contact Forms

A static site usually still needs one dynamic thing: a way for visitors to get
in touch. Setting `forms` provisions a Lambda behind
`https://<domain>/api/contact` that emails submissions via SES.

```bash theme={null}
fjall create app --name my-site --pattern staticsite \
  --source ../.. --build-command "npm run build" --output-dir out \
  --pattern-domain example.com \
  --forms-to hello@my-inbox.com
```

The sender and recipient are separate axes:

| Field  | Constraint                                                 | Default            |
| ------ | ---------------------------------------------------------- | ------------------ |
| `from` | Must be an address at `domain` (the verified SES identity) | `noreply@<domain>` |
| `to`   | Any address — your personal inbox is the common case       | *(required)*       |

The submitter's own address goes in **Reply-To**, so replying from your inbox
reaches them directly.

<Warning>
  Forms require `domain` — SES only sends from a verified identity, and the
  site's domain is that identity. Before the first submission works you must
  **verify the domain in SES** (SES console → Verified identities → Create
  identity → Domain) in the deployment account and region. With the hosted
  zone in Route53, SES adds the DKIM records automatically. While your account
  is in the SES sandbox, the `to` address must also be verified — or request
  production access.
</Warning>

### Wiring a form

POST JSON or form-encoded data with `name`, `email`, and `message` fields.
Include an empty `_gotcha` field as a honeypot — bots that fill it get a
silent success and no email is sent.

```html theme={null}
<form id="contact">
  <input name="name" placeholder="Name" />
  <input name="email" type="email" placeholder="Email" />
  <textarea name="message" required placeholder="Message"></textarea>
  <input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off" />
  <button type="submit">Send</button>
</form>

<script>
  document.getElementById("contact").addEventListener("submit", async (e) => {
    e.preventDefault();
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(e.target))),
    });
    alert(res.ok ? "Thanks — message sent." : "Something went wrong.");
  });
</script>
```

### Abuse posture

The endpoint is public and unauthenticated, so the pattern bounds what a
caller can do rather than pretending to authenticate them:

* **Fixed sender and recipient** — whatever a caller sends, the email can only
  ever go from `noreply@<domain>` to your configured address
* **Origin gate + CORS** — browsers on other sites cannot POST to it; the
  allow-origin defaults to `https://<domain>` (override with `--cors-origin`)
* **Honeypot** — filled `_gotcha` short-circuits to a silent success
* **Reserved concurrency** — the Lambda is capped (default 5 concurrent
  executions), so a flood cannot run up the SES bill or drain account-wide
  Lambda concurrency
* **Scoped IAM** — the Lambda may call `ses:SendEmail` only for the site's
  domain identity, conditioned on the exact `From` address

## Security Headers

```typescript theme={null}
security: {
  headers: true;
}
```

Ships four safe defaults: `Strict-Transport-Security`,
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and
`Referrer-Policy`.

Content-Security-Policy is deliberately opt-in — a wrong policy silently
breaks inline scripts and hydration, so it is never a shipped default:

```typescript theme={null}
security: {
  headers: true,
  contentSecurityPolicy: "default-src 'self'; img-src 'self' data:",
}
```

## Pattern Parameters

### Full Configuration Example

```typescript theme={null}
app.addPattern(
  PatternFactory.build("MySiteStaticSite", {
    type: "staticsite",
    name: "my-site",
    source: "../..",
    build: { command: "npm run build", outputDir: "out" },
    routing: "multipage",
    security: { headers: true },
    domain: "example.com",
    forms: {
      to: "hello@my-inbox.com",
      corsOrigin: "https://example.com",
    },
  }),
);
```

### Root Options

| Option          | Type                   | Default       | Description                                        |
| --------------- | ---------------------- | ------------- | -------------------------------------------------- |
| `type`          | `"staticsite"`         | —             | Pattern discriminator                              |
| `name`          | `string`               | —             | Pattern name (used for resource naming)            |
| `source`        | `string`               | —             | Directory the build runs in and assets upload from |
| `build`         | `object`               | —             | Build command + output directory (see below)       |
| `routing`       | `"multipage" \| "spa"` | `"multipage"` | Clean-URL rewriting or SPA fallback                |
| `security`      | `object`               | —             | Security-header configuration (see below)          |
| `domain`        | `string`               | —             | Custom domain; required when `forms` is set        |
| `managedDomain` | `object`               | —             | Import zone/cert from a managed domain stack       |
| `forms`         | `object`               | —             | Contact-form configuration (see below)             |
| `cdn`           | `object`               | —             | Advanced per-path CloudFront behaviour overrides   |

### Build Options

| Option      | Type     | Description                                            |
| ----------- | -------- | ------------------------------------------------------ |
| `command`   | `string` | Build command run in `source` (e.g. `"npm run build"`) |
| `outputDir` | `string` | Folder (relative to `source`) holding the built site   |

### Security Options

| Option                  | Type      | Default | Description                |
| ----------------------- | --------- | ------- | -------------------------- |
| `headers`               | `boolean` | —       | Ship the four safe headers |
| `contentSecurityPolicy` | `string`  | —       | Opt-in CSP header value    |

### Forms Options

| Option           | Type     | Default            | Description                                         |
| ---------------- | -------- | ------------------ | --------------------------------------------------- |
| `to`             | `string` | —                  | Where submissions are delivered (any address)       |
| `from`           | `string` | `noreply@<domain>` | Envelope sender; must be an address at `domain`     |
| `corsOrigin`     | `string` | `https://<domain>` | Exact origin allowed to POST the form               |
| `maxConcurrency` | `number` | `5`                | Reserved concurrent executions for the forms Lambda |

## Troubleshooting

### Form POST returns 403

The Origin/Referer gate rejected the request. The browser must send the form
from the configured origin — check `corsOrigin` matches the site origin
exactly (`https://example.com`, no path, no trailing slash), and that you are
not testing from `localhost` against the deployed endpoint.

### Form POST returns 502

SES refused the send. Almost always identity verification: the domain is not
verified as an SES identity in the deployment account/region, or the account
is in the SES sandbox and the `to` address is unverified. Check the identity's
status in the SES console.

### Clean URLs return 403/404

Confirm `routing` matches the site shape: `multipage` for a folder of `.html`
pages, `spa` for a client-side router that needs every path to serve
`index.html`.

### Deploy fails: build output not found

`outputDir` is resolved relative to `source`. Run the build command manually
in `source` and check which folder it emits — Next.js exports to `out`, Vite
and Astro to `dist`.

## Costs

Estimated monthly costs for a low-traffic site:

| Resource            | Cost                               |
| ------------------- | ---------------------------------- |
| S3                  | \~\$0.03–0.50 (storage + requests) |
| CloudFront          | \~\$0 (1 TB/month free tier)       |
| Lambda (forms)      | \~\$0 (pay per request)            |
| SES                 | \$0.10 per 1,000 emails            |
| Route53 hosted zone | \$0.50 (custom domain only)        |
| **Total**           | **\~\$0–1/month**                  |

## Next Steps

<CardGroup cols={2}>
  <Card title="Payload Pattern" icon="cube" href="/patterns/payload-pattern">
    Need a CMS or SSR? Deploy Payload on Lambda
  </Card>

  <Card title="CDN Factory" icon="cloud" href="/patterns/cdn-factory">
    Advanced CloudFront behaviours and origins
  </Card>

  <Card title="Storage Factory" icon="database" href="/patterns/storage-factory">
    S3 bucket configuration in depth
  </Card>

  <Card title="Deploy Application" icon="rocket" href="/deployment/deploy-application">
    General deployment guide
  </Card>
</CardGroup>

## Related Documentation

**Fjall:**

* [Deploy Application](/deployment/deploy-application) - General deployment guide
* [CDN Factory](/patterns/cdn-factory) - CloudFront configuration
* [Storage Factory](/patterns/storage-factory) - S3 options

**External:**

* [Next.js Static Exports](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) - `output: "export"` setup
* [Amazon SES identity verification](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html) - Verifying the sending domain
