Skip to main content
Back to Blog
6 min read

Progressive enhancement in the RSC era: what still works, what doesn't

Server Components changed which progressive enhancement patterns are viable. Forms work better than ever. Client interactivity requires more thought. Here's what I've learned shipping RSC apps to real users.

next.jsux

Progressive enhancement — the idea that a page should work without JavaScript and get better with it — used to be simple. Render HTML on the server, sprinkle JS for interactions. If JS fails to load, the page still works.

React Server Components change the equation. Some things are easier than ever. Others require deliberate choices. Here's what I've learned from shipping RSC-based Next.js apps to real users.

What works better than before

Forms

This is the big win. Server Actions turn forms into progressively enhanced components by default.

async function subscribe(formData: FormData) {
  'use server'
  const email = formData.get('email') as string
  await db.subscribers.create({ data: { email } })
}

export default function NewsletterForm() {
  return (
    <form action={subscribe}>
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>
  )
}

This form works with JavaScript disabled. The browser submits a standard POST request, the server action runs, and the page reloads. With JavaScript enabled, React handles it client-side — no reload, instant feedback.

Before Server Actions, a form needed either a dedicated API route (extra file, extra code) or client-side fetch (no JS, no form). Now the progressive enhancement is built in.

Content pages

Server Components render to HTML on the server. Blog posts, documentation, marketing pages — they arrive as complete HTML. No hydration needed for static content. The browser renders them instantly, and they work perfectly without JavaScript.

This is what HTML always did, but now it's the default in React instead of something you have to fight for.

Next.js App Router prefetches links and handles client-side navigation automatically. But the links are regular <a> tags under the hood. If JavaScript fails, clicking a link does a full page navigation — which works fine.

What requires thought

Interactive components in server-rendered pages

A page can be a Server Component with pockets of client interactivity. The common pattern:

// Server Component — renders to HTML, no JS
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id)

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* Client Component — needs JS */}
      <AddToCartButton productId={product.id} />
    </main>
  )
}

Without JavaScript, the user sees the product info (good) but the "Add to Cart" button doesn't work (bad). The question is: what should happen when JS isn't available?

Option 1: Fallback form. Wrap the button in a <form> with a Server Action. No JS? Form submits. JS available? Client-side handling takes over. This is the cleanest progressive enhancement, but it works only for actions that can be expressed as form submissions.

Option 2: Accept the limitation. For complex interactions (drag-and-drop, real-time updates, canvas-based UIs), there's no HTML fallback. Document the JS requirement and ensure the page degrades gracefully — show content, hide non-functional controls.

Option 3: <noscript> message. Show a message when JS is disabled: "Enable JavaScript for full functionality." Honest, but feels like a cop-out for simple interactions.

I use Option 1 for any action that's expressible as a form (add to cart, subscribe, contact, toggle settings). Option 2 for genuinely complex UIs.

Loading states

Server Components stream — the shell renders immediately, slow data arrives later. With JavaScript, the user sees Suspense boundaries and loading states. Without JavaScript, they see... nothing until the full page loads.

This is a real downgrade. A user on a slow connection without JS sees a blank page for longer than they would with a traditional server-rendered page (which waits for all data before sending the response).

The mitigation: keep Suspense boundaries tight. Don't wrap your entire page in one Suspense — wrap individual slow sections. The parts that don't await data render immediately as HTML regardless of JS support.

Client-side state

Any state managed in useState, useReducer, or a state management library requires JavaScript. For shopping carts, filters, tabs, accordions — if these only exist in client state, they vanish without JS.

The pattern that works: encode state in the URL.

// Instead of useState for filters
// URL: /products?category=shoes&sort=price

export default async function Products({ searchParams }) {
  const products = await getProducts({
    category: searchParams.category,
    sort: searchParams.sort,
  })

  return (
    <form method="get">
      <select name="category">
        <option value="shoes">Shoes</option>
        <option value="shirts">Shirts</option>
      </select>
      <button type="submit">Filter</button>
    </form>
  )
}

URL-based state works without JavaScript (form submits, page reloads with new params). With JavaScript, you can intercept the form and update client-side using router.push.

What doesn't work without JavaScript

Be honest about these — don't pretend you can progressively enhance everything:

The practical test

For every page I ship, I run this test: disable JavaScript in the browser and load the page.

  1. Can the user read the content? If not, something is wrong — content should always be HTML.
  2. Can the user navigate to other pages? Links should work. If navigation depends on JS, it's a bug.
  3. Can the user complete the primary action? For a contact page: can they submit the form? For a product page: can they at least see product details?
  4. Do interactive elements degrade gracefully? Tabs should show all content (not hidden behind JS-only tabs). Accordions should be expanded by default.

This test takes 2 minutes per page. It catches real issues — not hypothetical "what if JS fails" scenarios, but actual UX problems for users on slow connections where JS times out.

The honest position

Progressive enhancement isn't about supporting users who intentionally disable JavaScript. It's about resilience for users on bad connections, old devices, or in situations where JS fails to load (CDN error, ad blocker conflict, corporate proxy).

In the RSC era, the default is better than it's ever been for content and forms. The work is in making interactive components degrade gracefully — and being honest about which ones can't.


Building with RSC and want your pages to work for everyone? Let's talk — I ship progressively enhanced applications where the content works even when the JavaScript doesn't.