Most sites are dynamic out of habit rather than need. A page that changes a few times a day gets rebuilt from a database on every single request, which drags in an application server, a connection pool, and a scaling headache, all for content that could have been a plain file on disk. Static-first flips that default. Render the page once, cache it everywhere, and reach for a server only when the request truly cannot be answered ahead of time. We build this way on purpose: the main HOUSE603 site and this one are both single files served from the edge, which is most of why they load the instant you click.
The shape of a static-first stack
- Build step renders pages to HTML with hashed asset filenames.
- CDN edge serves those files from the location nearest each visitor.
- Thin dynamic layer, an edge function or a small API, handles the genuinely per-user parts such as search, forms, or a cart.
Knowing when not to do this matters just as much. If most of a page is unique to each logged-in user and changes on every view, you are fighting the model, and honest server rendering is the right call for that page. Static-first is for the large majority of pages that look the same for everyone, which is more of your site than you probably think: marketing, docs, blog, product listings, most of a dashboard's shell.
Cache-control is where the speed comes from
Caching is the whole reason static-first is fast, so it is worth getting exactly right, and it comes down to two classes of asset with two policies. Fingerprinted assets, the ones whose filename carries a content hash, cannot change without changing their name, so you cache them effectively forever. HTML does change, so you let the CDN revalidate it while it keeps handing back the last good copy the instant anyone asks:
# Immutable, hashed assets: app.9f2a1c.js, styles.4b8e.css
Cache-Control: public, max-age=31536000, immutable
# HTML: fast, but always fresh behind the scenes
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400
The s-maxage line tells the CDN to hold the page for ten minutes. The stale-while-revalidate line is the part people miss: for a full day after that, the edge is allowed to hand back the slightly stale page immediately while it fetches a fresh copy in the background. Readers never sit waiting on your origin, and your origin barely gets touched. The one tax you pay is cache invalidation, so when you need a change out now rather than in ten minutes, purge the specific paths through your CDN's API as the last step of the deploy.
A minimal edge-friendly origin
Whatever sits behind the CDN should be boring and correct. An nginx origin for a static bundle is a dozen lines:
server {
listen 443 ssl http2;
root /var/www/site;
# hashed assets never change
location ~* "\.[0-9a-f]{6,}\.(js|css|woff2|png|svg)$" {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# html revalidated, SPA-style fallback
location / {
add_header Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400";
try_files $uri $uri/ /index.html;
}
}
Keep the dynamic parts honest
Put anything that truly runs per request behind its own path, say /api/, so it scales, fails, and gets rate-limited on its own without ever taking the cached site down with it. If the API has a bad day, the pages still load from the edge and the site degrades to read-only instead of going dark. On the client, show what you already have instantly and refresh it in place:
async function load(url) {
const cached = sessionStorage.getItem(url);
if (cached) render(JSON.parse(cached)); // show instantly
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
const data = await res.json();
sessionStorage.setItem(url, JSON.stringify(data)); // refresh in place
render(data);
}
Ship the security headers for free
A static origin makes a strict Content-Security-Policy genuinely achievable, because you control every script that loads and nothing injects markup at runtime. Set HSTS, a tight CSP, X-Content-Type-Options: nosniff, and a sensible frame policy at the edge, and you have closed off whole categories of attack that dynamic apps spend real effort fighting. There is no database in the request path to inject into and no server-side template to trick.
What you get
What you end up with is pages that render in well under a second on a mid-range phone, an origin that shrugs off a traffic spike because the edge soaks it up, and a bill that barely moves as you grow. There is no trick to it. The discipline is just to make yourself justify every dynamic endpoint instead of assuming one, and most of the time you will find you did not need it.