Hey techies!
If youβve ever wrestled with a messy state management setup in a growing Next.js project, youβre not alone. Iβve been there β new ideas piling up, state logic sprawling across files, and debugging turning into a nightmare.
But fear not! After building my personal project with Next.js and Zustand (what a combo for performance and simplicity!), Iβve crafted a clean, modular, and production-ready file structure that keeps things organized and scalable.
π§± Why Next.js + Zustand?
Next.js: A full-stack React framework with server-side rendering (SSR), static site generation (SSG), and the App Router for optimal performance.
Zustand: A lightweight, hook-based state management library thatβs simple yet powerful β avoiding Redux boilerplate while supporting TypeScript and middlewares like persist
and devtools
.
Together, theyβre a match made in heaven for building fast, maintainable web apps.
But as your app grows, a poorly organized store can turn your codebase into chaos. Letβs fix that.
β οΈ The Problem: State Management Chaos
Initially, my Zustand store was a single file with state, actions, and async logic mashed together. It worked for small features, but as the app grew, debugging became painful, and adding new features felt like playing Jenga with my codebase.
I needed a structure that:
- β Separates concerns (state, actions, async actions, types)
- β Scales with multiple features
- β Integrates seamlessly with Next.jsβs App Router and TypeScript
- β Supports production-grade practices like persistence and debugging
Hereβs the structure I landed on π
ποΈ Production-Grade File Structure
project/
βββ app/
β βββ components/
β β βββ AuthComponent.tsx
β βββ page.tsx
βββ lib/
β βββ services/
β β βββ api/
β β β βββ auth.ts
β β βββ localStorage.ts
β βββ store/
β βββ auth/
β βββ index.ts
β βββ state.ts
β βββ actions.ts
β βββ asyncActions.ts
β βββ types.ts
βββ types/
β βββ index.ts
π§© 1. types.ts
β Define TypeScript Types
// lib/store/auth/types.ts
import { User } from "@/types";
export interface AuthState {
user: User | null;
signinError: string | null;
signupError: string | null;
isSigningIn: boolean;
isSigningUp: boolean;
isSignupDone: boolean;
}
export interface AuthActions {
setUser: (user: User | null) => void;
clearAuth: () => void;
clearErrors: () => void;
}
export interface AuthAsyncActions {
signinWithEmail: (email: string, password: string) => Promise<void>;
signupWithEmail: (name: string, email: string, password: string) => Promise<void>;
}
export type AuthStore = AuthState & AuthActions & AuthAsyncActions;
π‘ Why: Centralizes type definitions for state, actions, and async actions.
Place shared types (likeUser
) intypes/index.ts
to avoid circular imports.
βοΈ 2. state.ts
β Define Initial State
// lib/store/auth/state.ts
import { AuthState } from './types';
export const defaultState: AuthState = {
user: null,
signinError: null,
signupError: null,
isSigningIn: false,
isSigningUp: false,
isSignupDone: false,
};
π‘ Why: Keeps the initial state isolated, making it easy to modify defaults later.
π§ 3. actions.ts
β Define Synchronous Actions
// lib/store/auth/actions.ts
import { StateCreator } from 'zustand';
import { AuthActions, AuthStore } from './types';
export const authActions: StateCreator<AuthStore, [], [], AuthActions> = (set) => ({
setUser: (user) => set({ user }),
clearAuth: () => set({ user: null, signinError: null, signupError: null }),
clearErrors: () => set({ signinError: null, signupError: null }),
});
π‘ Why: Separates synchronous updates from async logic for clarity.
π 4. asyncActions.ts
β Handle Async Operations
// lib/store/auth/asyncActions.ts
import { StateCreator } from 'zustand';
import { signin, signup } from '@/lib/services/api/auth';
import { customLocalStorage } from '@/lib/services/localStorage';
import { AuthAsyncActions, AuthStore } from './types';
type ErrorCode =
| 'INVALID_PASSWORD'
| 'ACCOUNT_NOT_EXISTS'
| 'DIFFERENT_PROVIDER_ACCOUNT'
| 'EMAIL_ALREADY_EXISTS';
const getErrorMessage = (code: ErrorCode): string => {
const messageMap: Partial<Record<ErrorCode, string>> = {
INVALID_PASSWORD: 'The password you entered is incorrect',
ACCOUNT_NOT_EXISTS: 'No account found for this email address',
DIFFERENT_PROVIDER_ACCOUNT: 'This email is linked to a different sign-in method',
EMAIL_ALREADY_EXISTS: 'An account with this email already exists',
};
return messageMap[code] || 'Something went wrong.';
};
export const authAsyncActions: StateCreator<AuthStore, [], [], AuthAsyncActions> = (set) => ({
signinWithEmail: async (email, password) => {
set({ isSigningIn: true, signinError: null });
try {
const response = await signin(email, password);
if (response.data) {
const { accessToken, user } = response.data;
set({ user });
customLocalStorage.setValue('accessToken', accessToken);
}
} catch (err: any) {
const message = getErrorMessage(err.data?.code || 'UNKNOWN');
set({ signinError: message });
} finally {
set({ isSigningIn: false });
}
},
signupWithEmail: async (name, email, password) => {
set({ isSigningUp: true, signupError: null });
try {
const response = await signup(name, email, password);
if (response.data) {
set({ isSignupDone: true });
setTimeout(() => set({ isSignupDone: false }), 5000);
}
} catch (err: any) {
const message = getErrorMessage(err.data?.code || 'UNKNOWN');
set({ signupError: message });
} finally {
set({ isSigningUp: false });
}
},
});
π‘ Why: Isolates async logic, error handling, and state updates.
Keep API calls insidelib/services/api/
.
π§± 5. index.ts
β Combine and Export the Store
// lib/store/auth/index.ts
import { createStore } from 'zustand/vanilla';
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { defaultState } from './state';
import { authActions } from './actions';
import { authAsyncActions } from './asyncActions';
import { AuthStore } from './types';
export const createAuthStore = (initialState = defaultState) =>
createStore<AuthStore>()(
devtools(
persist(
(set, get) => ({
...initialState,
...authActions(set, get),
...authAsyncActions(set, get),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
}
),
{ name: 'AuthStore' }
)
);
const useAuthStore = create<AuthStore>()(createAuthStore());
export default useAuthStore;
π‘ Why: Combines everything with middleware support (
persist
,devtools
).
π» 6. Using the Store in a Component
// app/components/AuthComponent.tsx
"use client";
import useAuthStore from "@/lib/store/auth";
import { FormEvent } from "react";
export default function AuthComponent() {
const {
user,
signinError,
signupError,
isSigningIn,
isSigningUp,
isSignupDone,
signinWithEmail,
signupWithEmail,
} = useAuthStore();
const handleSignin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
await signinWithEmail(email, password);
};
const handleSignup = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
await signupWithEmail(name, email, password);
};
return (
<div className="p-4">
{user ? (
<div>
<h1>Welcome, {user.name}!</h1>
<button onClick={() => useAuthStore.getState().clearAuth()}>
Sign Out
</button>
</div>
) : (
<div>
<h1>Sign In</h1>
<form onSubmit={handleSignin} className="mb-4">
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit" disabled={isSigningIn}>
{isSigningIn ? "Signing In..." : "Sign In"}
</button>
{signinError && <p className="text-red-500">{signinError}</p>}
</form>
<h1>Sign Up</h1>
<form onSubmit={handleSignup}>
<input name="name" type="text" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit" disabled={isSigningUp}>
{isSigningUp ? "Signing Up..." : "Sign Up"}
</button>
{signupError && <p className="text-red-500">{signupError}</p>}
{isSignupDone && <p className="text-green-500">Signup successful!</p>}
</form>
</div>
)}
</div>
);
}
π 7. Integrating with a Next.js Page
// app/page.tsx
import AuthComponent from "@/app/components/AuthComponent";
export default function Home() {
return (
<div>
<h1>My Next.js App with Zustand</h1>
<AuthComponent />
</div>
);
}
π§ 8. Supporting Services
// lib/services/api/auth.ts
export interface User {
id: string;
name: string;
email: string;
}
interface AuthResponse {
data?: { accessToken: string; user: User };
error?: { code: string; message: string };
}
export const signin = async (email: string, password: string): Promise<AuthResponse> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (email === "test@example.com" && password === "password") {
resolve({
data: {
accessToken: "mock-token",
user: { id: "1", name: "Test User", email },
},
});
} else {
reject({ data: { code: "INVALID_PASSWORD" } });
}
}, 1000);
});
};
export const signup = async (name: string, email: string, password: string): Promise<AuthResponse> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (email === "test@example.com") {
reject({ data: { code: "EMAIL_ALREADY_EXISTS" } });
} else {
resolve({ data: { accessToken: "mock-token", user: { id: "2", name, email } } });
}
}, 1000);
});
};
// lib/services/localStorage.ts
export const customLocalStorage = {
setValue: (key: string, value: string) => {
if (typeof window !== "undefined") localStorage.setItem(key, value);
},
getValue: (key: string) => {
if (typeof window !== "undefined") return localStorage.getItem(key);
return null;
},
};
π§° 9. Production-Grade Best Practices
- Type Safety: Always use TypeScript.
-
Middleware:
-
persist
β Saves state tolocalStorage
-
devtools
β Integrates with Redux DevTools
-
Scalability: Create a folder per feature (e.g.,
cart
,user
).-
Next.js Integration:
- Use
"use client"
in components that access Zustand stores. - Pass server data via props for hydration.
- Use
πͺ 10. Why This Structure Rocks
β
Clarity β Each file has a single responsibility.
β
Scalability β Add new features easily by duplicating the folder structure.
β
Maintainability β Debugging is faster when you know exactly where to look.
β
Production-Ready β Works seamlessly with TypeScript, middlewares, and Next.js App Router.
π§ Wrapping Up
This file structure transformed my Next.js + Zustand project from chaos into a well-organized, scalable system. Whether youβre building a small app or a large platform, this setup will keep your codebase clean and future-proof.
π GitHub: harish-20/Taskify
π LinkedIn: Harish Kumar
If you found this helpful, give the repo a β and share your thoughts or improvements below.
Happy coding, techies! π
Top comments (0)