Repository navigation
Tier 3 (optional/deferred): Tailwind 4 CSS-first config, move CSP out of Express, Vite 8 + TypeScript 7 #9
Description
Three optional, independently-deferrable adoptions from upstream Epic Stack. None of these are required — the site works fine without any of them, and each carries real regression risk. They are collected here so they are not lost, with an explicit recommendation and the blocker for each.
Do not start this issue until the Tier 1 and Tier 2 issues are merged and stable. Task 1 in particular touches the Tailwind theme system, which is load-bearing for every component in app/components/; changing it before the smaller work settles makes bisecting a visual regression much harder.
Suggested PR split: one PR per task, each separately revertable. Task 3 is not even Epic Stack related — consider filing it separately or dropping it from this issue.
Context
The version-divergence background is in the Tier 1 issue. The short version relevant here: this repo is ahead of upstream on tailwindcss (4.3.3 vs 4.1.18) but upstream has completed two migrations this repo has not — deleting tailwind.config.ts entirely in favour of CSS-first config, and moving CSP out of Express into entry.server.tsx. Separately, vite and typescript have both shipped new majors that this repo has not adopted.
Each task below lists its recommendation up front. If you only do one, do Task 3 — it is the lowest risk and the highest reward (build performance).
Task 1 — Tailwind 4 CSS-first configuration
Recommendation: do it, but expect this to be the hardest item in this issue. Budget a full day and get a visual diff before/after.
Upstream deleted tailwind.config.ts (and this repo's postcss.config.js) and moved the entire theme into CSS. This is the direction Tailwind is steering, and it removes a JS config file plus a PostCSS pass in favour of the first-party Vite plugin.
Why it is not a mechanical port
This repo's tailwind.config.ts is not a stock config. It has four plugins and one custom utility, and its theme is consumed from TypeScript:
@tailwindcss/typographytailwindcss-animatetailwindcss-radix- an inline
matchUtilitiesplugin definingbg-dot-thick, parameterised overflattenColorPalette(theme('backgroundColor')) theme.extend: extendedTheme, imported fromapp/utils/extended-theme.ts
The extendedTheme import is the blocker. app/utils/misc.tsx:63-92 builds a customTwMerge via extendTailwindMerge and reads extendedTheme.colors, extendedTheme.borderRadius, and extendedTheme.fontSize to teach tailwind-merge about the custom scales. Tailwind 4's tailwind-merge@3 cannot be extended from a JS config the same way, and CSS @theme blocks are not readable at runtime.
You have to pick one of these and say so in the PR:
- (a) Keep a TS theme module and generate CSS from it. Keep
app/utils/extended-theme.tsas the source of truth, keepextendTailwindMergeuntouched, and emit an@theme { ... }block from it (a small build script, or hand-maintained CSS kept in sync). Lowest regression risk, but you have not actually removed the duplication — you have just moved it. - (b) Move fully to
@themeand dropextendTailwindMerge. Use plaintwMergefromtailwind-merge@3. Cleanest end state, but you lose custom conflict resolution, so a class liketext-h5 xs:text-h2may stop resolving the way it does today. This is a real visual-regression risk across the marketing pages.tailwind-merge@3does have some Tailwind 4 support, so check what it already handles before assuming the worst. - (c)
@theme inlinebridging. Emit CSS custom properties and extendtailwind-mergeagainst those variable names instead of the literal scales. Most faithful, most fiddly.
Porting mechanics (all options)
darkMode: 'class'→@custom-variant dark (&:is(.dark *));content: [...]→ delete; Tailwind 4 auto-detects sources.@tailwindcss/typography→@plugin '@tailwindcss/typography';tailwindcss-animate→ replace withtw-animate-cssand@import 'tw-animate-css';(this is what upstream did).tailwindcss-animateis unmaintained for v4.tailwindcss-radix→ evaluate. If it has no v4-compatible replacement and thedata-[state=]variants it provides are actually used, either inline those variants via@custom-variantor keep the package if it still works. Grep fordata-state,data-[stateinapp/components/ui/first to see whether it earns its place.container: { center: true, padding: '2rem', screens: { '2xl': '1400px' } }→@container/ theme variables. Check thecontainerclass usage inapp/components/before changing it; the header and footer both use it.bg-dot-thick→@utility bg-dot-thick. TheflattenColorPalette(theme('backgroundColor'))value list has no direct v4 equivalent; you will likely need to hardcode the colour list or drive it from CSS custom properties. Find where it is used first —app/components/line-glow.tsxand/or the header are the likely candidates.- Swap
@tailwindcss/postcssfor@tailwindcss/viteinvite.config.ts, deletepostcss.config.js, deletetailwind.config.ts, and remove thetailwindcss,tailwindcss-animate,tailwindcss-radix,postcss,autoprefixerdeps as appropriate. Noteautoprefixeris not currently inpackage.json— Tailwind 4 handles vendor prefixing internally, so nothing to remove there.
Acceptance criteria
npm run buildsucceeds andbuild/client/assets/*.cssis emitted.- Diff the compiled CSS before and after. Class counts and custom-property blocks should be equivalent. An unexplained drop in generated utilities means source detection broke.
- Manually verify on
/,/talks,/uses,/contact: dark-mode toggle, thecontainerlayout at mobile/tablet/desktop,text-h5/xs:text-h2heading scales,bg-dot-thickif it is used, the newsletter form focus rings, and the header/footer at every breakpoint. npm run validateandnpm run test:runpass.tests/utils.test.tsandtests/motion.test.tsxmay assert on class names — check them.
Task 2 — Move CSP out of Express (helmet → @nichtsam/helmet)
Recommendation: skip unless you have a specific reason. @nichtsam/helmet is at 0.3.3 — pre-1.0, young, and this is security-sensitive plumbing that currently works.
The actual improvement
The current design has a real wart. server/index.ts:98-105 mints the CSP nonce in Express and passes it to app/entry.server.tsx:37 via a request header (x-csp-nonce), because the two live in different module graphs — the server bundle and the app bundle do not share a createContext() instance. That constraint is documented in the comments at server/index.ts:99-102 and :205-210.
Upstream's @nichtsam/helmet removes the constraint: contentSecurity() is called inside entry.server.tsx, so the nonce is generated exactly where it is consumed and no header hand-off is needed. It also replaces the chalk-era helmet wrapper API.
Cost of doing it
- The nonce middleware at
server/index.ts:98-105and both explanatory comments get deleted, and thegetLoadContextcomment at:205-210needs rewriting (its esbuild-bundle rationale partly evaporates, especially if the Tier 2 native-stripping work has landed). app/entry.server.tsxgains acrypto.randomBytes(16).toString('hex')call and acontentSecurity(responseHeaders, {...})invocation inside the render callback, right afterresponseHeaders.set('Content-Type', 'text/html').- Preserve
'upgrade-insecure-requests': nulland theimg-srcallowance for*.cloudinary.com(used by Hashnode-sourced images in the blog/notes components). helmet(res, { general: { referrerPolicy: false } })replaces the currentreferrerPolicy: { policy: 'same-origin' }because the referrer policy conflicts with redirect logic.
🚩 Known pre-existing issue this will surface
app/root.tsx injects the Umami analytics script as a raw <script async defer src=...> with no nonce attribute. Under the current policy — script-src 'strict-dynamic' 'self' 'nonce-...' — a third-party script without a nonce is not permitted. It only appears to work because the CSP is reportOnly: true (flagged in the TODO at server/index.ts:113-114), so violations are reported, not enforced.
Check whether Umami is actually loading today by looking for CSP violation reports in Sentry. If it is not loading, that is a pre-existing bug this task will force you to confront: either add nonce={nonce} to that script tag, or accept the breakage when you eventually flip off reportOnly.
Also check app/utils/analytics.client.tsx — PostHog is initialised from JS (posthog-js), so it is subject to connect-src, which currently allows 'self' and conditionally *.sentry.io. PostHog is proxied through /resources/ingest on the same origin, so it should be fine, but verify.
Acceptance criteria
- Confirm CSP is still
reportOnlyand that the report count in Sentry does not increase after the swap. npm run devandnpm startboth serve working pages with no new console CSP violations.- Umami and PostHog both confirmed working (or the pre-existing breakage documented and fixed).
- The stale esbuild-bundle comments are removed or corrected.
Task 3 — Vite 8 and TypeScript 7
Recommendation: do Vite 8. Treat TypeScript 7 as a separate, later decision — it is a compiler rewrite, not a version bump.
Good news, verified
@react-router/dev@8.4.0's peer dependencies already allow both:
"typescript": "^5.1.0 || ^6.0.0 || ^7.0.0",
"vite": "^7.0.0 || ^8.0.0",
So neither bump is blocked by React Router.
Vite 7.3.6 → 8.3.1
- Update
vitein devDependencies. npm run build,npm run dev,npm run typecheck,npm run test:runall pass.npm run validatepasses.eslint@10's Vite-related plugins may need attention.- Check the Sentry Vite plugin (
@sentry/vite-plugin@5.4.0) declares Vite 8 support. This is the most likely breakage — verify its peer range before starting, and be aware that adopting upstream'ssentryReactRouterintegration is not the fix, since this repo is deliberately on@sentry/react(see the note in the Tier 2 issue). - Verify the
assetsInlineLimitpredicate and therollupOptions.external: [/node:.*/, 'fsevents']entry still behave — and if the Tier 2isSsrBuildinput change has landed, that too. - Compare
build/client/assetschunking before and after. A materially different chunk graph means a Rollup change; investigate rather than accepting it.
TypeScript 6.0.3 → 7.0.2
TypeScript 7 is the native (Go) compiler, not an incremental JS release. Treat it as a new compiler with a migration, not a bump.
@total-typescript/ts-reset@0.6.1— verify support.eslint@10+@epic-web/configTypeScript-aware linting — verify.react-router typegeninteraction.- Expect genuinely different error output and possibly different inference edge cases. This is the kind of change that surfaces a batch of pre-existing type errors that were previously masked.
- Do not bundle this with the Vite 8 bump. Land Vite 8 first, confirm clean, then evaluate TS 7 on its own.
Acceptance criteria
- Vite 8: full validate + test suite green, build output diff reviewed.
- TypeScript 7: only if attempted — all of the above tooling checks pass, and any newly surfaced type errors are triaged explicitly rather than silenced with
@ts-expect-erroror loosened config.
Adjacent opportunity worth a separate issue (not Epic Stack related)
@react-router/dev@8.4.0 supports a prerender config option (verified in node_modules/@react-router/dev/dist/config-t89niiFv.d.ts) that renders routes to static HTML at build time, with a concurrency option for parallel prerendering.
For a content site with 12 mostly-static routes, this is potentially a large win: most routes never change per-request, and prerendering would collapse the production server's job to serving a handful of dynamic routes plus static assets.
Before filing it, investigate:
- Which routes are genuinely per-request (
/contactPOST,/resources/ingest,/resources/newsletter,/resources/healthcheck) versus static. The_seo+and_legal+routes look fully static. - Whether
prerender: trueor an explicit path array is needed, and how it interacts with the/prefix,sitemap[.]xml.ts, androbots[.]txt.ts— prerendering arobots.txtroute would be actively harmful. - Whether client-side hydration cost changes, and whether the CSP nonce (minted per-request in
app/entry.server.tsx) is even meaningful on a prerendered document. - Whether
Sentryrelease/COMMIT_SHAreporting and thefly-regionheaders on prerendered documents still make sense.
This is a substantial architectural question, not an upgrade task. File it separately.
Activity
arpitdalal commentedon Sep 27, 2026
Task 2 (
helmet→@nichtsam/helmet) is superseded on the nonce question by #10, which diagnosed and fixed the Umami CSP failure. #10 landed: the tracker tag now carries a nonce,connect-srcallows the Umami origin, andimg-srcwas missing*.hashnode.com. Remaining in Task 2: the middleware/entry.server.tsx move itself and the stale esbuild-bundle comments. Task 3 (Vite 8) is in #17.build(deps): bump Vite to 8 (#9) (#17)
arpitdalal commentedon Sep 27, 2026
Status of all three tasks, now that two PRs are open. Summary: Task 3's Vite 8 half is done and merge-ready. Nothing else was attempted, and the reasons below are mostly that the tasks were correctly scoped as deferred.
Task 3a — Vite 8 ✅ done, merge-ready
#17 —
vite7.3.6 → 8.3.1,@vitejs/plugin-react→ 5.2.0.Both peer ranges the issue flagged as already-permitted checked out:
@react-router/dev@8.4.0allows^7.0.0 || ^8.0.0, so no React Router blocker.The one real snag was not in the issue write-up. Vite 8 SSR failed to resolve bare
tailwindcssinapp/styles/tailwind.cssduring style-condition resolution (vite#23096); pinned to@import 'tailwindcss/index.css'. Two files plus the lockfile.Checks the issue asked for:
@sentry/vite-plugin@5.4.0declares nopeerDependenciesat all (enginesnode>=18only), so there is no Vite 8 peer conflict. This was the "most likely breakage" and it did not materialise. Adopting upstream'ssentryReactRouterintegration was not needed, so the deliberate@sentry/reactchoice is untouched.assetsInlineLimitandrollupOptions.external:sprite-*.svg(4.9 KB) andapple-touch-icon-*.pngstill emitted as assets, not inlined.jsx-runtime,react-domanduseEventListenerbecome their own chunks andcomponents.jsdisappears. That is Rolldown's splitting behaviour, not a regression, and there is noisSsrBuildchange in play (that was Tier 2, and it did not touch this config).Not verified: Sentry sourcemap upload on a real deploy. The plugin path is only exercised with
SENTRY_AUTH_TOKENset, which is not available locally. It is on the PR's test plan.Task 3b — TypeScript 7 ⏸️ not attempted, and I think that is right
Still on
typescript@6.0.3. The issue's own reasoning for deferring it holds and nothing has changed to alter it: TS 7 is the Go compiler, not a version bump, and the issue itself says not to bundle it with Vite 8. Vite 8 is now landed and stable on its own PR, which was the stated precondition, but the tooling checks (@total-typescript/ts-reset,eslint@10+@epic-web/config,react-router typegen) are all unverified unknowns that would need doing in their own pass.Worth flagging for whoever picks it up: the issue predicts a batch of pre-existing type errors surfacing. That is the cheap part to verify first — a dry run would tell you whether this is a small PR or a large one before anyone commits to it.
Task 2 — Move CSP out of Express ⏸️ not attempted, and the issue's "skip" recommendation is now better founded
Still on
helmet@8.3.0. The issue recommended skipping, and that recommendation got stronger rather than weaker — but for a reason the issue did not anticipate, described below.The nonce half of this task is done, in #18, and the answer turned out to be the opposite of what this issue and #10 both assumed. #18 corrects it:
server/index.tssetsreportOnly: true, so the policy has never been enforced — production ships onlyContent-Security-Policy-Report-Only, confirmed withcurl -sI https://arpitdalal.dev. So the unnonced Umami tracker, its cross-origin beacon, and the Hashnode cover images were all loading fine. Nothing was broken. The'strict-dynamic'-makes-'self'-inert analysis in this issue is correct as spec reasoning and simply never had anything to act on.The three changes in #18 (nonce on the tracker tag, Umami origin in
connect-src,*.hashnode.cominimg-src) are therefore prerequisites for flipping enforcement on, not repairs for an outage. That is worth knowing before touching this task: the work that remains here is making the policy enforceable, and #18 is what makes the flip safe rather than blind.What is left in Task 2, unchanged:
contentSecurity()call moving from Express intoentry.server.tsxvia@nichtsam/helmetserver/index.ts:119-127and its two explanatory commentsgetLoadContextcomment about the esbuild-bundle rationalereferrerPolicy: { policy: 'same-origin' }→helmet(res, { general: { referrerPolicy: false } })@sentry/react, not upstream'ssentryReactRouterThe pre-1.0 concern in this issue still stands and is the main reason I would not rush it:
@nichtsam/helmetat 0.3.3 is young, and this is security-sensitive plumbing.One new consideration since this issue was written. #18's own review surfaced that CSP reporting can recurse through Sentry — reporting a violation means sending an envelope, so a blocked Sentry request raises another violation, unbounded.
connect-srchad*.sentry.io, which silently excluded any self-hosted Sentry. That is now the exact DSN origin plus a guard. If Task 2 moves this plumbing into@nichtsam/helmet, that same class of bug is the thing most likely to be reintroduced, because the violation-reporting path and the policy definition are about to live closer together.Task 1 — Tailwind 4 CSS-first config ⏸️ not attempted, and I would push back on the sequencing
Not started, and this is the one where I think the issue's plan needs revisiting rather than just deferring.
Everything the issue says about the difficulty is still true —
tailwind.config.tshas four plugins plus the inlinematchUtilitiesforbg-dot-thick,extendedThemefeedsextendTailwindMergeinapp/utils/misc.tsx:63-92, and thecontainerclass is load-bearing in the header and footer. TheextendedThemeblocker is real and unresolved.The reason not to start is the one the issue itself gives, and it has not been discharged: the issue says not to begin until Tier 1 and Tier 2 are merged and stable. Tier 1 and Tier 2 are now merged, but "stable" has not been established — Vite 8 (#17) is merge-ready and unmerged, and #18 is open. Starting a full-day Tailwind theme migration on top of an unvalidated Vite major bump means any visual regression has two candidate causes, and the issue's own stated worry about bisecting is exactly this.
If it is picked up, the three options in the issue are still the right framing, with one addition from what #18 turned up: option (b) — dropping
extendTailwindMerge— is now a bigger visual risk than the issue estimated, because #18 demonstrated that a class of change which "looks right in source" can be silently inert at runtime. The issue's own advice to "check whattailwind-merge@3already handles before assuming the worst" is the right instinct and should be done empirically, not by reading its changelog.Not filed
The
prerenderidea at the bottom of this issue is still unfiled and still not Epic Stack related. Nothing in the two PRs above changes its open questions, and one of them got sharper: #18 establishes that the CSP nonce is minted per-request inentry.server.tsx, so on a prerendered document there would be no per-request nonce to speak of. That is a genuine architectural question for that issue, not a detail.Suggested next step, in order: merge #17 and #18, let them run a while, then decide on Task 3b (TS 7) or Task 2 (
@nichtsam/helmet). Task 1 should be last, on its own, and only once the Vite 8 bump has proven itself.