Every page we serve falls into one of four buckets, and they want opposite things from a cache:

  • Marketing pages never change between deploys.
  • Blog articles change when someone hits publish in the CMS.
  • Tenant storefronts change when a seller edits a product — and must never be cached for the seller themselves.
  • The logged-in app must never be cached at all.

The obvious move is to split them: blog.grouptizer.com, stores.grouptizer.com, app.grouptizer.com. Separate zones, separate rules, no interference. We put all four on grouptizer.com instead.

What I want to be precise about is that this was not one decision applied consistently. It was two unrelated decisions that happened to produce the same layout, and conflating them is how you end up cargo-culting the wrong one.

Contents

Decision one: public content, for authority

Search engines accumulate signals — links, trust, topical relevance — and those signals attach to a host. A blog on blog.grouptizer.com builds authority for blog.grouptizer.com. Put it on grouptizer.com/blog and it builds authority for the domain that also serves your product pages.

Google's official position is that it handles subdomains and subfolders equally well, and I have no evidence that contradicts it directly. What I have is what everyone has: a long tail of migration case studies where moving a blog from a subdomain into a subfolder was followed by ranking improvements, plus near-universal agreement among practitioners that subfolders consolidate better. Weak evidence individually, hard to dismiss collectively.

For a domain with almost no authority to begin with, the calculus is easy. Splitting what little exists across three hosts is the one thing we definitely should not do.

The rule is not "everything on one domain" — it is purpose decides. Content meant to attract traffic goes on the main domain. Content that only serves existing users does not: our documentation lives on docs.grouptizer.com precisely because it is not trying to rank, is fully static, and sits on free static hosting. It gains nothing from our crawl budget and would dilute nothing by leaving.

Decision two: the app, for the repo

The app has no SEO argument. Nothing behind a login gets indexed, so authority consolidation is irrelevant to it. On SEO grounds alone, app.grouptizer.com would have been fine.

It stayed on the main domain for a much more boring reason: splitting the domain would have meant splitting the repository.

Public marketing pages and the private dashboard are one Nuxt project sharing one component library, one design system, one set of composables, one build. Moving the app to its own host means two projects. Which means every shared component becomes a package — extracted, versioned, published, installed, and kept in sync across two repos. It means two build pipelines and two deploys, and a change to a button now touches three places.

That is a real, permanent tax on every future change, paid to solve a problem we do not have. So the app stayed put, and we absorbed the cost on the caching side instead — which is most of the rest of this post.

Both costs are real. The difference is that the caching cost is paid once, in config, and the repo-splitting cost is paid forever, by every feature.

Three routing regimes, one router

Before the caching, the routing — because they interact more than you would expect.

We ship in more than one language — Spanish was the first locale after English, and it will not be the last — and the right URL strategy turns out to be different for each of the three surfaces.

Marketing and blog pages get real localized routes — and not the ones the i18n module would have generated. The module runs in no_prefix mode, which means it produces no localized routes at all: it only manages which locale is active at runtime. Every prefixed URL on this site is a file on disk:

app/pages/
├── index.vue                              → /
├── pricing.vue                            → /pricing
├── telegram-subscription-bot.vue          → /telegram-subscription-bot
├── blog/index.vue                         → /blog
└── es/
    ├── index.vue                          → /es
    ├── pricing.vue                        → /es/pricing
    ├── bot-de-suscripciones-para-telegram.vue
    └── blog/index.vue                     → /es/blog

Enter fullscreen mode Exit fullscreen mode

That is file-based routing doing the work an i18n strategy usually does, and it buys one thing the strategies cannot: the localized slug is a different word, not the English one behind a prefix. prefix_except_default would have given us /es/telegram-subscription-bot — the URL exists, but the keyword in it is in the wrong language for the market it targets. Here the path itself is translated.

The cost is honest and it is duplication: every one of these pages exists once per locale. Worth it for the handful that carry the search traffic; unworkable if the whole site needed it, which is exactly why the whole site does not do it.

Then each of those pages pins its own language:

definePageMeta({ forceLocale: 'es' })

Enter fullscreen mode Exit fullscreen mode

A global middleware reads that and switches the locale before the layout renders, so nothing paints in the wrong language first.

The part that is easy to miss: the English pages declare it too. With no_prefix, the active locale comes from a cookie, so a returning visitor whose cookie says otherwise would open /pricing and get an English URL rendering in another language — fine until a crawler sees it, or until they share the link. Pinning is needed in every direction, not just on the translated pages. The rule is not "localized pages force their locale", it is "every indexable page owns its language, and the cookie does not get a vote".

Everywhere else, the cookie is exactly the right mechanism. Which is the next two cases.

Storefronts get no prefix at all. A storefront lives at /{storeUrl} in every language, and the reason is that the page's language is not ours to decide. The content is whatever the seller wrote — product names, descriptions, policies. Only the surrounding chrome, a handful of buttons, is translated.

Serving that store under a locale prefix would be a lie: the URL would promise one language for a page whose body is in whatever language the seller happens to write in. And it would fragment the cache — one URL per locale, all serving a byte-identical HTML body, dividing the hit rate by the number of languages we support for no gain at all.

There is a plainer reason too, and for the people using the product it is probably the one that matters most. This URL is not a route, it is an address our sellers hand out: pasted into a Telegram bio, dropped in a message, read aloud on a video. It is the shortest thing standing between someone hearing about a community and paying for it. Two extra characters of routing metadata in front of the name make it longer, uglier and slightly harder to trust — and they encode a fact the recipient does not care about. A link that exists to be shared should carry nothing but the name of the thing it points to.

The app gets no prefix either. There is nothing to index; language is a user preference, and it belongs in a cookie, not in a URL.

So the split is: the module owns the locale, the filesystem owns the routes. Anything that needs to be found by a search engine gets a real file and pins its language; anything that only needs to be readable follows the cookie.

Sending a visitor to their own language, from a page that is cached

A visitor whose browser is set to a language we publish in should land on that version. The obvious implementation is a server-side redirect on Accept-Language, and it is unavailable to us: / is prerendered and cached at the edge. A 302 served from a cached document is a 302 for everyone, whatever their language. It would also be the wrong thing for crawlers, which would get bounced around instead of seeing two independent pages.

So the HTML stays identical for every visitor, and the decision moves to the only place that knows who is asking — the browser. It is a blocking inline script in the <head> of /:

(function () {
  try {
    if (document.cookie.indexOf('landing_locale_checked=') !== -1) return
    document.cookie = 'landing_locale_checked=1;path=/;max-age=31536000;samesite=lax'
    var l = (navigator.language || '').toLowerCase()
    if (l === 'es' || l.indexOf('es-') === 0) window.location.replace('/es')
  } catch (e) {}
})()

Enter fullscreen mode Exit fullscreen mode

Every line of that is load-bearing:

  • Inline and blocking, in the <head> rather than a framework plugin — it runs before first paint, so the visitor never sees a flash of English before the jump.
  • navigator.language, not the Accept-Language header. The header is only readable on the server, and the server has nothing per-visitor to say here. The browser knows.
  • location.replace, not assign — the English page never enters history, so the back button does not drop you into a redirect loop.
  • The one-year cookie makes it fire once, ever. Without it, someone who deliberately opens / gets yanked away every time, and the English page becomes unreachable to an entire market.
  • It scales without changing shape. The comparison is against one locale today because that is how many we have published beyond English. With more, the branch becomes a lookup against a list — the mechanism, the cookie and the caching consequences all stay exactly as they are.
  • Crawlers are unaffected. Googlebot's navigator.language does not match any of our locales, so the branch never fires. This is the part I would not have taken on faith — Googlebot does execute JavaScript and does follow client-side redirects — so it is worth saying that we checked: both URLs report impressions independently in Search Console, with no sign that one is swallowing the other. A server-side redirect would have hidden one of them.

The general shape is worth keeping: cache the same bytes for everyone, and let the client apply what only the client knows. Per-visitor behaviour and edge caching are not incompatible — they just cannot both live on the server.

The four surfaces

Nuxt with Nitro behind Cloudflare. The global default is set once, then overridden per route:

const CACHE_CONTROL_PUBLIC =
  'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800'

routeRules: {
  // Everything is cacheable at the edge by default...
  '/**':       { headers: { 'Cache-Control': CACHE_CONTROL_PUBLIC } },
  // ...except the two things that must never be.
  '/api/**':   { headers: { 'Cache-Control': 'private, no-store' } },
  '/_nuxt/**': { headers: { 'Cache-Control': 'public, max-age=31536000, immutable' } },
}

Enter fullscreen mode Exit fullscreen mode

max-age=0, s-maxage=86400 is the important pairing: the browser revalidates every time, the CDN holds the response for a day. That split is what makes purging viable — there is only one place to invalidate.

1. Marketing pages: prerender

'/':        { prerender: true },
'/pricing': { prerender: true },

Enter fullscreen mode Exit fullscreen mode

Static HTML at build time, real content in the initial response. Nothing clever.

2. The app shell: prerender the empty page

'/dashboard': { ssr: false, prerender: true },
'/orders':    { ssr: false, prerender: true },
'/account':   { ssr: false, prerender: true },

Enter fullscreen mode Exit fullscreen mode

This is the one that surprises people. These routes are behind auth — there is nothing to cache, right?

There is: the shell. ssr: false means the route renders client-side, so the HTML document is byte-identical for every visitor. It contains no user data; the data arrives afterwards from /api/**, which is no-store. So we prerender that empty document and let the CDN serve it as a static file.

You can watch it work. Request /dashboard from a cold edge location and then again:

req 1:  cf-cache-status: MISS
req 2:  cf-cache-status: HIT   age: 11
req 3:  cf-cache-status: HIT   age: 13

Enter fullscreen mode Exit fullscreen mode

Be precise about what this buys, because it is easy to oversell. The MISS still reaches our origin — there is no separate static host in front, just Cloudflare and a VPS. What it saves is the work: a MISS costs a file read instead of a server-side render, with no session to resolve, no data to fetch and nothing to compose. After that, the edge answers.

Authentication happens on the API, not on the document.

3. The blog: stale-while-revalidate

Articles come from a headless CMS, so we cannot prerender them: a new article should appear without a rebuild, and the Docker build has no network route to the CMS anyway.

'/blog':    { swr: 3600, headers: { 'Cache-Control': 'public, max-age=0, must-revalidate, s-maxage=3600' } },
'/blog/**': { swr: 3600, headers: { 'Cache-Control': 'public, max-age=0, must-revalidate, s-maxage=3600' } },

Enter fullscreen mode Exit fullscreen mode

Nitro renders an article once, caches it, and after the TTL serves the stale copy while revalidating in the background. Publishing also fires a purge webhook, so freshness never actually waits for the TTL.

Note what is not in that header: stale-while-revalidate. It is in the global default, and here it is deliberately replaced by must-revalidate. With stale-while-revalidate in the response, browsers serve their own stale copy from disk while they revalidate — so you would open a freshly published article, see the previous version for an instant, and watch it swap. Per RFC 5861, stale-while-revalidate does not apply alongside must-revalidate, so forcing revalidation removes the browser's licence to do that. The CDN still serves stale; that part we want. The browser asks every time and gets a cheap 304 from the edge.

4. Tenant storefronts: one URL, two audiences

Each seller gets a storefront at /{storeUrl}, server-rendered and cached at the edge. It is their landing page — its TTFB is their conversion rate — so commercially this is the surface where caching matters most.

Everything described below is observable, if you would rather check than take my word for it. Our demo store is at grouptizer.com/IronPath — read the response headers:

cache-control: public, max-age=0, s-maxage=86400, stale-while-revalidate=604800
cf-cache-status: HIT
age: 159

Enter fullscreen mode Exit fullscreen mode

A public Cache-Control with s-maxage, a HIT with an age counting up, and — the part you have to notice by its absence — no Set-Cookie. All three of those were broken at some point in the story below.

The complication is that one URL serves two people. A buyer sees the public store. The owner, logged in, may be previewing a store that is not published yet, and that response contains viewer-specific state. Caching it would be a data leak dressed as a performance win.

So the header is decided at render time, not in config:

export function useStoreLandingCacheControl(data: { isOwnerPreview?: boolean }) {
  if (!import.meta.server) return
  const event = useRequestEvent()
  if (!event) return

  event.node.res.setHeader(
    'Cache-Control',
    data?.isOwnerPreview
      ? 'private, no-store'          // owner previewing their own store
      : useRuntimeConfig().cacheControlPublic,
  )
}

Enter fullscreen mode Exit fullscreen mode

One route, two cache policies, chosen per request.

The storefront cache had two bugs: one leaked, one never cached

That no-store for owner previews is necessary and it is not what protected us. The two real problems were elsewhere, and they are the most useful part of this post.

It cached one visitor's identity and served it to everyone

Storefronts are server-rendered, and the SSR payload — window.__NUXT__ — is part of the HTML document. Our auth library was configured with initialRequest: true, which means the server resolved the current user during SSR. That put the user object into the serialized state: name, email, id, menu.

Now cache that document.

The identity of whichever logged-in visitor happened to cause the cache MISS was baked into the HTML, and the CDN handed it to every subsequent visitor. Not a flicker in the navbar — a data leak.

What makes this one worth internalising is why it was invisible. The navbar was already wrapped in <ClientOnly>, so nothing rendered. Visually it was correct. <ClientOnly> protects the DOM, not the payload — the state is still serialized into the document, sitting in plain text under a <script> tag, whether or not any component rendered it.

The fix was to stop resolving identity on the server at all:

sanctum: { client: { initialRequest: false } }

Enter fullscreen mode Exit fullscreen mode

The server never calls /me, so the payload is anonymous and genuinely cacheable. Identity resolution moves entirely client-side, into one idempotent composable used from two places: a global route middleware for client-side navigations, and an app:mounted plugin for hydration of SSR pages, where that middleware does not re-run. The middleware awaits it, because route guards depend on the answer. The plugin deliberately does not — a public storefront must not delay its interactivity waiting for /me, and the navbar is reactive, so it fills in when the answer arrives. Both share a flag, so /me is fetched exactly once per load.

The app never needed server-side identity in the first place: authenticated pages are ssr: false, and the navbar resolves auth in the browser anyway.

It was not caching anything at all

The second bug is less alarming and more embarrassing. Cloudflare bypasses the cache when a response carries Set-Cookie, no matter how public your Cache-Control is.

Our storefronts were being served with an anonymous Laravel session cookie proxied through Nuxt, plus the i18n cookie. Every visit: cf-cache-status: BYPASS. All that cache configuration, and the edge was forwarding everything to the origin.

Getting rid of the cookie took three attempts, and the wrong turns are the instructive part:

  1. Delete it from response.headers. No effect. The cookies were not there.
  2. Find out where they actually are. In SSR the auth client's proxyResponseHeaders interceptor re-injects Laravel's session cookie straight into the h3 event via setResponseHeaders(event, …) — so it lives on event.node.res, not on the response object you were handed. removeResponseHeader(event, 'set-cookie') is what works.
  3. Discover the blog was still leaking cookies. The strip is guarded by "is this response cacheable", tested by looking for s-maxage in the Cache-Control. On blog routes that test failed — because Nitro's SWR handler applies its Cache-Control after the render:response hook, so at guard time there was no s-maxage to find. Same hook-ordering trap as the next section, hit from a different direction.

Two details worth stealing. Using the response's own Cache-Control as the criterion for stripping cookies, rather than a list of routes, means new public routes are covered automatically — and it is what keeps the owner-preview responses, which are private, no-store, holding on to their cookies exactly as they should. And a cache that silently degrades to BYPASS looks identical to a working one from the browser. The only way to know is to read cf-cache-status.

Where your headers actually come from

Here is the part that cost the most time, and it is not about cache policy. It is about which layer gets the last word.

Those Cache-Control headers declared in routeRules for the blog do not reach the browser in production. Nitro applies route-rule headers early in the request. The SWR cache handler stores its own cache-control in the cache entry and writes it back onto the response when it serves that entry — after the route rule, so it wins. What browsers were receiving had no max-age at all, which invites heuristic caching, which is where the flicker came from.

The obvious fix is a hook that stamps the right header. The non-obvious part is which hook:

  • render:response fires before the cache handler writes its header. Too early; you get overwritten.
  • beforeResponse fires in onBeforeResponse, after the cache handler and just before the bytes go out. That one works.
nitroApp.hooks.hook('beforeResponse', (event) => {
  if (event.node.res.headersSent) return
  if (!isBlogSwrPath(event.path)) return
  setResponseHeader(event, 'cache-control', BLOG_CACHE_CONTROL)
})

Enter fullscreen mode Exit fullscreen mode

And then there is the conditional-request problem, which is the genuinely nasty one.

When the browser sends If-None-Match, the cache handler answers it itself, through h3's handleCacheHeaders. It emits a 304 carrying its header — public, max-age=3600, s-maxage=3600 — and calls res.end() before beforeResponse can touch anything.

If a browser receives that, fine, it is one stale hour. The problem is when Cloudflare receives it while revalidating. Under RFC 9111 a cache that gets a 304 must merge the new headers into its stored entry. So the edge quietly adopts max-age=3600 and starts handing it to every browser after it — and now nobody revalidates for an hour. One 304 at the wrong moment poisons the policy for everyone downstream.

The fix is to make sure the origin never emits that 304 at all:

nitroApp.hooks.hook('request', (event) => {
  if (!isBlogSwrPath(event.path)) return
  delete event.node.req.headers['if-none-match']
  delete event.node.req.headers['if-modified-since']
})

Enter fullscreen mode Exit fullscreen mode

Strip the conditionals, always answer 200 with the correct header. It costs one full body per TTL expiry per colo, which is nothing. Browser-to-Cloudflare 304s are unaffected — the edge still handles those.

The honest cost of this approach: isBlogSwrPath() restates, in TypeScript, which paths the route rules match. Two lists that have to agree, kept in sync by hand. Add a locale to the route rules and forget the plugin, and that locale silently loses its header in production — the flicker comes back on those pages only, and nothing tells you. I have not found a way around it that is better than knowing about it.

When to purge

Cached storefronts are only useful if they update when a seller edits something. Model observers call a purge service, which queues a job against Cloudflare's purge_cache endpoint. Three details worth stealing:

Chunk to 30 URLs per request — the per-call limit on the Free and Pro plans.

Deduplicate within the request. Reordering N products saves N models and fires N observers, all asking for the same landing page. Without a per-request seen-set you queue the same purge a dozen times.

Do not purge before the write commits. A purge fired inside a transaction that later rolls back invalidates the cache for a change that never happened. The idiomatic fix is DB::afterCommit(), which does not work for us: our MongoDB driver's transactions do not register with the framework's transaction manager, so the callback fires immediately instead of being deferred. We buffer purges in a wrapper and flush them only if the closure returns without throwing.

(That driver quirk has a second consequence worth knowing if you are on the same stack: multi-document transactions need a replica set at all, so production runs a single-node one — replication you do not want, as the price of atomicity you do.)

And the one that took longest to see coming: the invalidation graph is not the object graph.

Storefronts inline their top reviews, and each review inlines its author's avatar and name. So changing your avatar has to purge every storefront where you have ever left a review. Nothing in the storefront model references the user model — the dependency exists only in the rendered output. Those edges are invisible in your schema and obvious in your templates.

The rule that cached our unpublished drafts

The CMS previews drafts at /blog/preview. Access is gated: the route takes a shared secret issued by the CMS, compared server-side in constant time, and it is served noindex, nofollow — authorization is not the cache's job and never was.

What the cache still has to get right is that a draft response is not storable. It changes on every keystroke in the editor, and the whole point of a preview is to show the newest version. So we disabled SWR for it:

'/blog/preview': { swr: false }

Enter fullscreen mode Exit fullscreen mode

That does nothing.

Drafts were being served from cache. cf-cache-status: HIT on a preview route.

To be precise about the damage, because it matters: this was not drafts leaking to the public — the secret still gated who could ask. It was an editor opening a preview and being shown a version of their own draft from some minutes ago, with no indication that anything was stale. Which is a bug that wastes an afternoon before anyone suspects the cache, rather than one that ends up in a disclosure.

At startup, Nitro decides whether to wrap each handler in cachedEventHandler by looking up getRouteRulesForPath(path).cache, and that lookup merges every matching rule with defu. swr: false does not normalize to cache: false — it normalizes to the absence of a cache key. Absence merges as "no opinion", so { swr: true, maxAge: 3600 } from the broader /blog/** rule flowed straight in.

swr: false does not mean "do not cache this route". It means "I am not asking for SWR" — a different statement, and a losing one when a more general rule is asking for it.

'/blog/preview': {
  swr: false,
  cache: false,                                      // this is the line that matters
  headers: { 'Cache-Control': 'private, no-store' },
},

Enter fullscreen mode Exit fullscreen mode

Two things made it hard to catch. It only reproduces in a production build — npm run build && npm run start — because SWR is disabled entirely in dev, where the filesystem cache driver has a separate problem: nested blog routes collide, since /blog wants to be a file and /blog/{slug} needs /blog/ to be a directory, so you get ENOTDIR. And a cached draft looks completely normal. It is only wrong if you know what the draft currently says.

We found it while working on something unrelated.

What I would tell past me

  • Separate the decisions. "Same domain for SEO" and "same domain because splitting the repo is expensive" are different arguments with different evidence, and only one of them is about search engines.
  • URLs are for things that need to be found. A language prefix on a page whose language you do not control is a promise you cannot keep.
  • Per-visitor behaviour and edge caching are compatible — as long as the per-visitor part runs in the browser. Ship identical bytes, decide on the client. This turned out to be the whole thesis: it is the same answer for the locale redirect, for the app shell, and for user identity.
  • Hiding a component does not remove its data. <ClientOnly> guards the DOM; the SSR payload is a separate surface, and it is the one an edge cache stores.
  • Before tuning a cache, prove it is a cache. A response with Set-Cookie is a BYPASS, and BYPASS is indistinguishable from HIT unless you look at the status header.
  • A CDN can serve your authenticated app's shell. Auth belongs on the API, not on the document.
  • Negative config options are not always the negation you think. false and absent merge very differently.
  • Know which layer writes the header last. Your framework's cache handler probably outranks your config.
  • Test cache behaviour on a production build. Dev mode is a different program.
  • The things that must be invalidated together are defined by your templates, not your schema.

One thing I would genuinely like to hear about, since every answer I found was someone asserting a preference rather than showing evidence: if you have moved a blog between a subdomain and a subfolder, what actually happened to your traffic? Direction, rough magnitude, how long before it settled. And if you did it and nothing changed, that is worth saying too — the null results never get written up, which is exactly why the question is still unsettled.


I build Grouptizer. The storefronts in this post are the pages our sellers use to charge for access to their private communities, which is why their cache behaviour is not an academic concern for us.