Back to Next Js

Handling connectivity drops

docs/01-app/02-guides/offline-support.mdx

16.3.014.6 KB
Original Source

Network failures during a soft navigation, a data fetch, or a mutation throw errors in the client. Without explicit handling, the UI either breaks or you have to build a fallback UI that asks the user to retry.

With experimental.useOffline enabled, a failed navigation, RSC data fetch, prefetch, or Server Action no longer throws when the network is down. Next.js keeps it pending and retries it once the connection returns.

While the request is pending, the UI sits in its loading state (a Suspense fallback, or a pending transition for a Server Action), which looks the same as a slow server. Use the useOffline hook to give users feedback when the app is offline.

Requests you issue directly with fetch() inside a Client Component, or through a client-side data library like React Query or SWR, stay under that library's own retry policy. See How retry works for the framework's detection and polling behavior.

Example

We will build a live-metrics page that fetches fresh data on every request, plus a ping form that calls a Server Action.

The companion demo has two versions of this dashboard: /without-feedback uses a generic loading fallback, /with-feedback uses a connectivity-aware one. The next sections of this guide walk through building each.

Enable offline detection and see the default behavior

Turn on experimental.useOffline. This guide also enables Cache Components and Partial Prefetching. Cache Components lets you place the Suspense boundary as close as possible to the uncached data, with the App Shell rendered around it. Partial Prefetching makes that App Shell the unit a <Link> prefetches, so it is ready to render when a navigation happens offline.

Without Cache Components, a route-level loading.tsx gives you the same offline behavior at the segment level. See Without Cache Components below.

ts
import type { NextConfig } from 'next'

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

export default nextConfig
js
module.exports = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    useOffline: true,
  },
}

You also need a source page with a <Link> to the dashboard. Next.js prefetches the App Shell of any <Link> that enters the viewport, and that prefetch is what makes the shell available offline.

tsx
import Link from 'next/link'

export default function Home() {
  return (
    <nav>
      <Link href="/dashboard">Dashboard</Link>
    </nav>
  )
}
jsx
import Link from 'next/link'

export default function Home() {
  return (
    <nav>
      <Link href="/dashboard">Dashboard</Link>
    </nav>
  )
}

Then build the dashboard itself: a static shell and an uncached data section inside a <Suspense> boundary. getLiveMetrics is an uncached function that makes a fetch request to an endpoint; every call hits the network.

tsx
import { Suspense } from 'react'
import { getLiveMetrics } from '../lib/data'

export default function Dashboard() {
  return (
    <section>
      <h1>Live metrics</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <MetricsTable />
      </Suspense>
    </section>
  )
}

async function MetricsTable() {
  const { services } = await getLiveMetrics()
  // render services
}
jsx
import { Suspense } from 'react'
import { getLiveMetrics } from '../lib/data'

export default function Dashboard() {
  return (
    <section>
      <h1>Live metrics</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <MetricsTable />
      </Suspense>
    </section>
  )
}

async function MetricsTable() {
  const { services } = await getLiveMetrics()
  // render services
}

Load the home page. With the <Link> in the viewport, the dashboard's static shell is prefetched. Go offline (see Testing below) and click through to /dashboard.

The title and container render from the static shell. The Loading... fallback stays on screen indefinitely because the uncached getLiveMetrics() call cannot complete. The user sees the same spinner they would see for a slow server.

Toggle back to Online. The metrics table streams in automatically. Next.js retried the request on its own, no client code involved.

[!NOTE] This feature only applies to soft navigations into prefetched routes and Server Action calls from the current page. A full page reload while offline still fails because the browser needs the network to deliver the HTML; full offline loads would need a service worker (see the Progressive Web Apps guide).

Next, replace the generic fallback UI with one that reads the connectivity state.

Report connectivity inside the Suspense fallback

useOffline returns true when the browser fires an offline event or when a navigation, prefetch, or Server Action fetch fails. It flips back to false when a background connectivity check succeeds. This is more reliable than navigator.onLine, which only reflects the OS network interface and still reports true for a device on WiFi with no upstream internet.

Create a client component that picks its message based on the hook.

tsx
'use client'

import { useOffline } from 'next/offline'

export function ConnectivityFallback() {
  const isOffline = useOffline()

  return (
    <p>
      {isOffline
        ? 'Waiting for connection to load this section...'
        : 'Loading...'}
    </p>
  )
}
jsx
'use client'

import { useOffline } from 'next/offline'

export function ConnectivityFallback() {
  const isOffline = useOffline()

  return (
    <p>
      {isOffline
        ? 'Waiting for connection to load this section...'
        : 'Loading...'}
    </p>
  )
}

[!NOTE] useOffline returns false during server-side rendering and initial hydration. The first accurate value is whatever the browser reports after the app mounts.

Pass it as the Suspense fallback.

tsx
import { Suspense } from 'react'
import { getLiveMetrics } from '../lib/data'
import { ConnectivityFallback } from './connectivity-fallback'

export default function Dashboard() {
  return (
    <section>
      <h1>Live metrics</h1>
      <Suspense fallback={<ConnectivityFallback />}>
        <MetricsTable />
      </Suspense>
    </section>
  )
}
jsx
import { Suspense } from 'react'
import { getLiveMetrics } from '../lib/data'
import { ConnectivityFallback } from './connectivity-fallback'

export default function Dashboard() {
  return (
    <section>
      <h1>Live metrics</h1>
      <Suspense fallback={<ConnectivityFallback />}>
        <MetricsTable />
      </Suspense>
    </section>
  )
}

Navigating to the dashboard while offline, the fallback now reads "Waiting for connection to load this section..." Restore connectivity and the metrics stream in as the fallback disappears.

The fallback only shows on this page, and only while its Suspense boundary is waiting. In this app, we add a banner in the root layout so connectivity state is visible everywhere.

tsx
'use client'

import { useOffline } from 'next/offline'

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

  if (!isOffline) {
    return null
  }

  return (
    <div role="status">
      Offline. Pending requests will retry once you are back online.
    </div>
  )
}
jsx
'use client'

import { useOffline } from 'next/offline'

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

  if (!isOffline) {
    return null
  }

  return (
    <div role="status">
      Offline. Pending requests will retry once you are back online.
    </div>
  )
}

Add it to the root layout.

tsx
import { OfflineBanner } from './offline-banner'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <OfflineBanner />
        {children}
      </body>
    </html>
  )
}
jsx
import { OfflineBanner } from './offline-banner'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <OfflineBanner />
        {children}
      </body>
    </html>
  )
}

The banner shows on every route while offline, and hides when connectivity returns.

For most apps, the pending loading state is enough. Add a banner in the root layout to communicate the connectivity state across the app. To surface that state right where the content is loading, make the route's Suspense fallback itself offline-aware with useOffline.

The same pattern extends to parameterized routes. A route like /chats/[id] renders its shared App Shell when you navigate to /chats/42 offline, and the dynamic messages behind its <Suspense> boundary load when the connection returns.

If the route also prefetches its per-link URL data ahead of the click, /chats/42 renders its messages from that prefetch immediately, even offline, instead of waiting for the connection to return. See runtime prefetching to learn more.

Retry Server Actions after the network returns

Without the flag, a Server Action called with no network throws a fetch error and the awaited promise rejects. Your form has to catch the rejection and decide what to do: show an error, retry, or queue it somewhere.

With experimental.useOffline enabled, that failure never reaches your code. The call stays pending until the connection returns, the request runs again, and the awaited promise resolves with the server's response. No try/catch, no retry loop, no reconnection handler in the component.

The button is still going to sit there looking frozen, though. Combine useTransition with useOffline to give it an offline-aware label.

ts
'use server'

export async function ping(): Promise<string> {
  return new Date().toISOString()
}
js
'use server'

export async function ping() {
  return new Date().toISOString()
}
tsx
'use client'

import { useState, useTransition } from 'react'
import { useOffline } from 'next/offline'
import { ping } from './actions'

export function PingForm() {
  const [pongs, setPongs] = useState<string[]>([])
  const [pending, startTransition] = useTransition()
  const isOffline = useOffline()

  function handleSubmit() {
    startTransition(async () => {
      const pong = await ping()
      setPongs((prev) => [pong, ...prev])
    })
  }

  const label = pending
    ? isOffline
      ? 'Pinging (offline, will retry)...'
      : 'Pinging...'
    : 'Ping'

  return (
    <form action={handleSubmit}>
      <button type="submit" disabled={pending}>
        {label}
      </button>
      <ul>
        {pongs.map((t) => (
          <li key={t}>{t}</li>
        ))}
      </ul>
    </form>
  )
}
jsx
'use client'

import { useState, useTransition } from 'react'
import { useOffline } from 'next/offline'
import { ping } from './actions'

export function PingForm() {
  const [pongs, setPongs] = useState([])
  const [pending, startTransition] = useTransition()
  const isOffline = useOffline()

  function handleSubmit() {
    startTransition(async () => {
      const pong = await ping()
      setPongs((prev) => [pong, ...prev])
    })
  }

  const label = pending
    ? isOffline
      ? 'Pinging (offline, will retry)...'
      : 'Pinging...'
    : 'Ping'

  return (
    <form action={handleSubmit}>
      <button type="submit" disabled={pending}>
        {label}
      </button>
      <ul>
        {pongs.map((t) => (
          <li key={t}>{t}</li>
        ))}
      </ul>
    </form>
  )
}

Clicking Ping while offline disables the button and changes its label to "Pinging (offline, will retry)...". Restoring connectivity resolves the awaited ping() call, appends the timestamp to the list, and reverts the label to "Ping". No second click, no client-side retry code.

[!NOTE] While offline, clicking a link during a pending Server Action may appear to do nothing. The link's navigation also needs the network and queues behind the same connectivity signal as the action. Both resolve when the connection returns.

Testing

Test this feature with next build && next start. Dev mode is not a reliable reference for offline behavior.

In Chrome, use DevTools > Network > Offline; in Firefox, use the Network Monitor's throttling menu. For a real-world test, toggle airplane mode on your laptop or phone, disconnect WiFi, or unplug the network cable.

Without Cache Components

A route-level loading.tsx does the same job. It gives Next.js a boundary to prefetch as the route's shell, so the shell renders offline and the page resumes once the network returns. For how loading.tsx prefetching works, see Prefetching. The useOffline hook, banner, and Server Action retry all behave the same way.

Next steps