DEV Community

Rufat Aliyev
Rufat Aliyev

Posted on

1 1

Feature-Driven Architecture with Next.js: A Better Way to Structure Your Application

Today I want to share with you a structure I've been perfecting through years of Next.js development.

As applications grow in complexity, the traditional approach of organizing code by technical layers (components, hooks, services) quickly becomes unwieldy. After leading multiple large-scale Next.js projects, I've refined a feature-driven architecture that has consistently improved maintainability, team coordination, and development velocity.

The Problem with Technical Layering

Most tutorials and starter templates organize Next.js applications like this:

src/
├── components/
├── hooks/
├── services/
├── utils/
├── pages/ (or app/)
└── styles/
Enter fullscreen mode Exit fullscreen mode

This works fine for small projects, but as your application grows, you'll notice several issues:

  1. Scattered related files: Components, hooks, and services that work together are spread across different directories
  2. Ownership challenges: Teams struggle to own parts of the codebase as features cross-cut directories
  3. Dependency creep: Without clear boundaries, dependencies between features become implicit and tangled
  4. Refactoring difficulty: Changing a feature requires modifying multiple directories

Enter Feature-Driven Architecture

The core principle is simple: organize code by what it does, not what it is.

Here's the structure I've found most effective:

src/
├── app/            # Next.js App Router pages and layouts
├── components/     # Truly shared UI components
├── features/       # Feature modules
│   ├── auth/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── services/
│   │   └── types/
│   ├── posts/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── services/
│   │   └── types/
│   └── shared/     # Cross-feature shared code
├── lib/            # Core utilities
└── types/          # Global types
Enter fullscreen mode Exit fullscreen mode

Key Benefits

1. High Cohesion

Related code lives together regardless of its technical type. The authentication form component lives with authentication hooks, services, and types.

2. Clear Boundaries

Each feature exposes a deliberate public API through its index.ts file:

// features/auth/index.ts
export * from './components';
export * from './hooks';
export { signIn, signOut } from './services';
export type { User, AuthCredentials } from './types';
Enter fullscreen mode Exit fullscreen mode

Other features import only what's explicitly exported:

import { SignInForm, useAuth } from '@/features/auth';
Enter fullscreen mode Exit fullscreen mode

This makes dependencies between features explicit and manageable.

3. Team Ownership

Teams can own entire features without worrying about changes in unrelated areas. The auth team can own the entire /features/auth directory and be confident that other teams won't modify their code.

4. Scalable Organization

This structure works equally well for small projects and large enterprise applications with dozens of developers. As your application grows, you simply add more feature directories without changing the overall architecture.

Implementation Details

Let's look at how a specific feature might be organized:

Authentication Feature

features/auth/
├── components/
│   ├── sign-in.tsx
│   ├── sign-up.tsx
│   └── index.ts
├── hooks/
│   ├── use-auth.ts
│   └── index.ts
├── services/
│   ├── auth.ts
│   └── index.ts
├── types/
│   ├── auth.types.ts
│   └── index.ts
└── index.ts
Enter fullscreen mode Exit fullscreen mode

Each subdirectory has an index.ts file that exports only what should be publicly available:

// features/auth/components/index.ts
export * from './sign-in';
export * from './sign-up';
// Internal components are not exported
Enter fullscreen mode Exit fullscreen mode

The main feature index.ts then re-exports from these subdirectories:

// features/auth/index.ts
export * from './components';
export * from './hooks';
export * from './services';
export * from './types';
Enter fullscreen mode Exit fullscreen mode

Shared Code

There are two places for shared code:

  1. /components: Truly universal UI components like buttons, inputs, and cards
  2. /features/shared: Components, hooks, and utilities that are specific to your business domain but used across features

This distinction keeps your shared components directory clean while still allowing for code reuse.

Practical Example: Authentication Flow

Let's see how components, hooks, and services work together within a feature:

// features/auth/components/sign-in.tsx
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuth } from '../hooks';
import type { SignInCredentials } from '../types';

export function SignIn() {
  const [credentials, setCredentials] = useState<SignInCredentials>({
    email: '',
    password: '',
  });
  const { signIn, isLoading } = useAuth();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await signIn(credentials);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <Button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign In'}
      </Button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode
// features/auth/hooks/use-auth.ts
import { useState } from 'react';
import { signIn as nextAuthSignIn } from 'next-auth/react';
import type { SignInCredentials } from '../types';
import { handleAuthError } from '../services';

export function useAuth() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const signIn = async (credentials: SignInCredentials) => {
    try {
      setIsLoading(true);
      setError(null);
      const result = await nextAuthSignIn('credentials', {
        ...credentials,
        redirect: false,
      });

      if (!result?.ok) {
        throw new Error(result?.error || 'Failed to sign in');
      }

      return result;
    } catch (err) {
      const errorMessage = handleAuthError(err);
      setError(errorMessage);
      throw err;
    } finally {
      setIsLoading(false);
    }
  };

  return {
    signIn,
    isLoading,
    error,
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice how everything related to authentication lives together, creating a clean mental model for developers.

Getting Started with Feature-Driven Architecture

I've created a starter template that implements this architecture pattern. You can get started by cloning my GitHub repository:

# Clone the repository
git clone https://github.com/rufatalv/next-feature-based my-feature-app

# Navigate to the project
cd my-feature-app

# Install dependencies
npm install
Enter fullscreen mode Exit fullscreen mode

Architecture Principles to Follow

To maintain the benefits of this architecture, follow these principles:

  1. Feature Independence: Features should be as independent as possible
  2. Explicit Dependencies: Features should explicitly import what they need from other features
  3. Consistent Structure: All features should follow the same internal structure
  4. Public APIs: Features should expose a clear public API through their index.ts files
  5. Co-located Tests: Tests should live alongside the code they test

Conclusion

Feature-driven architecture has transformed how my teams build Next.js applications. The initial investment in structure pays huge dividends when you're months into development with multiple developers working on the same codebase.

This approach bridges the gap between technical implementation and business domains, making it easier for developers to reason about the codebase and for product teams to map requirements to code.

I'd love to hear your thoughts and experiences with different architecture patterns. Have you tried feature-driven architecture? What worked and what didn't?


You can find the starter template at https://github.com/rufatalv/next-feature-based.

Sentry blog image

How I fixed 20 seconds of lag for every user in just 20 minutes.

Our AI agent was running 10-20 seconds slower than it should, impacting both our own developers and our early adopters. See how I used Sentry Profiling to fix it in record time.

Read more

Top comments (0)

Postgres on Neon - Get the Free Plan

No credit card required. The database you love, on a serverless platform designed to help you build faster.

Get Postgres on Neon