A Render static site takes two inputs:
- A build command that produces a folder of files.
- A publish path pointing at that folder.
Render runs the build once on a build host, copies the output to its global CDN, and you’re done. No process, no port, no runtime config. The whole “service” is a directory of files served with HTTP/2 and Brotli compression.
What you actually configure
In the Render Dashboard, two fields:
- Build Command -
npm ci && npm run build(or your framework’s equivalent) - Publish Directory -
dist,build,public, etc.
In a Blueprint:
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: distBuild command and publish path by framework
Plug in the row that matches your framework:
| Framework | buildCommand | staticPublishPath |
|---|---|---|
| Vite (React/Vue/Svelte) | npm ci && npm run build | dist |
| Create React App | npm ci && npm run build | build |
| Next.js (static export) | npm ci && next build | out |
Nuxt (nuxt generate) | npm ci && nuxt generate | .output/public |
| Astro (no SSR adapter) | npm ci && astro build | dist |
| Gatsby | npm ci && gatsby build | public |
| Docusaurus | npm ci && npm run build | build |
| Hugo | hugo --minify | public |
| Jekyll | bundle exec jekyll build | _site |
| 11ty (Eleventy) | npm ci && npx @11ty/eleventy | _site |
If you’ve customized your config (a different output dir, a monorepo subpath), check the framework’s build output, not this table. The rule is “whatever your framework writes to disk during the build, plus a leading-relative path from the repo root or rootDir.”
The SPA fallback rule
If you’ve ever deployed a React app, refreshed /dashboard, and gotten a 404 - this is why. The CDN looks for a file at /dashboard, doesn’t find one, and returns 404. Your client-side router never gets a chance to handle it.
The fix is a rewrite rule that serves index.html for everything that’s not an actual file:
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: dist routes: - type: rewrite source: /* destination: /index.htmlWalk it: any request whose path doesn’t match an actual file in dist/ gets rewritten (server-side, before the response) to /index.html. The CDN serves index.html, the JS bundle loads, your client-side router reads window.location.pathname, and everything works.
flowchart LR
user["Browser hits<br/>/dashboard"]
cdn{"File at<br/>/dashboard?"}
index["Serve /index.html"]
router["Client router<br/>reads /dashboard"]
page["Render dashboard view"]
user --> cdn
cdn -->|"yes"| direct["Serve the file"]
cdn -->|"no - rewrite rule"| index
index --> router --> page
You only need this for SPAs (Vite, Create React App, plain React Router, Vue Router, Svelte SPA mode). Static-export frameworks like Next.js, Nuxt’s nuxt generate, Astro, Hugo, and Jekyll write a real HTML file per route, so they don’t need the rewrite.
Specific redirects, before the catch-all
If you’re migrating from an old site, you probably have URLs to redirect. Put redirect rules above the SPA fallback so they take priority:
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: dist routes: - type: redirect source: /old-blog/:slug destination: /blog/:slug - type: rewrite source: /* destination: /index.htmltype: redirect sends a 301 to the user’s browser. type: rewrite serves a different file under the same URL. Two different behaviors, both useful.
Custom response headers
Static sites are perfect for tightening up your security headers - none of your code is going to fight you. Add them via the headers field:
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: dist headers: - path: /* name: X-Frame-Options value: DENY - path: /* name: Strict-Transport-Security value: "max-age=63072000; includeSubDomains; preload" - path: /assets/* name: Cache-Control value: "public, max-age=31536000, immutable"A useful pattern: tight cache headers on hashed asset paths (/assets/index-a1b2c3.js) and short cache on the entry HTML. Vite, CRA, and most modern bundlers hash assets by default - pair that with the snippet above and you get fast subsequent loads plus immediate updates on deploy.
A complete static site Blueprint
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: dist pullRequestPreviewsEnabled: true routes: - type: rewrite source: /* destination: /index.html headers: - path: /* name: X-Frame-Options value: DENY - path: /assets/* name: Cache-Control value: "public, max-age=31536000, immutable" envVars: - key: VITE_API_URL value: https://api.example.comA few things worth pointing out:
- The
envVarsblock is build-time only for static sites. Vite readsVITE_API_URLat build time and bakes the value into the bundle. There’s no process at runtime to read env vars from. pullRequestPreviewsEnabled: trueenables PR previews. (You can also use the modernpreviews.generationfrom the advanced Blueprint patterns tutorial - both work.)- No
plan, noport, nohealthCheckPath. Static sites don’t have a process to health-check.
What you give up by going static
A short, honest list:
- No request-time logic. No middleware, no auth checks, no per-request rendering. All decisions happen at build time or in the user’s browser.
- No private network access. A static site can’t fetch from
auth.onrender.com-style internal hostnames. We deal with this in the hybrid pattern. - No environment variables at runtime.
VITE_API_URLis baked into the bundle atnpm run build. Changing it requires a rebuild. - No persistent disks. A static site is the bundle, full stop.
For most frontends, none of these are real losses - that’s why static sites are the default for SPAs. But if any one of them is a deal-breaker, jump to the next step.
What you learned
- Static sites use `type: web` + `runtime: static` in Blueprints; just `buildCommand` and `staticPublishPath`
- The `routes` block handles SPA fallbacks (`type: rewrite` to `/index.html`) and migration redirects (`type: redirect`)
- The `headers` block sets custom response headers - use it for security and aggressive caching on hashed assets
- Env vars on a static site are build-time only - Vite/CRA bake them into the bundle during `npm run build`