Deployment
Develi is a fully static site with no adapter. pnpm build produces a directory of HTML, CSS, JS and images, and that is the whole deliverable. There is no server-rendered route, no serverless function, no runtime environment to configure. Anything that can serve files can host it.
That is a deliberate constraint rather than a limitation to work around, and it shapes two things worth knowing up front: the output tree is flat (dist/, not dist/client/), and every dynamic-looking endpoint — robots.txt, llms.txt, rss.xml, the sitemap — is a .ts endpoint that prerenders to a static file at build time.
Build
pnpm build
On the shipped content this produces 29 pages and around 116 optimized image variants in a few seconds. Preview the real output before shipping it:
pnpm preview
pnpm dev runs everything through a server, so a route that accidentally depends on request-time behavior works locally and fails in a static build. Run pnpm build early and often — it is the check that tells the truth.
SITE_URL is the one required variable
It feeds seven things: the canonical link, Open Graph tags, JSON-LD @ids, the sitemap, robots.txt, llms.txt and the RSS feed. One wrong value poisons all seven at once, and none of them looks broken in review.
Set it in your host’s environment variables — not in the repo. A fresh clone builds happily on the placeholder https://example.com so you can see the site before you own a domain. What it will not do is let that placeholder reach production:
const isProductionDeploy =
process.env.CONTEXT === "production" || // Netlify
process.env.VERCEL_ENV === "production" || // Vercel
process.env.DEPLOY_ENV === "production"; // anything else
if (isProductionDeploy && site.includes("example.com")) {
throw new Error("SITE_URL is unset or still the placeholder. …");
}
The theme is host-agnostic, so the guard reads each host’s own build signal rather than assuming one. Local builds and deploy previews set none of those three, so they build freely — which is exactly why this only ever bites at deploy time.
If you deploy anywhere other than Netlify or Vercel, set DEPLOY_ENV=production in your host’s build environment yourself. The guard is only as good as the signal it can see, and on an unrecognised host it silently does nothing. That includes Cloudflare Pages, GitHub Pages, Dokploy, a plain nginx container, and anything else.
Host configuration
Two settings need to match the theme rather than the default:
Trailing slashes. astro.config.mjs sets trailingSlash: "always", and with the default directory build format every route emits path/index.html — so every canonical URL, OG URL and sitemap entry carries a trailing slash. Configure your host to serve directory indexes and, if it offers a choice, to keep trailing slashes rather than stripping them. A host that redirects /about/ to /about puts every canonical URL one redirect away from the page it names.
The 404. dist/404.html is built. Most static hosts pick it up automatically; some need it named explicitly.
Nothing else is required. There is no build plugin, no adapter package, no runtime.
Before you deploy
Seven items. The first five are minutes of work; the last two are decisions.
1. Set SITE_URL to your production domain, in your host’s environment variables. See above.
2. Replace public/og.jpg with a real 1200×630 social image. The shipped file is a placeholder, and its dimensions are what BaseHead claims in og:image:width/height for every page that has no image of its own.
3. Fill in src/config/siteData.json.ts — name, title, description, the author block, and the public contact email and phone the footer renders. The shipped values are Your Name, [email protected] and +1 (000) 000-0000.
4. Replace the legal copy in src/config/legalData.json.ts. It is placeholder text that announces itself as placeholder text in its own intro paragraph. Have it reviewed by a qualified professional; it is not legal advice.
5. Replace the favicons — public/favicon.svg and public/favicon.ico.
6. Resolve the eight nav rows that 404. src/config/navData.json.ts ships the design’s full information architecture so the header and footer look complete out of the box. /careers/, /process/ and the six /services/<slug>/ targets have no pages. Build them or delete the rows. Until you do, every page on the site links to eight dead URLs — a real SEO and UX cost, not a cosmetic one. Deleting a row is a one-line edit; note that the services array is also what the /services/ tab rail reads, so remove an entry there only if you genuinely do not offer that service.
Worth checking at the same time: navData.social ships six placeholder profile URLs (https://x.com/yourhandle and friends). Those are the source of the sameAs array in your Organization structured data, so a made-up URL here ships a made-up claim to search engines. Drop the rows you do not have rather than leaving them pointing at nothing.
7. Delete the dev catalog — src/components/Sections/UiCatalog/ and src/pages/examples/, once you no longer need the showroom. It builds no pages in production (getStaticPaths returns [] when import.meta.env.PROD), but Tailwind still scans its markup, so its demo classes sit in the stylesheet every real page loads.
Measured on the shipped theme: removing both takes the shared BaseLayout stylesheet from 103,249 to 84,802 bytes (−17.9%) and drops the @keyframes count from 93 to 2. Eighty-eight of the ninety-three keyframes in the production bundle are referenced by no built page.
Keep it while you are still choosing primitives — the cost is CSS, not JS, and it is the fastest way to see all 46. Re-measure after your own sections land; the saving moves with them.
Do not try to fix this with
@source not "…/UiCatalog". It was tried and reverted in a theme built from this codebase.@sourcerules are not build-mode conditional, so the same directive strips the demo rules inastro devtoo and the catalog silently stops animating — removing the exact check the Motion chapter tells you to run. Deleting the directory is the remedy, and it needs no configuration.
Verify the output
After a build, confirm the artifacts in dist/ directly rather than trusting the console:
pnpm build
ls dist/robots.txt dist/llms.txt dist/rss.xml dist/sitemap-0.xml dist/404.html
Four things are worth eyeballing once:
dist/sitemap-0.xmllists only indexable, trailing-slash URLs. Thefilterinastro.config.mjsexcludes/examples/and/404/, because both setnoindexin their markup and a sitemap entry would contradict that.dist/robots.txtanddist/llms.txtcarry absolute URLs on your real domain. If they sayexample.com,SITE_URLdid not reach the build.- The JSON-LD in any page’s
<head>is a@graphwith matching@ids. Paste it into Google’s Rich Results Test. dist/blog/<slug>/index.htmlcarriesog:type=articleplusarticle:published_time, and aBlogPostingnode in the graph.
The full pre-flight
pnpm lint && pnpm check && pnpm build && pnpm test
Run all four. pnpm build is the one that catches content-schema and config errors, pnpm check catches type errors across .astro files that ESLint does not, and pnpm test runs the eleven self-checks. See Commands & Testing.
A note on view transitions and inlined scripts
astro.config.mjs sets vite.build.assetsInlineLimit: 0. That is intentional and should stay: inlined short scripts break under <ClientRouter /> view transitions. If you raise it to reduce request count, expect interactive primitives to stop re-initialising after a navigation.