Deployment
Finly is static output with one exception. output stays at Astro’s default, so every page is prerendered unless it opts out, and exactly one does: /contact/ sets prerender = false because its form posts to a server action that sends email. That means 122 HTML files on a CDN and one route that needs a runtime — not a server-rendered site with some caching.
pnpm build produces:
dist/
├── client/ 122 HTML files, hashed CSS/JS/images, _headers,
│ robots.txt, llms.txt, rss.xml, sitemap-index.xml, sitemap-0.xml
└── server/ entry.mjs, the middleware, and a generated wrangler.json
dist/client/_headers ships a one-year immutable Cache-Control for /_astro/*, which every static host that reads that file will honor.
Set site first
Everything downstream of the domain is derived from a single constant at the top of astro.config.mjs:
const SITE = "https://finly.domidex01.workers.dev/";
It feeds the canonical link, og:url, hreflang, the JSON-LD @ids, the sitemap (including the manual customPages entry for /contact/), robots.txt’s Sitemap: line, llms.txt’s absolute links and rss.xml’s item URLs. One wrong value poisons seven things at once and none of them looks broken in review, so a move changes it here in the same commit.
It ships pointing at the theme’s own demo deployment. Before that existed it was https://finly.example — the IANA-reserved documentation TLD from RFC 2606, chosen over a plausible-looking finly.com because Finly is an invented company and a made-up registrable domain would point every canonical at whoever actually owns it. That is still the right fallback, and it is still what rss.xml.ts and llms.txt.ts use if site is ever unset.
The Cloudflare path
The theme ships wired for Cloudflare Workers, and one script does the whole job:
pnpm deploy:cf # astro build && wrangler deploy -c dist/server/wrangler.json
wrangler.jsonc in the repo root carries the Worker’s name, a compatibility_date, the nodejs_compat flag the Astro server entry needs, and observability. What it deliberately does not carry is main or assets:
{
"name": "finly",
"compatibility_date": "2026-08-18",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true }
// NO `main` AND NO `assets` HERE — the adapter owns both.
}
@astrojs/cloudflare runs the Cloudflare Vite plugin, which reads this file at the start of the build and validates that main points at a file that exists. Naming the build’s own output there is a chicken-and-egg problem: dist/server/entry.mjs is what the build is about to create, so the build would fail before it could. The adapter instead merges your config with the entry and asset paths it just produced and writes the result to dist/server/wrangler.json — which is the file deploy:cf hands to wrangler.
Add bindings, vars and flags to wrangler.jsonc; they are carried through. Secrets are not, and never should be:
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put CONTACT_TO_EMAIL
Those two are read at request time through astro:env/server, so setting them changes behavior without a rebuild. CONTACT_FROM_EMAIL has a default (Resend’s shared sandbox sender) and only needs setting once you have verified your own sending domain.
One deployment gotcha is worth knowing before your first test send. While no sending domain is verified at resend.com/domains, the sandbox sender delivers only to the Resend account owner’s own address. Any other CONTACT_TO_EMAIL returns 403 and the form renders “Your message could not be sent.” That is not a bug in the form; it is Resend’s sandbox. Verify a domain, then set CONTACT_FROM_EMAIL to an address on it.
Deploying somewhere else
Two shapes work, and the choice is really about whether you want the contact form to work.
Keep the form: swap the adapter. @astrojs/node, @astrojs/netlify and @astrojs/vercel all drop in. Replace the import and the adapter: line in astro.config.mjs, set the same three environment variables in the host’s own UI, and delete wrangler.jsonc and the deploy:cf script. Nothing else in the theme knows which host it is on — the action reads its config through astro:env/server, which is the one form of environment access that resolves at request time on every adapter. (import.meta.env would not: on Workers, secrets live only in the runtime env and never reach it.)
Drop the form: go fully static. Remove the adapter: line, delete the export const prerender = false from src/pages/contact.astro, and flip CONTACT_WIRED to false in src/components/Contact/_form.ts. The build then emits 123 files with no server half at all, the contact page renders with a disabled submit and a note explaining why, and any file host will serve it. Remove /contact/ from ON_DEMAND_ROUTES in astro.config.mjs at the same time, or the sitemap will keep listing a page as on-demand that is now a plain file — harmless, but wrong.
ON_DEMAND_ROUTES is worth understanding either way. It is one array feeding two consumers that would otherwise both be wrong about the same route in opposite directions: @astrojs/sitemap cannot enumerate a route that emits no file, so it would silently drop an indexable page; and the link-integrity hook reads the output directory, so it would call all 561 links pointing at /contact/ dead. Adding an on-demand route is one edit.
What the build checks for you
pnpm build is the real pre-flight, because three classes of mistake surface there and nowhere else.
Content schemas. Zod validates every entry in blog, authors and customers. Bad frontmatter fails with the entry named — a story missing one of its exactly-three metrics, a post with no heroImage.
Link integrity. The finly:link-integrity hook walks the emitted HTML, collects every href="/…", and compares it against the set of paths the build actually produced — including endpoints and assets, since /rss.xml, /robots.txt and /favicon.svg are real destinations the page list never mentions. Any miss fails the build with the dead path, how many pages draw it and an example:
1 link(s) in the built HTML point at a path this build did not produce:
/product/bill-pay/ — drawn by 4 page(s), e.g. /product/index.html
Build the page, retarget the link, or drop it — every link this site draws resolves.
Content invariants. officesFor throws when a careers advert names a city that is not in careersData.offices, and imageFor throws when a config entry has no matching static import. Both name the entry rather than rendering a hole.
Run the rest of the suite alongside it:
pnpm lint && pnpm check && pnpm build && pnpm test
Pre-launch checklist
siteinastro.config.mjspoints at the domain you will actually serve from.siteData.name,title,descriptionandtaglineare yours. The description is the site-wideWebSiteJSON-LD description and the lead line of/llms.txt, not just the home page’s meta tag.sameAsandauthor.twitterare filled — or still deliberately empty. They ship empty because every plausible value for an invented company points at a real stranger’s account.BaseHeadomits both cleanly when they are falsy, so an unfilled field costs nothing and a wrong one misattributes the site.public/og.jpgis a real 1200×630 social image. It is also theOrganizationlogo in the JSON-LD graph until you pass a different one, so replacing it fixes two things.public/favicon.svgandfavicon.icoare yours.legalData.json.tshas been through someone qualified. It is placeholder text and says so.siteData.sampleNotice— the “Design sample — invented company, invented figures” line the footer and several bands print — is removed or replaced. It exists because Finly’s figures are invented; on a real site it is either a lie or a leftover.- The examples catalog is gone, if you are done with it: delete
src/pages/examples/andsrc/components/UiCatalog/together. The route already emits nothing in production, but Tailwind still scans the catalog’s markup, so its demo classes sit in the stylesheet every real page loads until the components go too. - The two Resend secrets are set on the host, and a test submission has actually arrived.
dist/client/robots.txt,llms.txtandsitemap-0.xmlcarry your domain and list 119 indexable URLs — notexample.com, and not the three account screens or the 404, which arenoindexand filtered out.