[← All integrations](/getting-started)

Install · Verified

# Express + Docker

static SPA · canonical

The baseline every other recipe derives from. Static SPA in a Node container; snippet runs in Express middleware and splices the render bundle into your index.html.

## For agents (Claude, Cursor, Copilot, etc.) — read this first

You're here because a user pasted an install prompt of the shape **"Hey! I'd like to install agentsite.app — here's my token `asit_…`"**. Before you write any code:

1.  **Confirm the target site URL.** If the prompt doesn't name one, infer it from the repo (env vars, README, deployed‑domain references in `package.json` / framework configs) and **ask the user to confirm**: _"Based on your codebase, this install will be for `<URL>`. Correct?"_ Do not proceed on a guess.
2.  **Confirm the integration pattern.** Pick from the install patterns below using the decision rule. State your choice back: _"You're running `<framework>` behind `<server>` — I'm going to use the `<pattern>` recipe."_ If multiple match (e.g. SPA behind nginx — Nginx vs Express), explain the trade‑off and let the user pick.
3.  **Register the site.** Once URL is confirmed, call `POST https://api.agentsite.app/site` with `{"domain": "<the URL, host only>"}` and `Authorization: Bearer <token>`. The account token mints a per‑site token in the response — use that for the snippet's `AGENTSITE_TOKEN` env var. The token in the install prompt is account‑level (scope `sites:write`); the site‑level token returned from create is what the deployed snippet authenticates with.
4.  **Show the diff, do not auto‑commit.** Stage every change so the user can review file‑by‑file. Run any verification (build, type‑check, smoke curl) you can without committing. Only commit when the user explicitly says ship it.

If anything in steps 1–2 is ambiguous, ask before reading the patterns below. The user wants the install to be right, not fast.

**Don't have a token yet?** Read [https://api.agentsite.app/agent.md](https://api.agentsite.app/agent.md) §1 — it walks the user through minting a PAT at [https://agentsite.app/settings/tokens](https://agentsite.app/settings/tokens) and explains the scope set (`sites:read`, `sites:write`, `reports:read`) you need for the install.

* * *

Pick the install pattern that matches the shape of your stack. All shipping today:

-   **Nginx — Thin nginx (no code in your deploy).** _Our favorite install._ Your static SPA sits behind nginx and you want zero code in your deploy. Add a handful of `location` + `proxy_pass` directives to your existing `nginx.conf`; agentsite owns the HTML byte stream including reach‑back to `<host>/index.html` for the SPA shell. No Node sidecar, no edge function, no package to keep current. _Examples: Lovable / v0 / Bolt self‑hosters on a VPS, mid‑market shops with an existing nginx tier and no appetite for a Node sidecar._ See [Nginx install](#nginx--thin-nginx-no-code) below.
-   **Express — Static SPA in a container.** Your build produces a directory of static files (`dist/` or similar) and you can run a Node container in production. _Examples: Vue/React/Svelte/Solid SPAs deployed to Docker / k8s / [fly.io](http://fly.io) / [render.com](http://render.com) / Railway / VPS._
-   **Express‑Sidecar — Non‑streaming SSR framework in a container.** Your framework runs its own server process at request time, and you can run a Node sidecar alongside it. _Examples: Next.js pages router, Nuxt 2, classic Express/Koa/Hapi, Rails, Django, WordPress, PHP‑FPM, deployed to Docker / k8s / fly / render / Railway._
-   **SDK — Streaming SSR via the `/bundle` API.** Framework SDK for streaming‑SSR shops where buffering the response in a proxy would break things (React Server Components, Next App Router default, Nuxt 3 streaming, SvelteKit streaming, Suspense streaming). The SDK calls our `/bundle` API in‑process during SSR and folds the result into the rendered tree — no proxy in front, streaming preserved. Larger footprint than the middleware patterns (~9–10 SDK files committed into your repo, one config edit, one line per page) but framework‑native. _Examples: Next.js App Router, Nuxt 3, SvelteKit streaming. See `demos/after/next/` and `demos/after/nuxt/` for runnable references._
-   **Edge — Edge runtime / PaaS that doesn't run arbitrary Node.** _Examples: Vercel (without your own container), Cloudflare Pages, Netlify, GitHub Pages with Cloudflare in front._ Use the Fetch‑API port `agentsite-edge.mjs` + the platform's edge middleware shell. See [Edge install](#edge-install) below.

Decision rule:

1.  Can you run a Node container in production? **No** → **Edge** or **Nginx**.
2.  Does your framework have a server‑side runtime, and is the response **non‑streaming** HTML? **Yes** → **Express‑Sidecar**. **No** (response streams chunks — RSC / Suspense / Next App Router / Nuxt 3 streaming) → **SDK — Streaming SSR**. **No** (no server runtime) → **Express**, or **Nginx** if your build sits behind nginx and zero‑code‑in‑deploy is a hard constraint.

**Nginx** and **Express** target the same audience (static SPA). Default to Nginx — "no new process / no code in our deploy," favored by audit‑cautious CTOs and ops teams that won't add a Node sidecar. Choose Express when you want the friendliest local‑control + visible‑failure story (snippet runs in your Node process, code you can audit line‑by‑line) instead of nginx config.

ISR / hybrid frameworks (Astro static + islands, Next.js pages router with mixed static+ISR, non‑streaming SSR) → **Express‑Sidecar**. The framework's own runtime needs to run; agentsite proxies in front.

> **UA‑blind by design.** Every install pattern above returns identical response bytes to humans, GPTBot, ClaudeBot, PerplexityBot, and share‑card scrapers. No User‑Agent routing, no bot cloaking — the decision lives on the response, not on the requester. This is the cleaner posture vs. UA‑switching middlewares like [prerender.io](http://prerender.io), and it sidesteps Google's spam‑policy cloaking risk.

> **Fail‑open, snippet‑in‑your‑deploy.** Every pattern degrades to your unmodified site if agentsite is unreachable — Express / Express‑Sidecar return your `index.html` unchanged, the SDK skips the `/bundle` call and renders without it, Nginx falls back to a `@raw_shell` location via `error_page 502 504`. **The Nginx pattern goes further: agentsite owns the entire HTML byte stream including reach‑back to your origin for the SPA shell** — there is no agentsite library inside your codebase, no Node sidecar, no edge function, no package to keep current. Removing any of these is one config edit. Your site is never blocked on us.

> **Looking for njs / CloudFront / Python / PHP?** Those are on the [roadmap](#roadmap) — not yet in pre‑release. Use one of the patterns above today.

* * *

## Nginx — Thin nginx (no code)

For nginx‑fronted static SPAs. Your nginx stays the front door; agentsite is just another upstream a few paths route to. No code in your deploy beyond a paste of `location` + `proxy_pass` directives.

**Two deployment shapes** — same nginx directives, different way the token reaches them:

-   **Containerized** (§3a, primary) — nginx ships inside a Docker image (k8s / Fly / Railway / [render.com](http://render.com) / Lovable+Bolt+v0 exports). The token can't be baked into the image; the official `nginx:alpine` entrypoint templates it at boot via `envsubst`. **Most modern SPA deploys are this shape.**
-   **Bare‑metal / VPS** (§3b) — nginx is a long‑lived file on disk (`/etc/nginx/...`) that you edit and reload. The token is a literal string in the config.

The directives in §2 are identical for both shapes. Only the token placeholder differs.

### 1. Get your site token

Sign in to [https://agentsite.app](https://agentsite.app), register the site, and copy its token from the dashboard. Without the token, render calls 401 and the site degrades to the unenriched shell. The token is `<SITE_TOKEN>` in §2 below — for **Containerized**, you'll write `${AGENTSITE_TOKEN}` and pass the value at runtime; for **Bare‑metal / VPS**, you'll inline the literal token string.

### 2. The agentsite directives

```nginx
# Nginx install — paste into your existing nginx server { } block.
# Your existing listen / server_name / root / ssl stays as‑is. Make sure
# `root` is set (server‑level if it isn't already) so the bypass branch
# below can serve files from disk.

resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;

location ~ ^/__raw/(robots\.txt|sitemap\.xml|llms(-full)?\.txt|\.well-known/|.*\.md$) {
    internal;
    rewrite ^/__raw(.*)$ $1 break;
    try_files $uri =404;
}

location /__raw/ {
    internal;
    rewrite ^/__raw(.*)$ $1 break;
    try_files $uri /index.html =404;
}

location ~ ^/(robots\.txt|sitemap\.xml|llms(-full)?\.txt|\.well-known/) {
    if ($http_x_agentsite = "none") { rewrite ^ /__raw$uri last; }
    proxy_pass https://api.agentsite.app/render?url=https://$host$request_uri;
    proxy_set_header Authorization "Bearer <SITE_TOKEN>";
    proxy_ssl_server_name on;
}

location ~ \.md$ {
    if ($http_x_agentsite = "none") { rewrite ^ /__raw$uri last; }
    proxy_pass https://api.agentsite.app/render?url=https://$host$request_uri;
    proxy_set_header Authorization "Bearer <SITE_TOKEN>";
    proxy_ssl_server_name on;
}

location ~* \.(js|mjs|css|map|woff|woff2|ttf|otf|eot|ico|svg|png|jpe?g|gif|webp|avif|mp4|webm)$ {
    try_files $uri =404;
}

location / {
    try_files $uri @agentsite;
}

location @agentsite {
    if ($http_x_agentsite = "none") { rewrite ^ /__raw$uri last; }
    proxy_pass https://api.agentsite.app/render?url=https://$host$request_uri;
    proxy_set_header Authorization "Bearer <SITE_TOKEN>";
    proxy_ssl_server_name on;
    proxy_connect_timeout 2s;
    proxy_read_timeout 5s;
    proxy_intercept_errors on;
    error_page 502 504 = @raw_shell;
}

location @raw_shell {
    try_files /index.html =404;
}
```

> **Two ways the token reaches the `Authorization:` header above:**  
> - **Containerized (§3a)** → write the line as `proxy_set_header Authorization "Bearer ${AGENTSITE_TOKEN}";`.  
> - **Bare‑metal / VPS (§3b)** → replace `<SITE_TOKEN>` with the literal token string from your dashboard.

### 3a. Apply — Containerized

Two files in your repo: a templated nginx config + a Dockerfile. The reference implementation in [`demos/after/nginx-container/`](#reference-implementations) is the byte‑exact result of following the steps below.

#### `default.conf.template`

Take the block from §2, wrap it in a `server { listen 80 default_server; server_name _; root /usr/share/nginx/html; index index.html; ... }` if you don't have one already, and write the token line as `proxy_set_header Authorization "Bearer ${AGENTSITE_TOKEN}";`.

#### `Dockerfile`

```dockerfile
FROM nginx:1.27-alpine

COPY dist /usr/share/nginx/html
COPY default.conf.template /etc/nginx/templates/default.conf.template

ENV NGINX_ENVSUBST_FILTER=^AGENTSITE_

EXPOSE 80
```

#### Build + run

```sh
npm run build
docker build -t your-app .
docker run --rm -p 8080:80 \
  -e AGENTSITE_TOKEN=asit_xxxxxxxx \
  your-app
```

#### Smoke‑test the substitution before you ship

```sh
docker run --rm -e AGENTSITE_TOKEN=asit_test your-app \
  cat /etc/nginx/conf.d/default.conf | grep Bearer
```

| Output | Meaning |
| --- | --- |
| `Bearer asit_test` | ✓ Substitution worked. Ship it. |
| `Bearer ${AGENTSITE_TOKEN}` | Template at the wrong path. |
| `Bearer` (empty) | Env var didn't reach the container. |
| (no `Bearer` line) | Token line missing from the template. |

Visit `http://localhost:8080/` — you see your SPA. Curl the same URL without a JavaScript engine — you see the head enriched with title, meta, JSON‑LD, and a markdown body for AI crawlers.

### 3b. Apply — Bare‑metal / VPS

Replace `<SITE_TOKEN>` in the §2 block with the literal token string, paste into your existing `server { }` directive, and reload:

```sh
nginx -t
nginx -s reload
```

Visit a page in a browser — you see your SPA. Curl the same URL without a JavaScript engine — you see the head enriched with title, meta, JSON‑LD, and a markdown body for AI crawlers.

## Express — Static SPA

agentsite is a small Node middleware that serves your SPA's `dist/` directory plus a per‑request render bundle (title, meta, JSON‑LD schema, markdown body) that AI crawlers read without running JavaScript.

Two deployment shapes — both use the same `agentsite/` directory + `server.mjs`; only the surrounding container topology differs:

-   **Replace your static server** — single Node container, agentsite is the only process. Recommended when your existing static layer (nginx, Caddy, etc.) only serves files.  
-   **Sidecar behind your reverse proxy** — keep nginx (or Caddy, HAProxy) in front; agentsite runs on `127.0.0.1:3000` behind it. Recommended when your reverse proxy does more than serve files.

### 1. Add the `agentsite/` directory

```sh
mkdir -p agentsite
curl https://api.agentsite.app/snippet.js > agentsite/agentsite.cjs
```

### 2. Write `agentsite/package.json`

```json
{
  "name": "agentsite-sidecar",
  "private": true,
  "type": "module",
  "engines": { "node": ">=20" },
  "main": "server.mjs",
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "express": "^4.21.1" }
}
```

### 3. Write `agentsite/server.mjs`

```js
import express from 'express'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const agentsite = require('./agentsite.cjs')

const app = express()
app.use(agentsite({
  distDir: process.env.DIST_DIR || '/app/dist',
  site: process.env.AGENTSITE_SITE,
  token: process.env.AGENTSITE_TOKEN,
}))

const PORT = parseInt(process.env.PORT || '3000', 10)
app.listen(PORT, () => {
  console.log(`[agentsite] :${PORT} serving ${process.env.DIST_DIR || '/app/dist'}`)
})
```

### 4. Replace your Dockerfile

```dockerfile
FROM node:20-alpine

COPY agentsite/package.json /app/agentsite/package.json
RUN cd /app/agentsite && npm install --omit=dev --no-package-lock

COPY dist /app/dist
COPY agentsite/agentsite.cjs /app/agentsite/agentsite.cjs
COPY agentsite/server.mjs /app/agentsite/server.mjs

ENV NODE_ENV=production
ENV PORT=3000
ENV DIST_DIR=/app/dist

EXPOSE 3000

CMD ["node", "/app/agentsite/server.mjs"]
```

### 5. Build + run

```sh
npm run build
docker build -t your-app .
docker run --rm -p 3000:3000 \
  -e AGENTSITE_TOKEN=asit_xxxxxxxx \
  -e AGENTSITE_SITE=https://your-site.com \
  your-app
```

Visit `http://localhost:3000/` — you see your SPA. Curl the same URL without a JavaScript engine — you see the head enriched with title, meta, JSON‑LD, and a markdown body for AI crawlers.

* * *

## Express‑Sidecar — Non‑streaming SSR framework

> ⚠️ **Streaming SSR is not supported.** If your framework streams HTML chunks (React Server Components, Next.js App Router, Nuxt 3 streaming, SvelteKit streaming, or any `Transfer-Encoding: chunked` HTML response) — **do not use this pattern**. agentsite buffers the upstream response to inject head/body tags; buffering negates the streaming benefit and regresses TTFB.

Your framework has its own non‑streaming runtime that does meaningful work per request. agentsite sits in front as a reverse‑proxy sidecar — same container, two processes, with `entrypoint.sh` starting both.

### 1. Add the `agentsite/` directory

```sh
mkdir -p agentsite
curl https://api.agentsite.app/snippet.js > agentsite/agentsite.cjs
```

### 2. Write `agentsite/package.json`

```json
{
  "name": "agentsite-sidecar",
  "private": true,
  "type": "module",
  "engines": { "node": ">=20" },
  "main": "server.mjs",
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "express": "^4.21.1" }
}
```

### 3. Write `agentsite/server.mjs`

```js
import express from 'express'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const agentsite = require('./agentsite.cjs')

const app = express()
app.use(agentsite({
  upstream: process.env.UPSTREAM_ORIGIN || 'http://127.0.0.1:3001',
  site: process.env.AGENTSITE_SITE,
  token: process.env.AGENTSITE_TOKEN,
}))

const PORT = parseInt(process.env.PORT || '3000', 10)
app.listen(PORT, () => {
  console.log(`[agentsite] :${PORT} → ${process.env.UPSTREAM_ORIGIN || 'http://127.0.0.1:3001'}`)
})
```

### 4. Write `entrypoint.sh`

```sh
#!/bin/sh
set -e

PORT=3001 HOSTNAME=127.0.0.1 node server.js &
FRAMEWORK_PID=$!

trap "kill $FRAMEWORK_PID 2>/dev/null || true" EXIT

exec node /app/agentsite/server.mjs
```

### 5. Update your Dockerfile

```dockerfile
COPY agentsite/package.json /app/agentsite/package.json
RUN cd /app/agentsite && npm install --omit=dev --no-package-lock

COPY agentsite/agentsite.cjs /app/agentsite/agentsite.cjs
COPY agentsite/server.mjs /app/agentsite/server.mjs

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENV PORT=3000
ENV UPSTREAM_ORIGIN=http://127.0.0.1:3001

EXPOSE 3000
CMD ["/entrypoint.sh"]
```

### 6. Build + run

```sh
npm run build
docker build -t your-app .
docker run --rm -p 3000:3000 \
  -e AGENTSITE_TOKEN=asit_xxxxxxxx \
  -e AGENTSITE_SITE=https://your-site.com \
  your-app
```

* * *

## Verification

```sh
open http://localhost:3000/
curl -sL http://localhost:3000/ | head -50
curl -sL http://localhost:3000/pricing.md
curl -sI http://localhost:3000/ | grep -i '^link:'
```

If `curl` returns an empty `<div id="app"></div>` with no head enrichment, the snippet didn't wire. Common causes:

-   Missing or wrong `AGENTSITE_TOKEN`.
-   `AGENTSITE_SITE` doesn't match the registered domain.
-   `DIST_DIR` doesn't contain `index.html` (Express) or the framework isn't listening on `127.0.0.1:3001` (Express‑Sidecar).

## Edge install

For platforms where you can't run an arbitrary Node container — Vercel (without your own container), Cloudflare Pages, Netlify, GitHub Pages fronted by Cloudflare.

agentsite ships a Fetch‑API port (`agentsite-edge.mjs`) that runs in any web‑standard edge runtime, plus a small platform‑specific shell that wires it into the platform's middleware contract.

| Platform | Fetch these two files | Drop them at | Set env vars on |
| --- | --- | --- | --- |
| **Cloudflare Pages** | `agentsite-edge.mjs` + `edge/cloudflare-worker.mjs` | `functions/agentsite-edge.mjs` + `functions/_middleware.js` | Pages Settings → Environment Variables |
| **Vercel (non‑Next Vite SPA)** | `agentsite-edge.mjs` + `edge/vercel-edge-middleware.ts` | project root: `agentsite-edge.mjs` + `middleware.ts` | Vercel Project → Environment Variables |
| **Netlify Edge Functions** | `agentsite-edge.mjs` + `edge/netlify-edge-function.ts` | `netlify/edge-functions/` + add `[[edge_functions]]` block to `netlify.toml` | Site Settings → Environment Variables |
| **GitHub Pages / S3 / Lovable / Bolt / v0 / Webflow / Framer / Squarespace** | — | n/a directly | Point custom domain through Cloudflare DNS, then use the Cloudflare Pages recipe in front. |

All edge files are served from `https://api.agentsite.app/agentsite-edge.mjs` and `https://api.agentsite.app/edge/<platform>-…`. The handler covers head enrichment, JSON‑LD, `.md` content negotiation, `/llms.txt`, `/sitemap.xml`, the opinionated `/.well-known/*` paths, the `Link:` discovery header, the WebMCP `registerTool` script, and optional bot UA telemetry.

## Local development & testing

1.  Run your SPA locally with the install applied.  
2.  Open a public tunnel to the port (ngrok, Cloudflare quick tunnel, or Tailscale Funnel).  
3.  Register the tunnel URL as a site at the dashboard.  
4.  Set the env vars on your local container with the tunnel URL.  
5.  Verify with a bot‑like UA:

```sh
curl -sA 'AgentSiteBot/1.0' https://<tunnel-host>/ \
  | grep -E '<title>|og:description|application/ld\+json'
```

6.  Delete the test site when finished.

## Roadmap

-   **njs** — port of the snippet's local decision layer to nginx's njs runtime.  
-   **CloudFront** — CloudFront Functions / Lambda@Edge shell.  
-   **Python** — middleware for Django / Flask / FastAPI.  
-   **PHP** — PHP‑FPM hook / WordPress plugin.

These are roadmap entries, not commitments.

## What next

-   **Get your token.** [https://agentsite.app](https://agentsite.app) — free tier covers one site, no card.  
-   **Register your site.** The dashboard validates your domain and lets you set per‑page settings. The `AGENTSITE_SITE` value you set in your deploy env must exactly match the URL you register, or the snippet's render calls will 401 and agentsite will silently degrade to serving the unenriched shell.