Jacob Paris
← Back to all content

Progressively enhanced client rendering to avoid SSR hydration issues in Remix.

For all the benefits of serverside rendering, there are also things it makes more difficult, like showing the same date in the client and the server.

Any time there's a mismatch between what the server can render and what the client can render, you're going to run into issues.

Sites that handle this poorly end up with a flash of unstyled content (FOUC) or a flash of incorrect content (FOIC). You might get hydration errors and cause your site to bail on serverside rendering entirely.

Some people try to solve this by only rendering certain elements on the client. That's a sure way to avoid the mismatch, but it also means that users without javascript will never see that content.

Users without javascript will get whatever version of the page is rendered on the server, so if you want progressive enhancement, the server MUST render the content.

That said, javascript is going to work for most users of most websites, so that's the case we'll want to optimize for. We'll want to make sure the site works without Javascript, but I'm ok making that experience a bit clunky if it means the site works well for the majority of users.

Since we MUST render the content on the server, but we don't want to show it to the user right away, we need to hide it somehow. Javascript solutions don't work here. Even if you tell React to hide it immediately, there will still be that flash of the server rendered page before React takes over.

What we're left with is a CSS solution.

I've built a custom wrapper component that hides its contents until the page loads. If JS is disabled, it will reveal them with a CSS animation. If not, you can customize the behavior by passing in a class name.

import { useHydrated } from "remix-utils"
export function ProgressiveClientOnly({
className = "",
}: {
children: React.ReactNode | (() => React.ReactNode)
className: string
}) {
const isHydrated = useHydrated()
return (
// Create this class in your tailwind config
isHydrated ? className : "animate-appear"
{typeof children === "function"
? children()
: children}

The animate-appear class is a custom CSS animation that makes the element appear suddenly after a delay.

module.exports = {
theme: {
extend: {
animation: {
appear: "appear 300ms",
keyframes: {
appear: {
"0%, 99%": {
height: "0",
width: "0",
opacity: "0",
"100%": {
height: "auto",
width: "auto",
opacity: "1",

An input with a default value from local storage

Imagine you have a long form and you want to save the user's progress as they fill it out. Maybe the data is sensitive, or you want it to work offline, so you save to local storage while they're working on it.

The server doesn't have access to local storage, so at the moment the page loads, that input is going to be empty. You can immediately populate it with the value from local storage, but you're going to get the empty input for a split second.

Refresh the page while looking at these inputs. The ones marked "progressive" are wrapped in the ProgressiveClientOnly component and set to fade in.

Without JS, the local storage value is never populated, so the user will have to fill out the form from scratch, even if there's many many inputs on the page. There's definitely value in trying to make this work for most users.

But the default solution has a noticeable flash of an empty input before the text fills in. The slower the user's network connection is, and the longer your javascript bundle takes to load and parse, the longer that flash will be.

The progressive solution hides the flash by fading in the surrounding content at the same time as the input, so it's less jarring.

Here's the code for the progressive version, that wraps all the content that should fade in together.

<ProgressiveClientOnly className="animate-fade">
<label className="flex flex-col">
Progressive with JS
<input type="text" defaultValue={localStorageValue} />

A custom file input with a native fallback

If you want your users to get a nice file drop zone that shows image thumbnails and lets them drag and drop files, the only way JS-free users will still be able to use the app is if you show them a native file input.

But native file inputs are ugly, so we don't want them to flash on the screen before our custom input takes over.

We can use the ProgressiveClientOnly component, but rather than fading in the content, we hide it entirely. As long as React loads before the animate-appear class has time to take effect, the content will never be visible.

<FancyFileDropper />
<ProgressiveClientOnly className="sr-only">
<input type="file" />

This example shows the effect much more prominently. The most common case of JS loading looks terrible without the progressive enhancement. On the other hand, the zero JS cases, which are less common, are still usable here.

Many devs would avoid the flash by dropping the native input entirely and break the app for all users without javascript.

A date that's different between server and client

Dates are a common source of mismatch between server and client. Probably the most common. If you're getting hydration errors, and it's not a browser extension, it's probably a date.

There is a whole world of complexity here if you want accurate dates across timezones, but for the sake of this example, let's assume you're just trying to show the user a date and you don't want it to visibly flash different values.

Here we're assuming you have a date in your loader data that you want to display to the user. To help illustrate the conflict between server and client dates, we'll hardcode the server date to the first of January 2023 and use the client date as the current date.

You can use the ProgressiveClientOnly component to display only the client-rendered date but fall back to the server-rendered date if the user has JS disabled.

If you refresh the page, you'll see the default JS case is actually the worst here. The server date is shown for a split second before the client date takes over.

Both the progressive and the zero JS cases are better, since they just pick a date and stick with it. The zero JS case will lock to whatever the server date is, while the progressive case will lock to whatever the client date is.

Professional headshot

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.