Skip to content
AstroCraft Docs
On this theme

Deployment

Grafio is static except for one page. Every route prerenders to HTML at build time; src/pages/contact.astro sets export const prerender = false; because it receives the form POST and re-renders itself with the result. That single route is the only thing your host’s server function ever runs.

This shapes everything below. You need a host that can run one server function, not one that can run the whole site — and if you drop the contact form, you do not need one at all.

Before you deploy

Nine things, roughly in the order they will bite you.

  1. Set SITE_URL to your production domain. It feeds the canonical link, the Open Graph tags, the JSON-LD graph, the sitemap, robots.txt, llms.txt and the RSS feed — one wrong value poisons seven things at once. Set it in your host’s environment, not in the repo.
  2. Replace public/og.jpg. The shipped file is a labelled placeholder at the right dimensions. Yours should be 1200×630.
  3. Fill in src/config/siteData.json.ts — name, title, description, the author block and the socials list. Remember sameAs derives from socials; do not edit it separately.
  4. Rewrite src/config/legalData.json.ts. The terms and privacy copy are placeholder template text. Have a professional review them. They are not legal advice.
  5. Set RESEND_API_KEY and CONTACT_TO_EMAIL, or the contact form cannot deliver. See Contact Form.
  6. Decide on abuse protection. Set both Turnstile keys or neither, and confirm your Netlify plan supports the rate_limit block in netlify.toml — the deploy succeeds either way, it just does nothing on a plan without it.
  7. Replace the content — six work entries, four blog posts and the sample author under src/data/.
  8. Replace the faviconspublic/favicon.svg and public/favicon.ico.
  9. Delete the dev catalogsrc/components/Sections/UiCatalog/ and src/pages/examples/. It builds no pages in production, but Tailwind still scans its markup, so its demo classes ship in the stylesheet every real page loads. Removing it takes the shared CSS from roughly 108 KB to 89 KB.

Then run the full gate:

pnpm lint && pnpm check && pnpm build && pnpm test

pnpm build is the check that tells the truth. Content-schema errors, config typos and broken image paths all surface there rather than in the browser.

The SITE_URL guard

astro.config.mjs opens with this:

const site = process.env.SITE_URL ?? "https://example.com";
if (process.env.CONTEXT === "production" && site.includes("example.com")) {
  throw new Error(
    "SITE_URL is unset or still the placeholder. Set it to your production domain under " +
      "Netlify → Site configuration → Environment variables before deploying.",
  );
}

A fresh clone builds on the placeholder so you can see the site before you own a domain. A Netlify production build refuses to, so the placeholder cannot reach a live domain. CONTEXT is Netlify’s own build variable — "production", "deploy-preview" or "branch-deploy" — so deploy previews and branch deploys still build freely.

Read that last sentence twice if you are not deploying to Netlify. CONTEXT is a Netlify variable, so on any other host the guard never fires and the placeholder can ship. Either set SITE_URL in your host’s environment before the first deploy, or change the condition to key on whatever your host sets.

Deploying to Netlify

The adapter is already wired (adapter: netlify() in astro.config.mjs), and it emits the server function and the redirects itself. netlify.toml holds only what the adapter cannot know:

[build]
  command = "pnpm build"
  publish = "dist"

[functions."contact"]
  [functions."contact".rate_limit]
    window_limit = 5
    window_size = 60
    aggregate_by = ["ip"]

Connect the repository, set your environment variables under Site configuration → Environment variables, and deploy. The build output is a flat static tree — 404.html, about/, blog/, work/, _astro/, plus robots.txt, llms.txt, rss.xml and the sitemap — with no contact/ directory, because that route is the function.

The rate limit is the backstop behind Turnstile, not a replacement for it. Turnstile decides whether a given submission looks human; this caps how many any single client can make regardless of the answer, including attempts that never reach the function because they failed verification.

Two Netlify specifics worth knowing. No Node version is pinned — neither netlify.toml nor an .nvmrc sets one, and the only floor is engines.node in package.json (22.12.0). If your site’s default runtime is older, set NODE_VERSION in the environment. And images go through Netlify’s Image CDN: the adapter installs its own image service and marks sharp external, so nothing is optimized at build time and transforms happen per request.

Deploying somewhere else

Swapping hosts is two lines. Nothing else in the project knows which adapter is mounted:

pnpm add @astrojs/vercel
import vercel from "@astrojs/vercel";
export default defineConfig({ adapter: vercel() });

Four things to check afterwards.

  • Images. Without the Netlify adapter, Astro falls back to its own image service, which carries sharp as an optional dependency. That usually just works; pnpm add sharp is the fix if your package manager does not pull it in. Note that pnpm-workspace.yaml sets allowBuilds: { sharp: false }, which you may need to flip.
  • The rate limit disappears. netlify.toml becomes inert, so Turnstile is your only abuse control unless the new host offers an equivalent.
  • The SITE_URL guard stops firing, as described above.
  • The build command and publish directory need configuring your host’s own way: pnpm build, output dist.

Going fully static

If you do not want the contact form, delete src/pages/contact.astro and src/actions/index.ts, remove the contact entry from navData.json.ts, and drop the adapter from astro.config.mjs. The result is a plain static site that deploys to any static host — GitHub Pages, Cloudflare Pages, S3 — with nothing to run.

Be aware of one trap while you work: astro dev always runs a server, with or without an adapter. A route that accidentally depends on request-time behaviour will work locally and fail in a static build. Run pnpm build early and often.

What gets built

A production build emits, alongside the HTML:

  • sitemap-index.xml and sitemap-0.xml, excluding /examples/ and /404/ — the two routes that set noindex, which a sitemap entry would contradict.
  • robots.txt with an absolute Sitemap: line derived from site.
  • llms.txt, an editorial content map for AI crawlers: a hand-curated core-page list plus every published post, derived from the collection so it cannot fall behind.
  • rss.xml, mapping the blog collection to escaped RSS 2.0 with trailing-slash item URLs that match the canonical shape.
  • _astro/ with a long-lived immutable cache header applied by the adapter.

After a build, the five-minute verification is: open one HTML file and confirm the JSON-LD @graph has matching @ids and your real domain; confirm robots.txt and llms.txt carry absolute URLs; confirm sitemap-0.xml lists only indexable, trailing-slash URLs.

Continuous checks

The four commands in the gate each catch something different, and none of them subsumes another.

pnpm lint runs ESLint including the JSX accessibility rules. pnpm check runs astro check for types across .astro and .ts files. pnpm build catches content-schema and config errors. pnpm test runs the eleven *.test.ts files under src/ with Node’s type stripping — no framework, no fixtures — and fails if it finds zero, so the suite cannot quietly disappear.

The build does not run the tests. If you wire one CI step, wire all four.

NEXT STEPContent Collections