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

# Static Sites

> Deploy a static Vertz site (landing page, docs, marketing) to Cloudflare Workers

Vertz apps can be deployed as fully static sites — no server required. The build script SSR-renders your pages at build time, bundles client JS for interactivity, and outputs a `dist/` directory you can deploy anywhere. This guide uses Cloudflare Workers, but the output works with any static host.

## When to use this

Static deployment is ideal for:

* Landing pages
* Marketing sites
* Documentation
* Any page where content doesn't change per-request

If your app needs per-request SSR (dynamic data, auth, personalized content), use a [Cloudflare Worker with SSR](/guides/deploy/cloudflare) instead.

## Project structure

A static Vertz site has the same structure as any Vertz UI app:

```
my-site/
├── src/
│   ├── app.tsx              # App component + SSR exports
│   ├── entry-client.ts      # Client hydration entry
│   ├── dev-server.ts        # Dev server config
│   └── components/          # Your components
├── public/                  # Static assets (images, fonts, etc.)
├── scripts/
│   └── build.ts             # Production build script
├── wrangler.toml            # Cloudflare Workers config
└── package.json
```

## App entry

Your `app.tsx` exports the app component and SSR metadata — same as any Vertz app:

```tsx theme={null}
// src/app.tsx
import { getInjectedCSS, ThemeProvider } from '@vertz/ui';
import { Hero } from './components/hero';
import { appTheme, themeGlobals } from './styles/theme';

export { getInjectedCSS };
export const theme = appTheme;
export const styles = [themeGlobals.css];

export function App() {
  return (
    <ThemeProvider theme="dark">
      <main>
        <Hero />
      </main>
    </ThemeProvider>
  );
}
```

## Client entry

The client entry hydrates the server-rendered HTML for interactivity:

```ts theme={null}
// src/entry-client.ts
import { mount } from '@vertz/ui';
import { App, styles } from './app';
import { appTheme } from './styles/theme';

mount(App, {
  theme: appTheme,
  styles,
});
```

## Dev server

For local development, use the standard Vertz dev server:

The `vtz` runtime includes a built-in dev server with HMR, SSR, and the Vertz compiler — no additional configuration required. Just run:

```bash theme={null}
vtz dev
```

## Build script

The build script produces a self-contained `dist/` directory. It:

1. **Builds the client JS bundle** with the Vertz compiler plugin
2. **Extracts CSS** from component `css()` calls
3. **Copies public assets** to `dist/public/`
4. **SSR-renders the page** by starting the dev server and capturing its output
5. **Strips dev-only scripts** (HMR, WebSocket overlay, reload guard)
6. **Injects production head tags** (meta, OG, fonts, client bundle reference)

```ts theme={null}
// scripts/build.ts
import {
  cpSync,
  existsSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  rmSync,
  writeFileSync,
} from 'node:fs';
import { resolve } from 'node:path';
import { execSync, spawn } from 'node:child_process';

const ROOT = resolve(import.meta.dir, '..');
const DIST = resolve(ROOT, 'dist');
const PORT = 4100;

// ── 1. Build client JS bundle ──────────────────────────────
rmSync(DIST, { recursive: true, force: true });
mkdirSync(resolve(DIST, 'assets'), { recursive: true });

// Use `vtz build` for the client bundle in production.
// The build output goes to dist/assets/ with hashed filenames.
execSync('vtz build', { cwd: ROOT, stdio: 'inherit' });

// ── 2. Copy public/ → dist/public/ ────────────────────────
const publicDir = resolve(ROOT, 'public');
if (existsSync(publicDir)) {
  cpSync(publicDir, resolve(DIST, 'public'), { recursive: true });
}

// ── 3. SSR-render the page ─────────────────────────────────
const server = spawn('vtz', ['dev', '--port', String(PORT)], {
  cwd: ROOT,
  env: { ...process.env, PORT: String(PORT) },
  stdio: ['ignore', 'pipe', 'pipe'],
});

// Wait for server
for (let i = 0; i < 60; i++) {
  try {
    if ((await fetch(`http://localhost:${PORT}`)).ok) break;
  } catch {}
  await new Promise((r) => setTimeout(r, 500));
}

const html = await fetch(`http://localhost:${PORT}`).then((r) => r.text());
server.kill();

// ── 4. Strip dev-only scripts ──────────────────────────────
let clean = html
  .replace(/<style>[\s\S]*?<\/style>/g, (match) => (match.includes('hmr') ? '' : match))
  .replace(/<script[^>]*data-dev-server[^>]*>[\s\S]*?<\/script>/g, '')
  .replace(/<script type="module" src="\/src\/entry-client\.ts"><\/script>/g, '');

// ── 5. Inject production head + client bundle ──────────────
// Find the built client JS in dist/assets/
const assets = existsSync(resolve(DIST, 'assets')) ? readdirSync(resolve(DIST, 'assets')) : [];
const clientJs = assets.find((f) => f.startsWith('entry-client') && f.endsWith('.js'));
const clientCss = assets.filter((f) => f.endsWith('.css'));

if (clientJs) {
  clean = clean.replace(
    /<\/body>/,
    `  <script type="module" crossorigin src="/assets/${clientJs}"></script>\n</body>`,
  );
}

const cssLinks = clientCss.map((f) => `  <link rel="stylesheet" href="/assets/${f}" />`).join('\n');
if (cssLinks) {
  clean = clean.replace(/(<title>[^<]*<\/title>)/, `$1\n${cssLinks}`);
}

writeFileSync(resolve(DIST, 'index.html'), clean);
console.log('✓ Build complete: dist/');
```

<Note>
  The build script starts the dev server temporarily to SSR-render the page. This ensures the
  production HTML matches exactly what you see in development — same components, same data, same
  CSS. The dev server is killed immediately after the fetch.
</Note>

## Injecting meta tags

The build script is where you add production-only `<head>` content — meta descriptions, Open Graph tags, fonts, etc. Inject them after the `<title>` tag:

```ts theme={null}
const PRODUCTION_HEAD = `
  <meta name="description" content="Your site description" />
  <link rel="icon" type="image/svg+xml" href="/public/logo.svg" />

  <meta property="og:title" content="Your Site Title" />
  <meta property="og:description" content="Your site description" />
  <meta property="og:image" content="https://yoursite.com/public/og.png" />
  <meta property="og:url" content="https://yoursite.com" />
  <meta property="og:type" content="website" />

  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:image" content="https://yoursite.com/public/og.png" />

  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=..." rel="stylesheet" />`;

clean = clean.replace(/(<title>[^<]*<\/title>)/, `$1\n${PRODUCTION_HEAD}`);
```

For generating OG images programmatically, see the [OG Images guide](/guides/deploy/og-images).

## Wrangler configuration

Configure Cloudflare Workers to serve from `dist/`:

```toml theme={null}
# wrangler.toml
name = "my-site"
compatibility_date = "2025-01-01"
workers_dev = true

[assets]
directory = "./dist"
not_found_handling = "single-page-application"

[[routes]]
pattern = "mysite.com"
custom_domain = true
```

The `not_found_handling = "single-page-application"` setting returns `index.html` for any path that doesn't match a file — this lets client-side routing work.

## Package scripts

```json theme={null}
{
  "scripts": {
    "dev": "vtz dev",
    "build": "vtz run scripts/build.ts",
    "deploy": "vtz run build && vtz exec wrangler deploy"
  }
}
```

## Deploy

```bash theme={null}
# Build and deploy in one command
vtz run deploy

# Or step by step
vtz run build          # Produces dist/
vtzx wrangler deploy       # Uploads dist/ to Cloudflare
```

The output is a clean `dist/` directory:

```
dist/
├── index.html                    # SSR-rendered HTML + meta tags
├── assets/
│   ├── entry-client-[hash].js    # Client bundle (minified, hashed)
│   └── vertz.css                 # Extracted component CSS
└── public/
    ├── logo.svg                  # Your static assets
    └── og.png                    # OG image
```

<Warning>
  The dev server's SSR output includes HMR scripts, WebSocket overlays, and dev server bootstrapping
  code. The build script strips all of these — never deploy the raw dev server output directly.
</Warning>

## How it differs from full-stack deployment

|                  | Static site                  | Full-stack (Workers)            |
| ---------------- | ---------------------------- | ------------------------------- |
| **Server**       | None — static files only     | Cloudflare Worker with SSR      |
| **Data**         | Build-time only              | Per-request (queries, auth)     |
| **Build output** | `dist/` with HTML + JS + CSS | `dist/client/` + `dist/server/` |
| **Use case**     | Landing pages, marketing     | Apps with dynamic data          |
| **Command**      | `vtz run build`              | `vtz run build`                 |

<CardGroup cols={2}>
  <Card title="SSG with generateParams" icon="bolt" href="/guides/deploy/ssg">
    Pre-render dynamic routes at build time
  </Card>

  <Card title="OG Images" icon="image" href="/guides/deploy/og-images">
    Generate Open Graph images for social sharing
  </Card>

  <Card title="SSR" icon="server" href="/guides/ui/ssr">
    Server-side rendering for dynamic apps
  </Card>
</CardGroup>
