Progressive Enhancement in React: Works Without JavaScript
Progressive enhancement in React isn't dead — it's more relevant than ever. Here's how to build components that work without JS and shine with it.
The Case for Building Without JavaScript First
Honestly, most React developers have never once asked themselves what their app looks like with JavaScript disabled. It's an uncomfortable question. The answer, for a lot of production apps, is a blank white screen.
Progressive enhancement flips the script. You start with something that works — plain HTML, CSS, a form that submits, a link that navigates — and then you layer JavaScript on top to make it faster, richer, and more interactive. The baseline is always functional. The enhancement is optional.
This isn't nostalgia for the web of 2005. It's practical. Flaky mobile networks, corporate proxy servers stripping scripts, browser extensions misfiring, bot crawlers that don't execute JS — there are real scenarios where your JavaScript won't run. And your users still need to accomplish things.
React Server Components and the App Router in Next.js 14+ have brought this conversation back to the frontend community in a serious way. Suddenly you can write React components that render on the server and ship zero JavaScript to the client. That's a big deal.
How React Server Components Change the Baseline
Server Components render once, on the server, and send HTML. No hydration. No client bundle entry. The component doesn't exist in the browser at all — only its output does. For static or infrequently-updated content, this is exactly right.
The mental model shift is: not every component needs to be interactive. A blog post, a pricing table, a navigation header that uses server-fetched user data — none of these need client-side React. Mark them as Server Components (the default in Next.js App Router) and they'll ship as pure HTML.
Client Components — the ones you add 'use client' to — are the ones that hydrate. They get JavaScript. They get event handlers. They get useState and useEffect. The key discipline is keeping that boundary tight. Don't let interactivity creep upward into components that don't need it.
Think of it like applying a glassmorphism effect — you add the visual layer on top of a solid structural foundation. The structure works first. The glass layer is enhancement.
Server Actions: Forms That Work Without Client JS
Here's the thing: HTML forms have always worked without JavaScript. A <form method="POST"> submits data, triggers a server response, and navigates the browser. That's been true since 1993.
React 19's Server Actions let you write async functions that run on the server and bind them directly to form action attributes. With Next.js, this means a form that submits, validates, and mutates data entirely through the server — no preventDefault, no fetch, no client state. It just works.
// app/contact/page.tsx — no 'use client' needed
async function submitContact(formData: FormData) {
'use server'
const email = formData.get('email') as string
const message = formData.get('message') as string
if (!email || !message) {
// Next.js 14+: redirect with error params
redirect('/contact?error=missing-fields')
}
await db.insert(contactMessages).values({ email, message })
redirect('/contact?success=true')
}
export default function ContactPage() {
return (
<form action={submitContact} className="flex flex-col gap-4">
<input
type="email"
name="email"
required
className="border border-zinc-300 rounded-lg px-4 py-2"
placeholder="you@example.com"
/>
<textarea
name="message"
required
rows={5}
className="border border-zinc-300 rounded-lg px-4 py-2"
placeholder="Your message"
/>
<button type="submit" className="bg-indigo-600 text-white rounded-lg px-6 py-3">
Send Message
</button>
</form>
)
}That form submits, validates, and saves to a database with zero client JavaScript. Add a useFormState hook later and you get inline error feedback, optimistic updates, loading states. Same form, same action — you're just enhancing what's already there.
CSS Does More Than You Think
A lot of what developers reach for JavaScript to do can actually live in CSS. Disclosure patterns, hover states, focus rings, basic show/hide behavior — modern CSS handles all of it. And CSS always runs, even when JavaScript doesn't.
The :has() selector (supported in all major browsers since late 2023) lets you style a parent based on its children's state. A checkbox that controls a sibling panel's visibility. An input that shows a clear button only when it has a value. These patterns used to need React state. Now they're three lines of CSS.
For something like a theme toggle, you can store the preference in a data-theme attribute on the <html> element and drive all your color tokens off CSS custom properties. The server can read a cookie and render the correct data-theme value before any JavaScript runs. No flash of unstyled content, no layout shift, no hydration mismatch.
This matters because visual stability is part of the user experience. If your page jumps from light to dark after hydration, that's a failure — even if your JavaScript eventually gets it right. CSS-first approaches eliminate that class of problem entirely.
Hydration Mismatches and How to Avoid Them
Hydration mismatches are the silent killer of progressive enhancement in React. The server renders one thing, the client renders something different, and React throws a warning — or worse, silently patches the DOM in a way that breaks your UI.
The most common cause: code that reads browser-only APIs during render. window.innerWidth, localStorage.getItem('theme'), navigator.language. These return different values (or throw entirely) on the server. If your render output depends on them, you'll get a mismatch.
The fix is to defer that logic. Don't call localStorage during the render phase. Use useEffect to read it after hydration. Or better — store the value in a cookie that the server can read, so both environments see the same data. That's not a workaround, that's the correct architecture.
Worth noting: Tailwind v4.0.2 ships with a darkMode: 'selector' strategy that plays nicely here. Set data-theme="dark" on the <html> tag server-side from a cookie, and every Tailwind dark variant resolves correctly on first paint. No client JavaScript needed for the initial render.
Accessibility Is a Progressive Enhancement Story
Why do accessibility and progressive enhancement keep appearing in the same conversations? Because they're solving the same problem from different angles: making sure your interface works for everyone, regardless of what technology stack they're running.
Screen readers often interact with the DOM directly, sometimes bypassing JavaScript event handlers entirely. If your button only works because of a onClick listener added after hydration, a screen reader user navigating before hydration completes might encounter a dead button. Native HTML elements — <button>, <a href>, <input>, <select> — carry built-in accessibility semantics and event handling that don't depend on JavaScript.
So use them. Reach for a real <button> instead of a <div onClick>. Use <a href="/page"> for navigation instead of a router push inside a click handler. These choices make your app more accessible and more resilient at the same time. That's not a coincidence — semantic HTML is the foundation both disciplines are building on.
If you're building animation-heavy interfaces with things like canvas animations or Lottie animations, make sure the animated elements aren't load-bearing for comprehension. Provide text alternatives. Use prefers-reduced-motion. The animation is enhancement; the content is the baseline.
Measuring What Actually Ships to the Browser
Here's a question worth sitting with: do you actually know how much JavaScript your app ships to a first-time visitor? Not your bundle size — the amount that executes before a user can interact with something.
Next.js has @next/bundle-analyzer to visualize what's in your client bundle. Run it. Look at how much of that bundle is components that don't need to be client-side. Look for vendor libraries that got pulled into the client because one component imported them and forgot 'use client' boundaries.
A good target for a content-heavy page: under 50KB of compressed JavaScript for initial interactivity. That number is achievable if you're disciplined about Server vs Client Component boundaries. Menus, modals, and form validation can often be the only things hydrating on a marketing page.
Measure your Core Web Vitals too. Total Blocking Time (TBT) directly correlates with JavaScript parse time. Every kilobyte you move to the server is a millisecond you're giving back to users on mid-range Android devices. That's not abstract — that's your bounce rate.
Building Empire UI Components the Progressive Way
Empire UI components are built with Tailwind and React, but the approach works for any component library. The principle: define what the component does structurally first, then add the JavaScript-dependent behaviors as enhancements.
A modal dialog is a good example. The HTML baseline is a <dialog> element — it has native show/close behavior, focus trapping, and ESC key support baked in. The enhancement layer adds animations, backdrop blur via backdrop-filter: blur(8px), and state management. But if the JavaScript fails, the <dialog> still exists and the browser can render it.
Same goes for navigation dropdowns. A list of links works without JavaScript. The enhancement is the hover-triggered expand/collapse, the smooth 200ms transition, the keyboard arrow-key navigation. Strip the JS and you still have navigation — just not the polished version.
This mindset also makes your components easier to test. A form that submits via Server Action has no client state to mock. A navigation that uses real <a> tags has no router to stub. The simpler the baseline, the easier the test, the fewer the failure modes.
FAQ
It's the fastest way to see your baseline. In Chrome DevTools, open the Command Palette (Ctrl+Shift+P), type 'Disable JavaScript', and reload. Whatever you see is your baseline. But also test on a slow 3G connection with network throttling — your JS might technically load, just 8 seconds late.
Hooks only run in Client Components. If a component needs useState, useEffect, or useRef, it needs 'use client'. The strategy is to keep those components small and leaf-level — a single interactive button, not an entire page section. Parent wrappers and layout components stay server-side.
Traditional SSR (like getServerSideProps in Next.js Pages Router) renders HTML on the server but still sends the full React bundle to the client for hydration. Server Components in the App Router can send zero JavaScript for components that don't need it. The HTML is identical — the difference is what ships in the JS bundle.
You have two options. The simple one: redirect to the same page with error params in the URL, then read those params in the Server Component to render inline error messages. The enhanced version: use useFormState (React 19) or useActionState to get inline error feedback without a full page navigation, progressively layering on the client behavior.
No — and this is the misconception worth challenging. Progressive enhancement defines your floor, not your ceiling. Once JavaScript loads, you get full React interactivity. The difference is that users on slow connections or edge cases don't get a broken experience while they wait. You can still have snappy animations, optimistic updates, and instant feedback.
Dynamic imports with ssr: false in Next.js. Mark the parent component 'use client', then import the library with next/dynamic and { ssr: false }. This keeps the library out of the server render entirely and loads it only after hydration. The container div renders server-side; the library mounts client-side into it.