From 7cf985e90cda8e47a610ef70de5162d1d6e6e792 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 3 Aug 2026 09:29:42 -0700 Subject: [PATCH] perf: cache un-hashed static assets under /img and /logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are served from public/ with `public, max-age=0, must-revalidate`, so every navigation pays a conditional-request round-trip for them. The responses do return 304 with 0 bytes, so this was never a re-download problem — just a latency one, which is why it's a modest win rather than a large one. `immutable` is not safe here: the filenames are not content-hashed, so a redeploy has to be able to replace them in place. An hour of freshness plus a week of stale-while-revalidate drops the round-trip while bounding how long a replaced asset can linger. Extends headers() to apply in prod, which previously returned no rules at all; the noindex rule stays non-prod only. Tests updated to match. Refs #471 --- next.config.js | 18 +++++++++++++++++- next.config.test.ts | 41 +++++++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/next.config.js b/next.config.js index f23eb2c6..7bcc16c3 100644 --- a/next.config.js +++ b/next.config.js @@ -25,10 +25,26 @@ const nextConfig = { transpilePackages: ["jose"], serverExternalPackages: ["@duckdb/node-api"], async headers() { + // ponytail: un-hashed filenames under public/, so `immutable` is unsafe — + // a redeploy has to be able to replace these in place. An hour of freshness + // drops the per-navigation revalidation round-trip without stranding a + // stale asset for long. + const staticAssets = [ + { + source: "/:dir(img|logo)/:path*", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=3600, stale-while-revalidate=604800", + }, + ], + }, + ]; if (process.env.STAGE === "prod") { - return []; + return staticAssets; } return [ + ...staticAssets, { source: "/:path*", headers: [ diff --git a/next.config.test.ts b/next.config.test.ts index f6ae8a34..5e5de366 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -1,5 +1,20 @@ import nextConfig from "./next.config.js"; +const STATIC_ASSET_RULE = { + source: "/:dir(img|logo)/:path*", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=3600, stale-while-revalidate=604800", + }, + ], +}; + +const NOINDEX_RULE = { + source: "/:path*", + headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }], +}; + describe("next.config headers()", () => { const originalStage = process.env.STAGE; @@ -14,28 +29,26 @@ describe("next.config headers()", () => { test("non-prod stage emits noindex header on all paths", async () => { process.env.STAGE = "dev"; const result = await nextConfig.headers!(); - expect(result).toEqual([ - { - source: "/:path*", - headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }], - }, - ]); + expect(result).toEqual([STATIC_ASSET_RULE, NOINDEX_RULE]); }); - test("prod stage emits no header overrides", async () => { + test("prod stage emits no noindex override", async () => { process.env.STAGE = "prod"; const result = await nextConfig.headers!(); - expect(result).toEqual([]); + expect(result).toEqual([STATIC_ASSET_RULE]); }); test("missing STAGE defaults to noindex", async () => { delete process.env.STAGE; const result = await nextConfig.headers!(); - expect(result).toEqual([ - { - source: "/:path*", - headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }], - }, - ]); + expect(result).toEqual([STATIC_ASSET_RULE, NOINDEX_RULE]); + }); + + test("static assets stay cacheable in every stage", async () => { + for (const stage of ["prod", "dev"]) { + process.env.STAGE = stage; + const result = await nextConfig.headers!(); + expect(result).toContainEqual(STATIC_ASSET_RULE); + } }); });