---
title: Website guide
description: Prerendered Astro pages, Ilha islands, one server route on D1 — the website template.
---

The website template ([Templates](/guides/templates)) is an [Astro](https://astro.build)
site with [Ilha islands](https://ilha.build/guide/astro), deployed the Void
way on celld. Pages prerender to static HTML; the Worker only runs the
server routes you opt out of prerendering.

## Routes are files

`src/pages/` is the router: `index.astro` → `/`, `blog/index.astro` →
`/blog`, `api/newsletter.ts` → `/api/newsletter`. A new page is a new
file. The blog reads Markdown from `src/content/posts/` through the
collection in `src/content.config.ts` — each file needs `title`,
`description`, `date`; `draft: true` stays out of the build.

## Islands

Interactive parts are plain Ilha components in `src/islands/`, hydrated
where the page says:

```astro
---
import NewsletterForm from "../islands/NewsletterForm.tsx";
---
<NewsletterForm client:load />
```

`client:load` for above-the-fold, `client:idle` or `client:visible` below
it. Island props are JSON only — never forward an Astro `<slot />` into an
island. Until hydration, handlers don't exist: keep controls usable or
disabled, never silently dead.

## Server routes

Pages prerender by default — no server runs for them. A route that needs
bindings opts out. The starter's `src/pages/api/newsletter.ts` validates and
saves signups; a read-only route looks like this:

```ts
// src/pages/api/subscribers.ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";

export const prerender = false;

export const GET: APIRoute = async () => {
  const { DB } = env;
  const { results } = await DB.prepare("SELECT id FROM newsletter").all();
  return Response.json({ count: results.length });
};
```

`export const prerender = false` is the whole switch: without it, the route
renders once at build time, where bindings don't exist. Read bindings only
through `import { env } from "cloudflare:workers"` — Astro 6 removed
`Astro.locals.runtime.env`, which now throws at runtime. Keep server routes
few and small: one D1 database, one R2 bucket per site.

Pages build as files (`blog.html`, `blog/hello.html`) and links use the
no-slash spelling (`/blog`, `/blog/hello`); the template sets
`build.format: "file"` and `trailingSlash: "never"` for that.

## Database

D1 is on by default (`void.config.ts`, `d1_databases`); Outer provisions it
on first deploy. Schema changes ride the deploy as committed migration
files in `db/migrations/`. Outer applies pending migrations before the new
code goes live; a failing migration fails the deploy and the previous
version keeps serving. Never edit an applied migration — add a new one. The
route's `CREATE TABLE IF NOT EXISTS` stays as a local-dev backstop, with
the same statement as the migration.

## Build and deploy

`bun run build` prerenders the pages, bundles the Worker, and derives
`dist/wrangler.json` from the adapter's `dist/server/wrangler.json`
(`celld-config` in `astro.config.mjs`: allowlisted keys only, `main` →
`server/entry.mjs`, assets → `client`, `vars` → `{}`). Outer deploys `dist/`
with celld. Never hand-write `dist/wrangler.json`.
