DEV Community

Cover image for Next.js ⚑ + Zustand 🐻: A Production-Grade File Structure for Scalable State Management
Harish Kumar
Harish Kumar

Posted on

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
Enter fullscreen mode Exit fullscreen mode

🧩 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;
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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,
};
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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 }),
});
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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 });
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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;
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🌍 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ”§ 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);
  });
};
Enter fullscreen mode Exit fullscreen mode
// 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;
  },
};
Enter fullscreen mode Exit fullscreen mode

🧰 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! πŸš€

Top comments (0)