Skip to content

Tier 3 (optional/deferred): Tailwind 4 CSS-first config, move CSP out of Express, Vite 8 + TypeScript 7 #9

Closed

Description

@arpitdalal

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/typography
  • tailwindcss-animate
  • tailwindcss-radix
  • an inline matchUtilities plugin defining bg-dot-thick, parameterised over flattenColorPalette(theme('backgroundColor'))
  • theme.extend: extendedTheme, imported from app/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.ts as the source of truth, keep extendTailwindMerge untouched, 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 @theme and drop extendTailwindMerge. Use plain twMerge from tailwind-merge@3. Cleanest end state, but you lose custom conflict resolution, so a class like text-h5 xs:text-h2 may stop resolving the way it does today. This is a real visual-regression risk across the marketing pages. tailwind-merge@3 does have some Tailwind 4 support, so check what it already handles before assuming the worst.
  • (c) @theme inline bridging. Emit CSS custom properties and extend tailwind-merge against 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 with tw-animate-css and @import 'tw-animate-css'; (this is what upstream did). tailwindcss-animate is unmaintained for v4.
  • tailwindcss-radix → evaluate. If it has no v4-compatible replacement and the data-[state=] variants it provides are actually used, either inline those variants via @custom-variant or keep the package if it still works. Grep for data-state, data-[state in app/components/ui/ first to see whether it earns its place.
  • container: { center: true, padding: '2rem', screens: { '2xl': '1400px' } } → @container / theme variables. Check the container class usage in app/components/ before changing it; the header and footer both use it.
  • bg-dot-thick → @utility bg-dot-thick. The flattenColorPalette(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.tsx and/or the header are the likely candidates.
  • Swap @tailwindcss/postcss for @tailwindcss/vite in vite.config.ts, delete postcss.config.js, delete tailwind.config.ts, and remove the tailwindcss, tailwindcss-animate, tailwindcss-radix, postcss, autoprefixer deps as appropriate. Note autoprefixer is not currently in package.json — Tailwind 4 handles vendor prefixing internally, so nothing to remove there.

Acceptance criteria

  • npm run build succeeds and build/client/assets/*.css is 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, the container layout at mobile/tablet/desktop, text-h5/xs:text-h2 heading scales, bg-dot-thick if it is used, the newsletter form focus rings, and the header/footer at every breakpoint.
  • npm run validate and npm run test:run pass. tests/utils.test.ts and tests/motion.test.tsx may 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-105 and both explanatory comments get deleted, and the getLoadContext comment at :205-210 needs rewriting (its esbuild-bundle rationale partly evaporates, especially if the Tier 2 native-stripping work has landed).
  • app/entry.server.tsx gains a crypto.randomBytes(16).toString('hex') call and a contentSecurity(responseHeaders, {...}) invocation inside the render callback, right after responseHeaders.set('Content-Type', 'text/html').
  • Preserve 'upgrade-insecure-requests': null and the img-src allowance for *.cloudinary.com (used by Hashnode-sourced images in the blog/notes components).
  • helmet(res, { general: { referrerPolicy: false } }) replaces the current referrerPolicy: { 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 reportOnly and that the report count in Sentry does not increase after the swap.
  • npm run dev and npm start both 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 vite in devDependencies.
  • npm run build, npm run dev, npm run typecheck, npm run test:run all pass.
  • npm run validate passes. 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's sentryReactRouter integration is not the fix, since this repo is deliberately on @sentry/react (see the note in the Tier 2 issue).
  • Verify the assetsInlineLimit predicate and the rollupOptions.external: [/node:.*/, 'fsevents'] entry still behave — and if the Tier 2 isSsrBuild input change has landed, that too.
  • Compare build/client/assets chunking 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/config TypeScript-aware linting — verify.
  • react-router typegen interaction.
  • 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-error or 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 (/contact POST, /resources/ingest, /resources/newsletter, /resources/healthcheck) versus static. The _seo+ and _legal+ routes look fully static.
  • Whether prerender: true or an explicit path array is needed, and how it interacts with the / prefix, sitemap[.]xml.ts, and robots[.]txt.ts — prerendering a robots.txt route 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 Sentry release/COMMIT_SHA reporting and the fly-region headers on prerendered documents still make sense.

This is a substantial architectural question, not an upgrade task. File it separately.

Activity

  1. arpitdalal commented on Sep 27, 2026

    @arpitdalal
    OwnerAuthor

    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-src allows the Umami origin, and img-src was 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.

  2. added a commit that references this issue on Sep 27, 2026
  3. arpitdalal commented on Sep 27, 2026

    @arpitdalal
    OwnerAuthor

    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 — vite 7.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.0 allows ^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 tailwindcss in app/styles/tailwind.css during style-condition resolution (vite#23096); pinned to @import 'tailwindcss/index.css'. Two files plus the lockfile.

    Checks the issue asked for:

    • Sentry plugin: @sentry/vite-plugin@5.4.0 declares no peerDependencies at all (engines node>=18 only), so there is no Vite 8 peer conflict. This was the "most likely breakage" and it did not materialise. Adopting upstream's sentryReactRouter integration was not needed, so the deliberate @sentry/react choice is untouched.
    • validate / test:run / build all green (136 tests at the time of that PR).
    • assetsInlineLimit and rollupOptions.external: sprite-*.svg (4.9 KB) and apple-touch-icon-*.png still emitted as assets, not inlined.
    • Chunk graph: materially different, and I looked rather than waved it through. Rolldown splits more — jsx-runtime, react-dom and useEventListener become their own chunks and components.js disappears. That is Rolldown's splitting behaviour, not a regression, and there is no isSsrBuild change 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_TOKEN set, 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.ts sets reportOnly: true, so the policy has never been enforced — production ships only Content-Security-Policy-Report-Only, confirmed with curl -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.com in img-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:

    • the contentSecurity() call moving from Express into entry.server.tsx via @nichtsam/helmet
    • deleting the nonce middleware at server/index.ts:119-127 and its two explanatory comments
    • rewriting the getLoadContext comment about the esbuild-bundle rationale
    • referrerPolicy: { policy: 'same-origin' } → helmet(res, { general: { referrerPolicy: false } })
    • deliberately still on @sentry/react, not upstream's sentryReactRouter

    The pre-1.0 concern in this issue still stands and is the main reason I would not rush it: @nichtsam/helmet at 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-src had *.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.ts has four plugins plus the inline matchUtilities for bg-dot-thick, extendedTheme feeds extendTailwindMerge in app/utils/misc.tsx:63-92, and the container class is load-bearing in the header and footer. The extendedTheme blocker 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 what tailwind-merge@3 already handles before assuming the worst" is the right instinct and should be done empirically, not by reading its changelog.


    Not filed

    The prerender idea 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 in entry.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions