butr
Guides

SSR without the hydration flash

Seed persisted wallet state from cookies so the first server render shows the connected shell.

When a user lands on a page with a wallet already connected, the default butr setup produces a brief flash: the server renders the empty pre-hydration UI, ships HTML to the browser, the client mounts, hydration runs, and the UI flips to "connected." This guide persists to cookies and passes the server a snapshot, so the first paint already shows the connected shell.

The pattern works with any SSR React framework. Concrete code below uses Next.js' App Router; the principles transfer directly to TanStack Start, Remix, Astro, or anything else that can read request cookies before rendering.

Why this needs two pieces

  1. Persistence the server can read. By default butr persists to localStorage, which never reaches the server. createCookieWalletStorage puts the pool, selection, and active id in cookies that ride every request.
  2. A server-safe view of that state. A wallet adapter can only exist in the browser, so the server can't build real connections. The same createCookieWalletStorage result has readInitialState(cookies), which decodes the cookies into a WalletSnapshot that names the wallets and their accounts without an adapter, and <WalletManagerProvider initialState={…}> seeds the manager with it synchronously.

Both pieces come from one call, over one key layout and one decoder, so the server cannot read a state the client did not write.

Step 1 — Storage: one module for server and client

Create the storage in a module without "use client", because the Server Component needs the object itself.

A value imported from a "use client" module into a Server Component is a client reference, not the value. Keep walletStorage in its own module, imported by both sides.

src/wallet-storage.ts
import { createCookieWalletStorage } from "@usebutr/core";

export const walletStorage = createCookieWalletStorage({
  keyPrefix: "my-app",
  // Local dev runs on http://localhost, where the browser drops Secure cookies.
  secure: process.env.NODE_ENV === "production",
});
src/wallet-provider.tsx
"use client";

import type { WalletManagerConfig, WalletSnapshot } from "@usebutr/core";
import { discoverEvmAdapters } from "@usebutr/evm";
import { WalletManagerProvider } from "@usebutr/react";
import type { ReactNode } from "react";

import { walletStorage } from "./wallet-storage";

const config: WalletManagerConfig = {
  sources: [discoverEvmAdapters],
  storage: walletStorage,
};

type WalletProviderProps = {
  children: ReactNode;
  initialState?: WalletSnapshot;
};

export const WalletProvider = ({ children, initialState }: WalletProviderProps) => (
  <WalletManagerProvider config={config} initialState={initialState}>
    {children}
  </WalletManagerProvider>
);

Three notes:

  • Why cookies hold only part of the state. The pool, selection, and active id survive reloads and go to cookies. The disconnect intent is session-scoped, which cookies can't model, so it stays in sessionStorage.
  • Why secure: false in dev. Secure cookies are only sent over HTTPS. Local development on http://localhost would otherwise silently fail to persist anything.
  • Why module scope is safe. Creating the storage touches no browser global: it reads and writes document.cookie only when the manager calls it, and the manager touches storage only after it starts, in an effect. The server's copy of the module is never asked to load or save. config and initialState are each read once, at mount.

Step 2 — Server Component: read cookies, build the snapshot

In the Server Component that wraps the wallet provider (typically your root layout), read the request cookies with the same storage:

src/app/layout.tsx
import { cookies } from "next/headers";
import type { ReactNode } from "react";

import { WalletProvider } from "../wallet-provider";
import { walletStorage } from "../wallet-storage";

const RootLayout = async ({ children }: { children: ReactNode }) => {
  const cookieStore = await cookies();
  const initialState = walletStorage.readInitialState(cookieStore.getAll());

  return (
    <html lang="en">
      <body>
        <WalletProvider initialState={initialState}>{children}</WalletProvider>
      </body>
    </html>
  );
};

export default RootLayout;

Only the parsed snapshot crosses into the client component, never the cookie jar, so your httpOnly session cookies stay on the server.

readInitialState accepts three input shapes. Pick whichever your framework hands you:

// Plain object (Hono, a parsed Cookie header, generic Node)
walletStorage.readInitialState({ "my-app-pool": "{...}", "my-app-active": "io.metamask" });

// { name, value }[] (Next.js cookies().getAll())
walletStorage.readInitialState(cookieStore.getAll());

// [name, value][] (a Map or any iterable of pairs)
walletStorage.readInitialState(entries);

Step 3 — Component: render normally

With initialState there is no separate shell to write. The manager is already hydrated on the first render, the pool is populated, and the primary hooks return values on both server and client:

src/app/page.tsx
"use client";

import { useConnectedWallets } from "@usebutr/react";

const Page = () => {
  const wallets = useConnectedWallets();

  if (wallets.length === 0) {
    return <ConnectButton />;
  }

  return (
    <ul>
      {wallets.map((wallet) => (
        <WalletCard key={wallet.connector.id} wallet={wallet} />
      ))}
    </ul>
  );
};

Until the live adapter arrives, a seeded wallet is backed by a shadow adapter that carries data but has none of the optional methods: signMessage, sendTx, and switchChain are absent. A presence check alone would hide those controls and then pop them in. Render them disabled while reconnecting instead, so the card keeps its geometry:

import type { ConnectedWallet } from "@usebutr/core";
import { useBalance, useIsReconnecting } from "@usebutr/react";

const WalletCard = ({ wallet }: { wallet: ConnectedWallet }) => {
  const reconnecting = useIsReconnecting(wallet.connector.id);
  const balance = useBalance(wallet);
  const canSign = reconnecting || wallet.connector.signMessage !== undefined;

  return (
    <div className="card" aria-busy={reconnecting}>
      <h3>{wallet.connector.name}</h3>
      <p>{wallet.account.walletAddress}</p>
      <p>{balance.status === "success" ? balance.data.formatted : "—"}</p>
      {canSign ? (
        <button disabled={reconnecting} type="button">
          Sign
        </button>
      ) : null}
    </div>
  );
};

useSigner() and useBalance() stay "idle" for a seeded wallet rather than reporting an error, and start loading once the live adapter is announced. useConnectionStatus() returns "reconnecting" over the same window, then "connected".

Anti-pattern: transient text

A tempting addition is a "Restoring connection…" line that renders only while reconnecting. Don't. That line ships in the SSR HTML and then disappears, and the disappearance is itself a flash. Use aria-busy so screen readers announce the state without anything visible flashing away, or put the hint inside an element that exists in both states and has its own lifecycle (next to the balance while useBalance() is loading, for example).

What you can't avoid

The snapshot only carries what the cookie holds: account, accounts, chainPlatform, connectorId, name, and icon. Anything that needs a live adapter appears progressively:

ValueSourceAvailable on first paint?
Address, accounts, chain ID/namewallet.accountyes
Wallet id, name, iconpersisted pool entryyes
Balancelive adapter (EVM)no, show — then the live value
Optional methods (signMessage, switchChain)live adapterno, render the controls disabled

Design around the "yes" rows and reserve space for the rest, so the card geometry stays stable and only the inner pixels update.

The snapshot reflects whatever the browser most recently persisted. If between page loads the user locked the wallet, disconnected in another tab, or uninstalled the extension, the cookie lags reality:

  1. A locked or revoked wallet fails its silent reconnect. The UI shows "MetaMask · 0xabc…" for a moment, then the manager drops the entry from the pool and the UI flips to disconnected. The entry stays persisted, so the next load tries again. This happens only when the cookie is genuinely wrong, not on every page load.

  2. An uninstalled wallet never announces an adapter, so its entry stays reconnecting: there is no timeout for a wallet that has not appeared. onHydrated lists it in pendingIds; call disconnect(id) if your UI should stop waiting for it.

  3. The cookie is not a security boundary. It's HttpOnly: false by construction (the client needs to read and write it). Don't put anything in there you wouldn't put in localStorage.

Gate sign affordances on useIsReconnecting() and use aria-busy so the transient state is announced without anything visible flashing away.

Each pool entry is a JSON blob containing the active account, the full accounts list, and chain metadata. For a typical "one or two wallets connected" session this lands well under the 4KB per-cookie ceiling. Apps with users routinely connecting many wallets across many chains should monitor cookie size and consider:

  • A server-side session store (the cookie holds a short session id; the pool blob lives in Redis / Postgres) behind your own WalletPersistence.
  • A custom storage driver that compresses the pool JSON before write.

What this opts you out of

Using cookies() in a Server Component opts that route out of static prerendering — Next.js marks it ƒ (Dynamic). This is inherent to any cookie-aware SSR, not a butr-specific cost. If your route was statically prerendered before and you want to keep it that way, you can either:

  • Move the cookie reads to a nested layout that doesn't include the static parts of the page.
  • Use a route-level segment cache (export const dynamic = "auto") and accept per-request rendering of the wallet-aware sections only.

See also

Source: synchronous seeding in ADR 0003 and the persisted-state wire format in ADR 0005. See the Next.js guide and synchronous seeding.