Render Tutorials
Web service or static site?

Static sites - when and how

⏱ 7 min

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:

render.yaml
services:
- type: web
runtime: static
name: my-frontend
buildCommand: npm ci && npm run build
staticPublishPath: dist

Build command and publish path by framework

Plug in the row that matches your framework:

FrameworkbuildCommandstaticPublishPath
Vite (React/Vue/Svelte)npm ci && npm run builddist
Create React Appnpm ci && npm run buildbuild
Next.js (static export)npm ci && next buildout
Nuxt (nuxt generate)npm ci && nuxt generate.output/public
Astro (no SSR adapter)npm ci && astro builddist
Gatsbynpm ci && gatsby buildpublic
Docusaurusnpm ci && npm run buildbuild
Hugohugo --minifypublic
Jekyllbundle 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:

render.yaml
services:
- type: web
runtime: static
name: my-frontend
buildCommand: npm ci && npm run build
staticPublishPath: dist
routes:
- type: rewrite
source: /*
destination: /index.html

Walk 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:

render.yaml
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.html

type: 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:

render.yaml
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

render.yaml
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.com

A few things worth pointing out:

  • The envVars block is build-time only for static sites. Vite reads VITE_API_URL at build time and bakes the value into the bundle. There’s no process at runtime to read env vars from.
  • pullRequestPreviewsEnabled: true enables PR previews. (You can also use the modern previews.generation from the advanced Blueprint patterns tutorial - both work.)
  • No plan, no port, no healthCheckPath. 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_URL is baked into the bundle at npm 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.

You deploy a Vite-built React SPA as a Render static site. Refreshing `/dashboard` returns a 404. What's missing?

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`