How to Implement Feature-Based Module Architecture in Next.js: A Complete Guide

Implement feature-based module architecture by organizing domain-specific logic into self-contained packages under src/modules/<feature>, each containing routes, pages, components, actions, and schemas that are centrally exported via src/db/schema.ts and integrated into Next.js through thin router wrappers.

The ifindev/fullstack-next-cloudflare template demonstrates a scalable approach to structuring full-stack Next.js applications by grouping related functionality into cohesive feature modules rather than technical layers. This pattern encapsulates everything a domain needs—from UI components to database schemas—within a single directory, making the codebase maintainable as it grows. By following this architecture, you ensure that adding or removing features requires changes to only one isolated location.

What is Feature-Based Module Architecture?

Feature-based module architecture organizes code by domain capability rather than by technical role. Instead of scattering React components in components/, database logic in db/, and API handlers in app/api/, every domain (such as auth, todos, or dashboard) lives as a self-contained unit under src/modules/<feature-name>.

According to the ifindev/fullstack-next-cloudflare source code, each module encapsulates:

  • Route builders for centralized URL management
  • Page components exported as React components
  • UI components scoped to the feature
  • Action handlers for server-side CRUD operations
  • Schemas/Models using Drizzle ORM and Zod validation
  • Utilities and enums for feature-specific logic

This approach creates high cohesion within features and loose coupling between them, allowing teams to work on separate domains without merge conflicts or circular dependencies.

Project Structure Overview

The template establishes a strict convention where all business logic resides in src/modules/, while the app/ directory contains only thin routing wrappers.


src/
├── app/                    # Next.js App Router entry points (thin wrappers)

│   └── dashboard/
│       └── todos/
│           └── page.tsx    # Simply imports from modules/todos/

├── db/
│   └── schema.ts           # Re-exports all Drizzle tables from modules

├── lib/                    # Shared infrastructure (DB client, etc.)

└── modules/                # Domain-specific features

    ├── auth/               # Authentication feature

    │   ├── auth.route.ts
    │   ├── actions/
    │   └── schemas/
    └── todos/              # Todos feature

        ├── todos.route.ts
        ├── todo-list.page.tsx
        ├── components/
        ├── actions/
        └── schemas/

All imports use the @ alias configured in tsconfig.json, pointing to src/ to eliminate relative path hell and keep imports stable during refactoring.

Core Components of a Feature Module

Route Builders

Each feature exports a route builder object that centralizes URL constants, preventing magic strings throughout the application. In src/modules/todos/todos.route.ts, the pattern supports both static and dynamic segments:

// src/modules/todos/todos.route.ts
const todosRoutes = {
  list: "/dashboard/todos",
  new: "/dashboard/todos/new",
  edit: (id: string | number) => `/dashboard/todos/${id}/edit`,
} as const;

export default todosRoutes;

This enables type-safe navigation—change a route in one place, and TypeScript immediately highlights all affected components.

Page Components

Features export their pages as standard React components, completely decoupled from Next.js routing. The file src/modules/todos/todo-list.page.tsx contains the actual page implementation:

// src/modules/todos/todo-list.page.tsx
import { getAllTodos } from "@/modules/todos/actions/get-todos.action";
import { TodoCard } from "./components/todo-card";

export default async function TodoListPage() {
  const todos = await getAllTodos();
  return (
    <div>
      {todos.map((t) => (
        <TodoCard key={t.id} todo={t} />
      ))}
    </div>
  );
}

UI Components

Reusable building blocks that belong exclusively to a feature live in src/modules/<feature>/components/. These components remain private to the feature unless explicitly exported, preventing global namespace pollution. For example, src/modules/todos/components/todo-card.tsx renders individual todo items using the feature's own types.

Action Handlers

Server-side logic resides in src/modules/<feature>/actions/ and handles CRUD operations, validation, and authorization. The src/modules/todos/actions/create-todo.action.ts file demonstrates the pattern:

// src/modules/todos/actions/create-todo.action.ts
import { requireAuth } from "@/modules/auth/utils/auth-utils";
import { todos, insertTodoSchema } from "@/modules/todos/schemas/todo.schema";
import { db } from "@/lib/db";

export const createTodo = async (input: typeof insertTodoSchema) => {
  const { user } = await requireAuth();
  const validated = await insertTodoSchema.parseAsync({ 
    ...input, 
    userId: user.id 
  });
  return db.insert(todos).values(validated).run();
};

Action handlers import type definitions directly from their feature's schemas, ensuring end-to-end type safety from database to UI.

Schemas and Models

Database tables and validation schemas coexist in src/modules/<feature>/schemas/. Using Drizzle ORM with Zod integration, src/modules/todos/schemas/todo.schema.ts defines both the SQL structure and runtime validation:

// src/modules/todos/schemas/todo.schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";

export const todos = sqliteTable("todos", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  status: text("status").notNull(),
  userId: integer("user_id").notNull(),
});

export const insertTodoSchema = createInsertSchema(todos);
export const selectTodoSchema = createSelectSchema(todos);
export const updateTodoSchema = insertTodoSchema.partial().omit({ id: true });

Utilities and Enums

Feature-specific helpers and constants live in src/modules/<feature>/utils/ and src/modules/<feature>/models/. The src/modules/auth/utils/auth-utils.ts file contains authentication guards used by other features' action handlers, while src/modules/todos/models/todo.enum.ts defines status enumerations.

Centralized Schema Management

While each feature owns its database definitions, a central src/db/schema.ts aggregates all tables for the Drizzle ORM client:

// src/db/schema.ts
import { authUser } from "@/modules/auth/schemas/auth.schema";
export { categories } from "@/modules/todos/schemas/category.schema";
export { todos } from "@/modules/todos/schemas/todo.schema";
// Additional feature exports...

This single entry point allows the database client to instantiate once with complete type information, while preserving feature modularity. When adding a new feature, you simply append one export line to this file.

Routing Integration with Next.js App Router

The app/ directory remains minimal, serving only as a routing layer that delegates to feature modules. This preserves clean separation between Next.js routing mechanics and business logic:

// src/app/dashboard/todos/page.tsx
import TodoListPage from "@/modules/todos/todo-list.page";

export default function Page() {
  return <TodoListPage />;
}

For dynamic routes, the pattern remains identical—import the feature's page component and pass route parameters through.

Step-by-Step: Adding a New Feature

To implement a "Notes" feature following this architecture, create the following structure:


src/
└─ modules/
   └─ notes/
      ├─ notes.route.ts
      ├─ notes.page.tsx
      ├─ components/
      │  └─ note-card.tsx
      ├─ actions/
      │  ├─ get-notes.action.ts
      │  └─ create-note.action.ts
      └─ schemas/
         └─ note.schema.ts

1. Define Routes

// src/modules/notes/notes.route.ts
const notesRoutes = {
  list: "/dashboard/notes",
  new: "/dashboard/notes/new",
  edit: (id: string | number) => `/dashboard/notes/${id}/edit`,
} as const;

export default notesRoutes;

2. Create Schema

// src/modules/notes/schemas/note.schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";

export const notes = sqliteTable("notes", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  content: text("content"),
  userId: integer("user_id").notNull(),
});

export const insertNoteSchema = createInsertSchema(notes);
export const selectNoteSchema = createSelectSchema(notes);

3. Implement Action Handler

// src/modules/notes/actions/create-note.action.ts
import { requireAuth } from "@/modules/auth/utils/auth-utils";
import { notes, insertNoteSchema } from "@/modules/notes/schemas/note.schema";
import { db } from "@/lib/db";

export const createNote = async (input: typeof insertNoteSchema) => {
  const { user } = await requireAuth();
  const validated = await insertNoteSchema.parseAsync({ 
    ...input, 
    userId: user.id 
  });
  return db.insert(notes).values(validated).run();
};

4. Build Page Component

// src/modules/notes/notes.page.tsx
import { getAllNotes } from "@/modules/notes/actions/get-notes.action";
import { NoteCard } from "./components/note-card";

export default async function NotesPage() {
  const notes = await getAllNotes();
  return (
    <div>
      {notes.map((n) => (
        <NoteCard key={n.id} note={n} />
      ))}
    </div>
  );
}

5. Wire to Router

// src/app/dashboard/notes/page.tsx
import NotesPage from "@/modules/notes/notes.page";

export default function Page() {
  return <NotesPage />;
}

6. Export Schema

Add to src/db/schema.ts:

export { notes } from "@/modules/notes/schemas/note.schema";

7. Consume in Navigation

// src/components/navigation.tsx
import Link from "next/link";
import notesRoutes from "@/modules/notes/notes.route";

<Link href={notesRoutes.list}>Notes</Link>

Summary

  • Encapsulation: Place all domain files—routes, pages, components, actions, and schemas—under src/modules/<feature> to create self-contained units that are easy to locate and modify.
  • Scalability: Adding features requires only creating a new module directory and registering its schema export in src/db/schema.ts, without touching unrelated code.
  • Type Safety: The @ path alias and centralized schema exports enable TypeScript to infer types across the entire stack, from database queries to React props.
  • Clean Routing: Keep Next.js app/ directory thin by importing and rendering feature page components, maintaining separation between framework routing and business logic.
  • Centralized URLs: Use route builder objects to eliminate magic strings and enable compile-time checking of navigation paths.

Frequently Asked Questions

What are the benefits of feature-based architecture over folder-by-type?

Feature-based architecture colocates related code by domain capability rather than technical role, eliminating the need to jump between components/, lib/, and app/ directories when working on a single user story. As demonstrated in ifindev/fullstack-next-cloudflare, this reduces merge conflicts when multiple developers work on different features and makes deletion of obsolete functionality as simple as removing one directory.

How does the template handle database schema changes across features?

Each feature owns its Drizzle ORM table definitions, but re-exports them through src/db/schema.ts to instantiate a single database client with complete type information. When you modify src/modules/todos/schemas/todo.schema.ts, TypeScript immediately reflects those changes in src/modules/todos/actions/create-todo.action.ts because both import from the same source, while the central schema file ensures the database client stays synchronized.

Can components from one feature be used in another feature?

Yes, but with explicit imports that maintain boundary visibility. Since all modules use the @ alias, you can import TodoCard from the todos module into the dashboard module using @/modules/todos/components/todo-card. However, the architecture encourages keeping such cross-feature dependencies minimal and well-defined, preserving the encapsulation benefits while allowing justified reuse.

How do route builders improve type safety compared to hardcoded strings?

Route builders centralize URL definitions as const objects with TypeScript as const assertions, turning runtime string errors into compile-time failures. When you change todosRoutes.list in src/modules/todos/todos.route.ts, TypeScript immediately flags all navigation components using the old path, whereas hardcoded strings would fail silently at runtime or require global search-and-replace operations.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →