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
revalidatePathbut 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
| Concern | Where it lives |
|---|---|
| KPIs, table rows, user permissions | Server Component fetch + cache tags |
| Create / update / delete rows | Server Actions + revalidateTag |
| Filter chips, column visibility, panel open | Localized Zustand slice (client leaf only) |
| Form pending / error UI | useActionState (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
searchParamsfor 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:
- Buy me a coffee at buymeacoffee.com/safdarali
- Subscribe to my YouTube channel — it's free; 70+ React & Next.js tutorials
Related reading
More guides on safdarali.in — same author, production-focused.
- Guide
How to Build a Frontend Developer Portfolio That Stands Out
Frontend developer portfolio guide for India — sections, React/Next.js examples, SEO, performance, personal branding, FAQ, and checklist to build and rank.
May 2026Read article →
- Guide
React Server Components vs Client Components — When to Use Which
Practical RSC vs client guide for Next.js App Router — when to use each, real code, bundle before/after, and performance impact.
May 2026Read article →