Skip to main content

Jun 2026 · Guide · ~10 min read

Stop Using Global State for Server Data in Next.js

By Safdar Ali — frontend engineer, Bengaluru

I inherited a Next.js dashboard with a single Redux store holding everything: user profile, KPI metrics, table rows, filter selections, modal open state, and toast queue. Every navigation re-fetched into the same global slice. Every Server Component upgrade fight started with "but the store expects client data on mount."

That architecture made sense in 2019. In 2026 with App Router, React 19 Server Actions, and fetch caching, it is an anti-pattern for server-owned data. This post shows the refactor I ship now: server data stays on the server; Zustand holds a tiny UI-only slice; mutations go through Actions + useActionState.

What went wrong with global state for server data

  • Duplicate sources of truth — cache tag says fresh, Redux still shows stale rows
  • Hydration cost — entire store rehydrates on every route with a provider wrapper
  • Context/Provider hell — 400-line layout client boundary just to read KPIs
  • Server Actions call revalidatePath but UI waits for manual dispatch

Tooling comparison: Zustand vs Redux Toolkit — Redux still wins on huge teams with middleware ecosystems; for new App Router dashboards, global server data in either store is the mistake.

Target architecture

ConcernWhere it lives
KPIs, table rows, user permissionsServer Component fetch + cache tags
Create / update / delete rowsServer Actions + revalidateTag
Filter chips, column visibility, panel openLocalized Zustand slice (client leaf only)
Form pending / error UIuseActionState (React 19)

Before vs after (dashboard)

Before — global provider

// app/layout.tsx — entire app client because of store
"use client";
import { Provider } from "react-redux";
import { store } from "@/store";

export default function RootLayout({ children }) {
  return (
    <Provider store={store}>
      {children}
    </Provider>
  );
}

// store/dashboardSlice.ts — server data duplicated
const fetchMetrics = createAsyncThunk("dashboard/fetch", async () => {
  const res = await fetch("/api/metrics");
  return res.json();
});

After — server page + UI slice

// app/dashboard/page.tsx — Server Component
import { getMetrics, getRows } from "@/lib/data/dashboard";
import { DashboardShell } from "./DashboardShell";

export default async function DashboardPage({ searchParams }) {
  const [metrics, rows] = await Promise.all([
    getMetrics(),
    getRows(searchParams),
  ]);

  return <DashboardShell metrics={metrics} rows={rows} />;
}

Localized Zustand slice (UI only)

// stores/dashboard-ui.ts — import ONLY in client leaves
import { create } from "zustand";

type DashboardUiState = {
  columnIds: string[];
  sidePanelOpen: boolean;
  toggleColumn: (id: string) => void;
  setPanelOpen: (open: boolean) => void;
};

export const useDashboardUi = create<DashboardUiState>((set) => ({
  columnIds: ["name", "status", "updated"],
  sidePanelOpen: false,
  toggleColumn: (id) =>
    set((s) => ({
      columnIds: s.columnIds.includes(id)
        ? s.columnIds.filter((c) => c !== id)
        : [...s.columnIds, id],
    })),
  setPanelOpen: (open) => set({ sidePanelOpen: open }),
}));

No metrics. No rows. No user object. That is Zustand local state synchronization done right — the slice never tries to mirror the database.

Server Actions + useActionState for mutations

Full Server Actions patterns: production guide. Dashboard row archive example:

// app/dashboard/actions.ts
"use server";

import { revalidateTag } from "next/cache";
import { auth } from "@/lib/auth";

export async function archiveRow(_prev: ActionState, formData: FormData) {
  const session = await auth();
  if (!session) return { ok: false, message: "Unauthorized" };

  const id = String(formData.get("rowId"));
  await db.row.archive(id);
  revalidateTag("dashboard-rows");
  return { ok: true, message: "Archived" };
}
// ArchiveButton.tsx — client leaf
"use client";

import { useActionState } from "react";
import { archiveRow } from "../actions";

export function ArchiveButton({ rowId }: { rowId: string }) {
  const [state, action, pending] = useActionState(archiveRow, { ok: true, message: "" });

  return (
    <form action={action}>
      <input type="hidden" name="rowId" value={rowId} />
      <button disabled={pending} type="submit">
        {pending ? "Archiving…" : "Archive"}
      </button>
      {!state.ok && <p role="alert">{state.message}</p>}
    </form>
  );
}

After success, revalidateTag refreshes server props — no Redux dispatch, no manual sync effect between store and server.

Next.js fetch caching vs global state

The question I get most: "Should I cache API responses in Zustand?" Almost never on App Router.

  • fetch(url, { next: { tags: ['dashboard-rows'] } }) — server cache invalidates with Actions
  • URL searchParams for filters — shareable, server-rendered lists
  • Zustand for ephemeral UI that should not hit the URL (column picker, drawer open)

That split is the core of React 19 state management patterns on Next.js: server owns truth; client owns interaction chrome.

Composed dashboard shell

// DashboardShell.tsx — "use client" but thin
"use client";

import { useDashboardUi } from "@/stores/dashboard-ui";
import { MetricsBar } from "./MetricsBar";
import { DataTable } from "./DataTable";

export function DashboardShell({ metrics, rows }) {
  const columnIds = useDashboardUi((s) => s.columnIds);

  return (
    <>
      <MetricsBar data={metrics} /> {/* props from server — read-only */}
      <DataTable rows={rows} visibleColumns={columnIds} />
    </>
  );
}

Client JS dropped ~90 KB gzip on this route after removing RTK + thunks. INP improved because hydration no longer replayed store middleware on every click.

When global client state still makes sense

  • Cross-route UI: theme toggle, command palette, audio player
  • Optimistic UI spanning multiple client islands before Action completes (narrow slice)
  • Offline-first PWA — genuinely client-owned data

Server-backed KPIs and CRUD tables are not on that list.

Closing

Next.js Server Actions with Zustand is not either/or — it is a boundary decision. Stop mirroring server data in global stores; use fetch cache + Actions for truth and a small Zustand slice for UI that only the browser should remember. Your layout stays a Server Component; your vitals thank you.

If this helped you

I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:

More guides on safdarali.in — same author, production-focused.

"Talk is cheap. Show me the code."