Next.js ⚡ + Zustand 🐻: A Production-Grade File Structure for Scalable State Management

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 (like User) in types/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 inside lib/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 to localStorage
    • 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.

💪 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! 🚀

Leave a Reply