Point0by 1gr14
  • Menu
    • GitHub
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Home
    • Start0
    • Support
    • Author
  • Community
    • Discord
    • Telegram
  • Open Source
    • Point0
    • Route0
    • Error0
    • Flat
  • Account
    • Sign In
    • Sign Up
Point0by 1gr14
Building open-source software for the glory
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️
Terms of ServicePrivacy PolicySergei Dmitriev 2026 😎

Cache-Control

  • Category: Extra

On this page

  1. The asset / publicdir split
  2. An existing header always wins
  3. Tuning the slots
  4. override — the top-level escape hatch
  5. Pairing with compress
  6. Reference
  7. cacheControl(options)
  8. Behavior at a glance

@point0/cache-control sets the Cache-Control header. Point0's handlers leave it unset, so browsers and CDNs are left to guess how long a response may live. cacheControl(options) returns a Point0 middleware: mount it on your root and every response variant gets a correct value.

import { Point0 } from '@point0/core'
import { cacheControl } from '@point0/cache-control'

export const root = Point0.lets
  .root()
  .middleware(cacheControl()) // ← every response gets a correct Cache-Control
  // ...
  .root()

The bare cacheControl() already does the right thing:

VariantDefaultWhy
assetpublic, max-age=31536000, immutablecontent-hashed URLs (build chunks, /_point0/assets/*) can never serve different bytes — cache forever
publicdirpublic, max-age=3600stable names, mutable content (favicons, robots.txt, …) — cache briefly, revalidate
pageprivate, no-storeSSR HTML embeds the dehydrated store — the current user's data — a shared cache must never store it
errorprivate, no-storeerror HTML is rendered the same way
endpoint— (untouched)API caching is the endpoint's own decision — set it in the handler when you want it

The page/error default is a safety property, not a taste: a misconfigured CDN in front of an app whose HTML has no Cache-Control can cache one user's server-rendered page — with their data dehydrated into it — and serve it to the next user. private, no-store closes that door by default.

The asset / publicdir split

The engine itself classifies every statically served file into two request variants by what the URL promises. An asset is a file whose name carries a content hash — a bundler chunk of the built client (including the entry) or the asset pipeline's /_point0/assets/<hash>.<ext>. The build persists the exact list of those files into _point0/<scope>/build-assets.json, and the server classifies against it — so the cache-forever set is exact, never guessed from file name patterns. Everything else the publicdir serves keeps a stable name and stays publicdir.

That's why the middleware needs no path regexes and no isImmutable callback: the framework already knows which of its files are immutable.

An existing header always wins

cacheControl() never overwrites a Cache-Control the app already set — an auth action's or a download's no-store survives untouched:

.loader(({ set }) => {
  set.headers('Cache-Control', 'no-store') // ← cacheControl() will not touch this response
  // ...
})

Tuning the slots

Every option slot takes a header string (set it) or undefined (skip — leave the response's header untouched). The built-in default applies only when you leave the slot out; passing the slot — even as undefined — means you're taking control:

cacheControl({
  page: 'public, max-age=60', // a fully public site may let its HTML be cached
  publicdir: undefined, //       skip static files — don't set any Cache-Control
})

Deleting a header (false) is not a slot value — that's an override capability, because it would overrule a header the app set. Slots stay safe: they set-or-skip, and always defer to an app-set header (above).

A callback decides per response. It receives the same detailed result object the middleware got back from next() — response, request, variant (narrowed to the slot's variant), scope, error — and returns a string (set) or undefined (skip). Note undefined skips; it does not fall back to the default — to keep the default from a callback, return it explicitly:

import { cacheControl, cacheControlValues } from '@point0/cache-control'

cacheControl({
  publicdir: ({ request }) =>
    request.location.pathname.endsWith('.pdf')
      ? 'public, max-age=604800' //         PDFs are versioned by hand — cache a week
      : cacheControlValues.revalidate, //   everything else: keep the default explicitly
})

cacheControlValues exports the three default strings (immutable, revalidate, noStore) so callbacks don't retype header values.

override — the top-level escape hatch

The slots only cover the five variants above, and they always defer to a Cache-Control the app already set. override sits above both: it runs for every response and above the existing-header rule. It returns a string (set it, overwriting any existing value), false (delete the header), or undefined (fall through to the normal per-variant procedure).

cacheControl({
  // maintenance window: force no-store on everything, even responses that set their own header
  override: () => 'no-store',
})

Two things only override can do: reach variants without a slot (middleware, options), and overrule a header a handler set. Because it bypasses the existing-header rule, an override that wants to respect an app-set header checks result.response.headers itself:

cacheControl({
  override: ({ response, request }) =>
    request.location.pathname.startsWith('/downloads/') &&
    !response.headers.has('cache-control')
      ? 'private, max-age=0'
      : undefined, // everything else: normal procedure
})

Pairing with compress

cacheControl() and compress() compose in either order — compress copies response headers onto the compressed response, so the Cache-Control rides along:

.middleware(compress())
.middleware(cacheControl())

Reference

cacheControl(options)

Every option is optional; cacheControl() with no argument is the correct default for a typical app. Each slot is string | undefined | (result) => string | undefined | Promise<...> (set / skip — no false; deletion is override-only).

OptionDefaultApplies to
assetpublic, max-age=31536000, immutablecontent-hashed build files
publicdirpublic, max-age=3600stable-name static files
pageprivate, no-storeserver-rendered page HTML
errorprivate, no-storeerror responses
endpointuntouched (unconfigured)query/mutation/action endpoints
override— (runs for every variant)top-level escape hatch: string sets (overwriting), false deletes, undefined defers

Behavior at a glance

AspectBehavior
Sidecut from the client bundle — the .middleware(...) argument and its imports are removed, so it never ships to the browser
Existing headera Cache-Control already on the response always wins — unless override overrules it
Other variantsmiddleware / options have no slot; only override can set them
Slot valuestring sets, undefined skips; the built-in default applies only when the slot is left unconfigured
Callback undefinedskips (does not fall back to the default — return the default explicitly to keep it)
Streamed endpointsa defer()-streamed NDJSON response already carries private, no-store from the framework, so the existing-header rule leaves it alone — an endpoint policy only ever caches the non-streamed variant
PreviousCORSNextCompress

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter