How to do state management on the server side of Next.js

In Next.js 15, server-side state management can be handled in a few different ways depending on your use case — especially with the App Router (introduced in Next 13) and React Server Components (RSC) becoming more central.
Here’s a breakdown of how to do server-side state management effectively in Next.js 15:
Server-Side State Management Approaches
1.Using React Server Components (RSC)
Next.js 15 supports Server Components by default in the app/ directory.
You can “manage state” by fetching data on the server and passing it as props to client components (if needed).
// app/page.tsx (server component)
import { getUserData } from '@/lib/api';
import UserCard from './UserCard';
export default async function Page() {
const user = await getUserData(); // server-side state/data
return (
<div>
<h1>Welcome back!</h1>
<UserCard user={user} />
</div>
);
}
This avoids needing global state libraries entirely unless you’re doing complex client-side state interactions.
2.Storing State in Middleware or Cookies (Session-like)
If your “state” is user/session-based, use:
Middleware for request-level logic
Cookies (next/headers or next/response-cookies) for storing server-accessible state
// app/api/route.ts
import { cookies } from 'next/headers';
export async function GET() {
const userToken = cookies().get('token')?.value;
// Use token to fetch user data
}
This is great for:
Auth state
User preferences
Locale/language settings
3.API Routes + Database or Cache Layer
For persistent or sharable state, keep it in:
Postgres / MongoDB
Redis / Upstash
Edge config
Then access it via:
// app/api/settings/route.ts
import { db } from '@/lib/db';
export async function GET() {
const settings = await db.getUserSettings();
return Response.json(settings);
}
When You Do Need Client-Side State (and Bridge It)
You can combine server-side logic with a hydrated client component:
// app/page.tsx
import ClientCounter from './ClientCounter';
export default function Page() {
return <ClientCounter initial={5} />;
}
'use client';
export default function ClientCounter({ initial }: { initial: number }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
Tools to Help
next/headers, next/response-cookies: Manage request/response state
cache() from React or Next.js: For memoizing server-side data
Zustand or Redux (if you must use client-global state)
TL;DR: Server State in Next.js 15
Use Case | Approach |
Data fetched on request | Server Components + fetch |
Persisted state (auth, prefs) | Cookies / Middleware |
Shared state across components | React Context (server or client) |
Client interactivity | Hydrated client components |
Heavy state mgmt | Zustand / Redux (client) |
Bonus Round
URL query parameters as a form of state management in Next.js 15, in the App Router.
This is actually a super clean and powerful way to manage state — especially when you want the state to be shareable, bookmarkable, and synchronized with navigation (like filters, tabs, pagination, etc.).
In a Server Component
You can access query parameters using the searchParams prop (available in server components):
// app/page.tsx (Server Component)
type Props = {
searchParams: { [key: string]: string | string[] | undefined };
};
export default function Page({ searchParams }: Props) {
const tab = searchParams.tab ?? 'overview';
return <div>Current Tab: {tab}</div>;
}
No client state needed — the URL is the state.
In a Client Component
Use useSearchParams() from next/navigation to read query params reactively:
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
export default function TabSelector() {
const searchParams = useSearchParams();
const router = useRouter();
const activeTab = searchParams.get('tab') || 'overview';
const changeTab = (tab: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tab);
router.push(`?${params.toString()}`);
};
return (
<>
<button onClick={() => changeTab('overview')}>Overview</button>
<button onClick={() => changeTab('analytics')}>Analytics</button>
<p>Current tab: {activeTab}</p>
</>
);
}
When should you use this?
Use case | Use query params? |
Filters on a page (search, tags) | ✅ Yes |
Pagination | ✅ Yes |
Tabs or views | ✅ Yes |
Auth or sensitive state | ❌ No |
Transient UI state (hover, drag) | ❌ No |
Bonus: Deep Linking
This method makes it easy to:
Share filtered views via URLs
Maintain state when the user refreshes
Let users hit “Back” and go to the previous state
Avoid relying on heavy client state management