Skip to content
Ben is currently available for contract work for 3D & web solutions — reach out.

Back to Blog Listing

Implementing ETag-based Caching Revalidation for TanStack Start

How to return a cheap 304 Not Modified response in TanStack Start, with three implementations at three different layers of the stack.

Ben Houston • • 5 min read

Rendering a page costs real server resources; checking whether it changed costs almost none. If you can answer "did this change?" with a cheap comparison instead of a full render, the same hardware can serve far more traffic. The same logic also cuts CDN refreshes: when a CDN's cached copy goes stale, it can revalidate with your origin using this same 304 exchange instead of pulling a full new render every time.

ETag is how you make that comparison possible. It's an optional response header — servers don't send it by default, so your server has to add one to a normal response first. Once a browser has seen an ETag for a page, it can ask whether that page changed by sending it back as If-None-Match on the next request. If it still matches, the server replies 304 Not Modified with no body, and the browser reuses its cached copy. No render, no HTML transfer.

On the first response for a page, add two headers:

  1. ETag, a hash of the content — for example ETag: W/"a3f9c1".
  2. Cache-Control with a directive that forces revalidation rather than trusting a local cache indefinitely — for example Cache-Control: no-cache (always revalidate) or Cache-Control: max-age=60, must-revalidate (trust it for 60 seconds, then revalidate).

On every later request for that page, the logic is the same three steps:

  1. Compute the current ETag for this request (the same way it was calculated in step 1 above.)
  2. Compare it to the browser's If-None-Match header. Match → return an empty 304.
  3. No match → the content changed. Render it and stamp the new ETag and Cache-Control on the response, same as the first response above.

What differs is where you run those steps. Here are three places to put it in TanStack Start.

A Shared ETag Helper#

Each example below calls the same tiny helper, computing a hash of the content version and the URL:

// src/server/etag.ts
import { createHash } from 'node:crypto'

export function getEtag(url: string): string {
  const hash = createHash('sha256').update(url).digest('hex')
  return `W/"${hash}"`
}

Real content versioning (deploy commit, CMS revision, etc.) is a separate concern — swap in whatever makes your ETag change when the page's content changes.

Method 1: A Route's Server Handler#

The most localized option: intercept GET on one route before it renders.

// src/routes/blog.$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { setResponseHeader } from '@tanstack/react-start/server'
import { getEtag } from '~/server/etag'
import { getPostFn } from './-getPostFn'

const CACHE_CONTROL = 'public, max-age=60, must-revalidate'

export const Route = createFileRoute('/blog/$slug')({
  loader: ({ params }) => getPostFn({ data: params.slug }),
  component: PostPage,
  server: {
    handlers: {
      GET: async ({ request, next }) => {
        const etag = getEtag(request.url)

        if (request.headers.get('if-none-match') === etag) {
          return new Response(null, {
            status: 304,
            headers: { ETag: etag, 'Cache-Control': CACHE_CONTROL },
          })
        }

        setResponseHeader('ETag', etag)
        setResponseHeader('Cache-Control', CACHE_CONTROL)
        return next()
      },
    },
  },
})

Good for: routes that need their own logic before deciding whether to cache (an existence check, a per-route policy).

Method 2: Global Request Middleware#

Apply the same check to every document request in one place, using TanStack Start's request middleware:

// src/server/etagMiddleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { setResponseHeader } from '@tanstack/react-start/server'
import { getEtag } from './etag'

const CACHE_CONTROL = 'public, max-age=60, must-revalidate'

export const etagMiddleware = createMiddleware({ type: 'request' }).server(
  ({ request, next }) => {
    if (request.method !== 'GET') return next()

    const etag = getEtag(request.url)

    if (request.headers.get('if-none-match') === etag) {
      return new Response(null, {
        status: 304,
        headers: { ETag: etag, 'Cache-Control': CACHE_CONTROL },
      })
    }

    setResponseHeader('ETag', etag)
    setResponseHeader('Cache-Control', CACHE_CONTROL)
    return next()
  },
)

Register it once for the whole app:

// src/start.ts
import { createStart } from '@tanstack/react-start'
import { etagMiddleware } from './server/etagMiddleware'

export const startInstance = createStart(() => ({
  requestMiddleware: [etagMiddleware],
}))

Good for: applying one caching policy site-wide without touching every route file. Routes that need an exception (an admin page, a personalized page) opt out inside the middleware by path, or add their own override in the route's server.handlers.

Method 3: A Nitro Server Route#

TanStack Start runs on Nitro. You can handle 304s a layer below the router entirely, in a Nitro middleware that runs before any TanStack Start route matches:

// server/middleware/etag.ts
import { defineEventHandler, getRequestHeader, setResponseHeader, send } from 'h3'
import { getEtag } from '../../src/server/etag'

const CACHE_CONTROL = 'public, max-age=60, must-revalidate'

export default defineEventHandler((event) => {
  if (event.method !== 'GET') return

  const etag = getEtag(event.node.req.url ?? '')
  setResponseHeader(event, 'ETag', etag)
  setResponseHeader(event, 'Cache-Control', CACHE_CONTROL)

  if (getRequestHeader(event, 'if-none-match') === etag) {
    event.node.res.statusCode = 304
    return send(event, null)
  }
})

Nitro middleware runs for every request that reaches the server, including ones no TanStack Start route matches. That's the tradeoff: it's the one place to catch everything, but it also means it can shadow or conflict with a route you define elsewhere — a Nitro server route at the same path takes precedence over the TanStack Start router. Keep it scoped (check the path, or only run for HTML document requests) if your app defines its own server routes.

Good for: caching that needs to apply even outside the React router (static assets, API routes, a mixed Nitro + TanStack Start app).

Which One to Pick#

  • One route needs it, with its own rules → Method 1.
  • Most/all pages should get the same policy → Method 2.
  • You need it below the app router, or it must apply to non-TanStack routes too → Method 3.

All three do the same HTTP: compare If-None-Match, return an empty 304 on a match, stamp ETag/Cache-Control otherwise.

Verify It Works#

PAGE_URL='http://localhost:8080/blog/your-article'
curl -sS -D - -o /dev/null "$PAGE_URL"

Expect 200 OK with an ETag header. Copy it, then send it back:

curl -i -H 'If-None-Match: W/"paste-the-hash-here"' "$PAGE_URL"

Expect 304 Not Modified with no body.