INITIALIZING…
SYSTEMS
00
00
Tushaar Naagar
Back to writing

Frontend

Next.js useOffline — Building Better Offline UX

Sep 10, 20267 min read

Next.js 16.3's experimental useOffline keeps failed navigations, prefetches, and Server Actions pending instead of throwing — then retries when you're back online. Here's how the flag, the hook, and Partial Prefetching fit together.

The failure mode we've all shipped

On a soft navigation, an RSC fetch, or a Server Action, a dropped connection usually means an error boundary, a toast, or a spinner that never resolves. The user clicks again. Sometimes that creates duplicate writes. Sometimes they refresh and lose form state. Teams patch this with navigator.onLine listeners, manual retry buttons, and copy that says 'check your connection' — three different signals that don't agree with what the router is actually doing. Next.js 16.3 started treating connectivity as a framework concern: experimental.useOffline in config, and useOffline from next/offline in Client Components. The hook is small. The behavior change underneath it is not.

Pending beats throwing

With the flag enabled, a failed navigation, prefetch, RSC data fetch, or Server Action request does not immediately surface as a thrown error in your app code. Next.js holds the work in a pending state and retries when connectivity returns. While it waits, the UI looks like a slow server — Suspense fallbacks, loading.tsx, or a pending transition on a form — which is exactly why you need explicit offline messaging. Without useOffline, users assume the backend is broken. With it, you can say the network dropped and the app will resume on its own. That distinction is the whole UX win.

What the framework does for you

Under the hood, Next listens for browser offline and online events, treats failed network work on navigations, prefetches, and Server Actions as a connectivity problem, and polls with HEAD requests and backoff while offline until something succeeds. Blocked requests retry automatically after reconnect — no custom reconnection handler in every page, no hand-rolled queue in localStorage for 'try this POST again.' You still own product decisions: what to show while pending, whether to disable destructive actions, how long to wait before offering a manual retry. The framework owns the plumbing.

Config: one switch, optional stack mates

Enable experimental.useOffline in next.config. The docs' offline-support guide pairs it with cacheComponents and partialPrefetching on purpose: Cache Components let you put Suspense boundaries tight around uncached data while the App Shell renders around them; Partial Prefetching makes that shell the unit Link prefetches. Navigate offline and the prefetched shell can paint immediately; dynamic segments stay blocked until the network returns, then stream in after the automatic retry. You don't need Instant Navigations to try useOffline, but the combination is where 'SPA shell + resilient data' stops being a slide deck and starts being default Next behavior.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    useOffline: true,
  },
};

export default nextConfig;

The hook: boolean, Client Components only

Import useOffline from next/offline. No parameters; returns a boolean. Without experimental.useOffline, the hook always returns false — so you can wire UI early and flip the flag in staging. On the server and during the first paint before hydration completes, it also returns false; the first trustworthy value is whatever the browser reports after mount. That matters for banners: don't flash 'offline' on SSR. isOffline becomes true when the browser fires offline or when a navigation, prefetch, or Server Action fetch fails; it goes false again when a background connectivity check succeeds. That's stricter than navigator.onLine, which only reflects the OS interface — Wi-Fi connected with no upstream internet still reads 'online.'

"use client";

import { useOffline } from "next/offline";

export function OfflineBanner() {
  const isOffline = useOffline();

  if (!isOffline) return null;

  return (
    <div role="status" className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm">
      You&apos;re offline. We&apos;ll retry navigation and saves when you reconnect.
    </div>
  );
}

Offline-aware Suspense and loading states

The awkward middle state is a route you already prefetched: the static shell renders, but dynamic content behind Suspense blocks on the network. To a user, that looks identical to a slow API. Pull useOffline into loading.tsx or a client fallback component and change the message — 'Waiting for network' instead of 'Loading…' — so people don't rage-quit the tab. When connectivity returns, Next retries the blocked fetch and content streams in without a full navigation. Same pattern for dashboards with polling or live data: the shell stays usable; the expensive segment catches up.

Server Actions without a retry circus

Forms that call await myAction(formData) are where offline UX usually falls apart. Today you wrap try/catch, map network errors, and maybe stash payload client-side. With useOffline enabled, the invocation can stay pending until the connection is back, then run once and resolve — no duplicate submit handler, no 'queue failed mutations' library unless your product requires offline-first editing. You should still disable double-submit in the UI and show pending state clearly; automatic retry is not permission to hide a 30-second offline gap. But it removes an entire class of bespoke retry loops from feature code.

"use client";

import { useOffline } from "next/offline";
import { useTransition } from "react";
import { updateProfile } from "./actions";

export function ProfileForm() {
  const isOffline = useOffline();
  const [pending, startTransition] = useTransition();

  function onSubmit(formData: FormData) {
    startTransition(async () => {
      await updateProfile(formData);
    });
  }

  return (
    <form action={onSubmit}>
      {/* fields */}
      <button type="submit" disabled={pending}>
        {pending && isOffline ? "Offline — will save when connected" : pending ? "Saving…" : "Save"}
      </button>
    </form>
  );
}

How I'd roll it out on a real app

Start on a canary with the flag on and a global OfflineBanner in the root layout. Pick one high-churn flow — settings save, checkout step, internal tool form — and add offline-aware loading copy. Test with DevTools offline throttling, not just airplane mode: flaky LTE is the real enemy. Confirm prefetched routes show shell-only offline, then full content after reconnect. Log how long requests stay pending; if your users routinely go offline for minutes, you still need product rules (cancel, draft locally, read-only mode). useOffline optimizes reconnect, not full offline-first CRUD.

What this is not (yet)

useOffline is not a service worker, not a local database, and not a guarantee that every API route magically queues. It targets App Router client navigations, RSC fetches, prefetches, and Server Actions — the paths Next controls. REST calls from useEffect with fetch still need your own strategy. Full offline-first editing (draft in IndexedDB, sync on reconnect, conflict resolution) remains a product architecture problem. The experimental flag is also a warning: retry semantics and edge cases will change. Read the official offline-support guide when you implement; treat blog posts — including this one — as orientation, not a spec.

Experimental — read the release notes, not this post

The Next.js docs are explicit: experimental.useOffline can change and is not recommended for production yet. File feedback on GitHub if you hit bugs — that's how experimental features graduate. PWAs, service workers, and asset caching are adjacent but separate; this feature is about the tab still running your JS while the network doesn't. Keep prod on stable defaults until the flag stabilizes; use side projects and staging to learn the mental model now so you're not retrofitting banners the week it goes stable.

Takeaway

useOffline is a small API on top of a bigger idea: resilient web apps shouldn't require every team to reinvent offline detection, pending navigations, and Server Action retries. Enable the config, read the boolean in Client Components, and design Suspense and form pending states for 'offline but recovering' — not just 'loading.' Network reliability will never be perfect; making recovery automatic and visible is how you earn trust on flaky connections.