Your definitive guide to building a scalable, maintainable Next.js application with enforced architectural boundaries
- Introduction
- The Big Picture
- Folder Structure Deep Dive
- Module Independence Rules
- The Barrel File Problem & Solution
- Next.js Configuration Deep Dive
- TypeScript Configuration Explained
- ESLint Configuration Deep Dive
- Independent Module Configuration
- Workflow Guide
- Dos and Don'ts
- Real-World Examples
- Troubleshooting
- Configuration Files Reference
Welcome to the complete architecture documentation! This isn't just another boring config file explanation. We're building something special hereβa codebase that scales without turning into spaghetti.
Imagine you're building a house. You wouldn't want the kitchen plumbing connected to the bedroom electrical system, right? The same principle applies to our code. We're creating independent modules that:
- β Can be understood in isolation
- β Won't break when other parts change
- β Keep your build times fast (yes, really!)
- β Make code reviews actually enjoyable
This isn't just folder organizationβit's a complete system with three layers of enforcement:
- TypeScript - Provides type safety and path aliases
- ESLint - Enforces import rules at development time
- Next.js Config - Optimizes bundle size at build time
Think of it like building a car with:
- π§ TypeScript = The blueprint (what goes where)
- π¦ ESLint = The traffic rules (what can connect to what)
- ποΈ Next.js = The engine optimization (making it fast)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π APP FOLDER β
β Pages, layouts, route handlers - your application entry β
β β
β CAN IMPORT FROM: β
β β’ Shared folders (components, utils, config) β
β β’ UI components β
β β’ Feature INDEX files ONLY (src/features/*/index.ts) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π― FEATURES β
β Business logic organized by domain (auth, dashboard, etc) β
β β
β CAN IMPORT FROM: β
β β’ Its own family members (same feature) β
β β’ Shared folders β
β β’ UI components β
β β CANNOT import from other features β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π§ SHARED FOLDERS β
β Pure utilities, configs, types - the foundation β
β β
β CAN IMPORT FROM: β
β β’ Other shared folders β
β β’ UI components β
β β CANNOT import from features β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π¨ UI LAYER β
β Pure presentational components (shadcn/ui, styles) β
β β
β CAN IMPORT FROM: β
β β’ Other UI components only β
β β Most restricted layer - zero business logic β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 1: TypeScript Configuration (tsconfig.json) β
β π Enables: Path aliases, module resolution β
β π― Purpose: Makes imports clean and consistent β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 2: ESLint Configuration (eslint.config.mjs) β
β π¦ Enforces: Import rules, architectural boundaries β
β π― Purpose: Catches violations during development β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 3: Next.js Configuration (next.config.ts) β
β ποΈ Optimizes: Bundle size, tree-shaking β
β π― Purpose: Ensures fast builds and small bundles β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
src/
βββ π± app/ # Next.js App Router
β βββ page.tsx
β βββ layout.tsx
β βββ global.css # β Special: Can be imported by app layer
β βββ [routes]/
β
βββ π― features/ # Feature modules (your business logic)
β βββ auth/
β β βββ index.ts # β Public API (the ONLY file app/ can import)
β β βββ components/
β β βββ hooks/
β β βββ utils/
β β βββ types.ts
β β
β βββ dashboard/
β β βββ index.ts
β β βββ ...
β β
β βββ profile/
β βββ index.ts
β βββ ...
β
βββ π§ components/
β βββ shared/ # Shared business components
β βββ ui/ # Pure UI components (shadcn/ui)
β
βββ π lib/ # Third-party integrations & utilities
β βββ utils.ts # β Part of UI layer
β βββ prisma/ # Database client
β βββ stripe/ # Payment integration
β
βββ βοΈ config/ # App configuration
βββ π types/ # Shared TypeScript types
βββ π¦ store/ # State management (Zustand, Redux, etc)
βββ π οΈ utils/ # Helper functions
βββ π¨ styles/ # Global styles
Folder | Layer | Can Import From |
---|---|---|
app/ |
Application | Everything (via public APIs) |
features/ |
Feature | Own feature + Shared + UI |
components/shared/ , lib/*/ , config/ , types/ , store/ , utils/ |
Shared | Shared + UI |
components/ui/ , styles/ , lib/utils.ts |
UI | UI only |
// components/ui/badge.tsx
// β
CAN import from UI layer
import { cn } from "@/lib/utils"; // lib/utils.ts is UI layer
import "./badge.css";
// β
CAN import other UI components
import { Button } from "@/components/ui/button";
// β CANNOT import from anywhere else
import { useAuth } from "@/features/auth"; // β
import { API_URL } from "@/config/api"; // β
import { UserCard } from "@/components/shared/UserCard"; // β
Why? UI components should be design system primitives. They're the building blocks, not the buildings.
Real-world analogy: Think of LEGO bricks. A basic brick doesn't know about spaceships or castlesβit's just a brick that can be used anywhere.
// components/shared/DataTable.tsx
// β
CAN import from shared layer
import { formatDate } from "@/utils/date";
import { API_URL } from "@/config/api";
import { useAppStore } from "@/store/app";
// β
CAN import from UI layer
import { Table } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
// β CANNOT import from features
import { useProducts } from "@/features/products"; // β
Why? Shared code is foundational. If it depends on features, it's not really sharedβit's coupled.
Real-world analogy: Think of a toolbox. Your hammer doesn't know whether it's building a house or a birdhouse. It's a generic tool that works everywhere.
// features/products/hooks/useProducts.ts
// β
CAN import from its own feature (family)
import { Product } from "../types";
import { fetchProducts } from "../api/productService";
import { ProductCard } from "../components/ProductCard";
// β
CAN import from shared layer
import { useDebounce } from "@/utils/hooks";
import { API_URL } from "@/config/api";
import { useAppStore } from "@/store/app";
// β
CAN import from UI layer
import { Button } from "@/components/ui/button";
// β CANNOT import from OTHER features
import { useAuth } from "@/features/auth"; // β NOPE!
import { DashboardHeader } from "@/features/dashboard"; // β NOPE!
Why? Features should be independent. If products
needs auth
, you have two choices:
- Move the common logic to
shared/
- Pass the data down from the app layer
Real-world analogy: Think of departments in a company. The Marketing department doesn't directly access HR's filesβthey go through official channels (the app layer).
// app/products/page.tsx
// β
CAN import feature PUBLIC APIs (index.ts only)
import { ProductList } from "@/features/products";
import { useAuth } from "@/features/auth";
// β
CAN import shared layer
import { Container } from "@/components/shared/Container";
import { formatPrice } from "@/utils/format";
// β
CAN import UI layer
import { Button } from "@/components/ui/button";
// β
CAN import global styles
import "@/app/global.css";
// β CANNOT import feature internals
import { ProductCard } from "@/features/products/components/ProductCard"; // β
import { validateProduct } from "@/features/products/utils/validation"; // β
Why? The app layer orchestrates features. It's like a conductorβit doesn't play the instruments, it coordinates them.
Real-world analogy: Think of a restaurant manager. They don't cook the food or wash dishesβthey coordinate the kitchen, servers, and customers.
What's a barrel file? A file that re-exports everything:
// features/products/index.ts (BAD BARREL)
export * from "./components";
export * from "./hooks";
export * from "./utils";
export * from "./api";
export * from "./types";
Why is this bad?
When you import ONE thing:
import { ProductList } from "@/features/products";
Without optimization, Next.js bundles EVERYTHING from that feature:
- All 15 components (even unused ones)
- All hooks
- API functions
- Utilities
- Types
Result: Your 50KB page becomes 500KB! ππ₯
Scenario: You have a feature with these exports:
// features/products/index.ts
export { ProductList } from "./components/ProductList"; // 15KB
export { ProductGrid } from "./components/ProductGrid"; // 20KB
export { ProductCard } from "./components/ProductCard"; // 10KB
export { ProductDetail } from "./components/ProductDetail"; // 25KB
export { useProducts } from "./hooks/useProducts"; // 8KB
export { useProductSearch } from "./hooks/useProductSearch"; // 12KB
export { productApi } from "./api/productService"; // 30KB
Without optimization:
// app/products/page.tsx
import { ProductList } from "@/features/products";
// Bundles: ALL 120KB of the feature! π₯
With optimization:
// app/products/page.tsx
import { ProductList } from "@/features/products";
// Bundles: Only ProductList (15KB) + its dependencies β¨
In next.config.ts
, we automatically optimize all feature imports:
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: [
"@/features/auth",
"@/features/products",
"@/features/dashboard",
// ... all features automatically added
],
},
};
What this does:
β¨ Magic! Next.js now treats your features like external packages:
- Only bundles what you actually import
- Tree-shaking works properly
- Dead code elimination
- Build size stays lean
Before optimization:
Page Size First Load JS
β β /products 450 kB 525 kB
After optimization:
Page Size First Load JS
β β /products 45 kB 120 kB
That's a 10x improvement! π
β DO: Be selective in your exports
// features/products/index.ts (GOOD)
export { ProductList } from "./components/ProductList";
export { ProductCard } from "./components/ProductCard";
export { useProducts } from "./hooks/useProducts";
export type { Product, ProductFilter } from "./types";
// Keep internals private!
// validation.ts, productService.ts, etc. are NOT exported
β DON'T: Export everything blindly
// features/products/index.ts (BAD)
export * from "./components"; // Exports 20 components
export * from "./hooks"; // Even internal hooks
export * from "./utils"; // Private utilities leaked!
π― The Rule: Only export what the app layer needs. Keep internals private.
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
import path from "path";
import { readdirSync } from "fs";
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const featuresDir = path.resolve(__dirname, "src/features");
const featureFolders = readdirSync(featuresDir).map(
(folder) => `@/features/${folder}`
);
const nextConfig: NextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "randomuser.me",
},
],
},
experimental: {
optimizePackageImports: [...featureFolders],
},
};
export default withBundleAnalyzer(nextConfig);
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
Purpose: Visualize your bundle size and identify bloat.
Usage:
ANALYZE=true npm run build
This opens an interactive treemap showing exactly what's in your bundles.
What you'll see:
- π Size of each module
- π― Which features are biggest
- π Duplicate dependencies
- π‘ Optimization opportunities
const featuresDir = path.resolve(__dirname, "src/features");
const featureFolders = readdirSync(featuresDir).map(
(folder) => `@/features/${folder}`
);
What this does:
- Reads
src/features
at build time - Finds all folders:
['auth', 'products', 'dashboard', ...]
- Converts to path aliases:
['@/features/auth', '@/features/products', ...]
- Passes to
optimizePackageImports
Why this is brilliant:
- β Add a new feature? Automatically optimized
- β Rename a feature? No config changes needed
- β Delete a feature? Automatically removed
- β Zero maintenance
Is this safe for production? β YES! Because:
- Runs at build time (not runtime)
src/features
is part of your repository- Works on all platforms (Vercel, Netlify, Docker, etc.)
- No filesystem dependency in production bundle
experimental: {
optimizePackageImports: [...featureFolders],
}
What this does:
Treats each feature like an external npm package, enabling:
Feature | Benefit | Impact |
---|---|---|
Tree-shaking | Only bundles imported code | 70% smaller bundles |
Code splitting | Better chunk distribution | Faster page loads |
Dead code elimination | Removes unused exports | Cleaner builds |
Lazy loading | Defers non-critical code | Better performance |
Real-world impact:
Scenario | Without Optimization | With Optimization |
---|---|---|
Import 1 component | Bundles entire feature (500KB) | Bundles only that component (50KB) |
Import from 3 features | 1.5MB total | 150KB total |
Build time | Slower (analyzes everything) | Faster (targeted analysis) |
Hot reload | Slower (rebuilds more) | Faster (minimal rebuilds) |
Example:
// Your code
import { LoginForm } from "@/features/auth";
// Without optimization
// β Bundles: LoginForm, SignupForm, useAuth, authService,
// validators, ResetPasswordForm, EmailVerification, etc.
// Total: 500KB
// With optimization
// β
Bundles: Only LoginForm + its direct dependencies
// Total: 45KB
1. Zero Maintenance
// Just add a new feature
src/features/notifications/
index.ts
components/
hooks/
// Next build automatically includes it in optimizePackageImports!
2. Performance by Default
- Every feature gets tree-shaking
- Prevents accidental bundle bloat
- Keeps pages lean
- No manual configuration needed
3. Predictable Builds
- Filesystem read happens at build time
- Same result every build
- No runtime surprises
- Works everywhere
If you prefer explicit configuration over dynamic discovery:
// Option 1: Explicit list (more predictable, more maintenance)
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: [
"@/features/auth",
"@/features/dashboard",
"@/features/products",
"@/features/settings",
// β οΈ Must add new features here manually
],
},
};
Pros:
- β More explicit and predictable
- β No filesystem dependency
- β Clear what's optimized
- β Better for very large teams
Cons:
- β Must remember to update when adding features
- β More manual maintenance
- β Risk of forgetting to add new features
Recommendation: Stick with dynamic discovery for this architecture. It aligns perfectly with the "convention over configuration" philosophy.
1. Target & Library
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"]
- target: Compiles to ES2017 (async/await support)
- lib: Includes DOM APIs + latest JavaScript features
- Why ES2017? Balance between modern features and broad browser support
2. Module Resolution
"module": "esnext",
"moduleResolution": "bundler"
- module: Uses latest ESM (import/export)
- moduleResolution: Optimized for bundlers (Next.js/Webpack)
- Why bundler? Enables advanced optimizations like tree-shaking
3. Path Aliases - The Magic β¨
"paths": {
"@/*": ["./src/*"]
}
What this does:
Transforms ugly relative imports into clean absolute imports:
// β BEFORE: Ugly relative paths
import { LoginForm } from "../../../features/auth/components/LoginForm";
import { useProducts } from "../../../../features/products/hooks/useProducts";
import { Button } from "../../../components/ui/button";
// β
AFTER: Clean absolute paths
import { LoginForm } from "@/features/auth";
import { useProducts } from "@/features/products";
import { Button } from "@/components/ui/button";
Why this matters:
- Readability - Instantly know where imports come from
- Refactoring - Move files without breaking imports
- Consistency - Same import style everywhere
- IDE Support - Better autocomplete and navigation
How it works:
// You write:
import { something } from "@/features/auth";
// TypeScript resolves to:
import { something } from "./src/features/auth";
// In file: src/app/dashboard/products/page.tsx
4. Strict Type Checking
"strict": true
Enables all strict type checking options:
strictNullChecks
- No implicitundefined
strictFunctionTypes
- Safer function signaturesstrictPropertyInitialization
- Class properties must be initializednoImplicitAny
- Must explicitly typeany
noImplicitThis
-this
must be typed
Why? Catches bugs at compile time, not runtime!
5. Next.js Integration
"jsx": "preserve",
"plugins": [{ "name": "next" }]
- jsx: preserve - Keeps JSX for Next.js to transform
- next plugin - Adds Next.js-specific type checking
6. Performance Options
"skipLibCheck": true,
"incremental": true,
"noEmit": true
- skipLibCheck - Don't type-check node_modules (faster builds)
- incremental - Cache results for faster rebuilds
- noEmit - Don't output JS (Next.js handles that)
"include": [
"next-env.d.ts", // Next.js type definitions
"**/*.ts", // All TypeScript files
"**/*.tsx", // All React files
".next/types/**/*.ts", // Generated types
"eslint.config.mjs", // ESLint config
"independentModuleConfig.mjs" // Module config
],
"exclude": ["node_modules"]
Why include config files?
- Provides TypeScript support in ESLint config
- Type-safe module configuration
- Better IDE experience
Example 1: Deep Nesting Made Simple
// File: src/app/dashboard/products/[id]/reviews/page.tsx
// β WITHOUT path aliases
import { ProductReviews } from "../../../../../features/products/components/ProductReviews";
import { useAuth } from "../../../../../features/auth/hooks/useAuth";
import { Card } from "../../../../../components/ui/card";
// β
WITH path aliases
import { ProductReviews } from "@/features/products";
import { useAuth } from "@/features/auth";
import { Card } from "@/components/ui/card";
Example 2: Refactoring Resilience
// You move: src/app/products/page.tsx
// to: src/app/store/products/page.tsx
// β WITHOUT path aliases - ALL imports break!
import { ProductList } from "../../features/products"; // Now β broken
import { Button } from "../../components/ui/button"; // Now β broken
// β
WITH path aliases - NOTHING breaks!
import { ProductList } from "@/features/products"; // Still β
works
import { Button } from "@/components/ui/button"; // Still β
works
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β You write code with @/ imports β
β import { X } from '@/features/auth' β
ββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
ββββ TypeScript checks types
β β
Resolves @/ to src/
β β
Validates imports exist
β β
Type checks the code
β
ββββ ESLint checks architecture
β β
Validates import rules
β β
Enforces layer boundaries
β β
Prevents feature coupling
β
ββββ Next.js bundles optimally
β
Tree-shakes unused code
β
Code splits features
β
Optimizes performance
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { independentModulesConfig } from "./independentModuleConfig.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
{
ignores: ["**/.next/**", "**/node_modules/**"],
},
...compat.config({
extends: [
"next",
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:jsx-a11y/recommended",
"prettier",
"plugin:prettier/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
root: true,
plugins: ["@typescript-eslint", "eslint-plugin-project-structure"],
rules: {
"linebreak-style": ["error", "unix"],
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true },
],
"project-structure/independent-modules": [
"error",
independentModulesConfig,
],
},
}),
];
export default eslintConfig;
import { FlatCompat } from "@eslint/eslintrc";
Why? ESLint is moving to a new "flat config" format. FlatCompat
bridges old configs to new format.
Old vs New:
// β OLD (.eslintrc.json)
{
"extends": ["next"],
"rules": {}
}
// β
NEW (eslint.config.mjs)
export default [
{ rules: {} }
];
{
ignores: ['**/.next/**', '**/node_modules/**'],
}
What's ignored:
.next/
- Build output (no need to lint)node_modules/
- Third-party code- Uses glob patterns for flexibility
extends: [
'next', // Next.js best practices
'next/core-web-vitals', // Performance rules
'plugin:@typescript-eslint/recommended-type-checked', // Type safety
'plugin:@typescript-eslint/strict-type-checked', // Strict types
'plugin:@typescript-eslint/stylistic-type-checked', // Code style
'plugin:jsx-a11y/recommended', // Accessibility
'prettier', // Prettier integration
'plugin:prettier/recommended', // Prettier as ESLint rules
],
Let's break down each configuration:
Config | Purpose | What It Does |
---|---|---|
next |
Next.js rules | React Hooks, Link usage, Image optimization |
next/core-web-vitals |
Performance | CLS, LCP, FID optimizations |
@typescript-eslint/recommended-type-checked |
Type safety | Ensures type-safe code |
@typescript-eslint/strict-type-checked |
Strict types | No implicit any, strict null checks |
@typescript-eslint/stylistic-type-checked |
Code style | Consistent TypeScript patterns |
jsx-a11y/recommended |
Accessibility | ARIA labels, keyboard nav, semantic HTML |
prettier |
Formatting | Code formatting rules |
Why so many? Each layer adds a specific type of protection:
Next.js rules β Framework best practices
TypeScript rules β Type safety & consistency
Accessibility rules β Inclusive user experience
Prettier rules β Consistent formatting
parser: '@typescript-eslint/parser',
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
}
What this does:
- parser: Uses TypeScript-aware parser for better analysis
- projectService: Automatically finds and uses
tsconfig.json
- tsconfigRootDir: Sets the root for TypeScript configuration
Why projectService? Enables type-aware linting:
// Without type-aware linting
const x = await somePromise; // β
Looks fine
// With type-aware linting
const x = await somePromise; // β Error: somePromise is not a Promise!
rules: {
'linebreak-style': ['error', 'unix'],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/restrict-template-expressions': [
'error',
{ allowNumber: true },
],
'project-structure/independent-modules': ['error', independentModulesConfig],
}
Rule Breakdown:
a) Linebreak Style
'linebreak-style': ['error', 'unix']
- Enforces Unix line endings (LF) not Windows (CRLF)
- Prevents git diff noise
- Consistent across all platforms
b) Type Definitions
'@typescript-eslint/consistent-type-definitions': ['error', 'type']
// β NOT ALLOWED: interfaces
interface User {
name: string;
}
// β
REQUIRED: type aliases
type User = {
name: string;
};
Why prefer types?
- More flexible (can use unions, intersections)
- Consistent with our codebase style
- Better for composition
c) Template Expressions
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }]
const age = 25;
const name = "Alice";
// β
ALLOWED: Numbers in templates
const msg1 = `Age: ${age}`;
// β
ALLOWED: Strings in templates
const msg2 = `Name: ${name}`;
// β NOT ALLOWED: Objects in templates
const obj = { x: 1 };
const msg3 = `Object: ${obj}`; // Error!
Why? Prevents common bugs like [object Object]
in strings.
d) Independent Modules (THE KEY RULE)
'project-structure/independent-modules': ['error', independentModulesConfig]
This is the heart of our architecture enforcement! Let's dive deep into this...
// @ts-check
import { createIndependentModules } from "eslint-plugin-project-structure";
export const independentModulesConfig = createIndependentModules({
modules: [
{
name: "App folder",
pattern: "src/app/**",
allowImportsFrom: [
"src/app/global.css",
"{sharedImports}",
"{uiImports}",
"src/features/*/index.(ts|tsx)",
],
errorMessage:
"π₯ App folder can only import from {sharedImports},{uiImports} and src/features/*/index.ts files π₯",
},
{
name: "Features",
pattern: "src/features/**",
allowImportsFrom: ["{family_3}/**", "{sharedImports}", "{uiImports}"],
errorMessage:
"π₯ A feature may only import items from shared folders and its own family. Importing items from another feature is prohibited. π₯",
},
{
name: "Shared",
pattern: [
"src/components/shared/**",
"src/lib/*/**",
"src/config/**",
"src/types/**",
"src/store/**",
"src/utils/**",
],
allowImportsFrom: ["{sharedImports}", "{uiImports}"],
errorMessage:
"π₯ Shared folders are not allowed to import items from the `features` folder. π₯",
},
{
name: "UI",
pattern: ["src/components/ui/*", "src/styles/**", "src/lib/*"],
allowImportsFrom: ["{uiImports}"],
},
{
name: "Unknown files",
pattern: [["src/**", "!src/*"]],
allowImportsFrom: [],
allowExternalImports: false,
errorMessage:
"π₯ This file is not specified as an independent module in `independentModules.jsonc`. π₯",
},
],
reusableImportPatterns: {
sharedImports: [
"src/components/shared/**",
"src/lib/*/**",
"src/config/**",
"src/types/**",
"src/store/**",
"src/utils/**",
],
uiImports: ["src/components/ui/*", "src/styles/**", "src/lib/*"],
neverImports: ["src/*"],
},
});
{
name: 'App folder',
pattern: 'src/app/**',
allowImportsFrom: [
'src/app/global.css',
'{sharedImports}',
'{uiImports}',
'src/features/*/index.(ts|tsx)',
],
}
What this means:
// β
ALLOWED: Import global styles
import "@/app/global.css";
// β
ALLOWED: Import from shared folders
import { formatDate } from "@/utils/date";
import { API_URL } from "@/config/api";
import { useAppStore } from "@/store/app";
import { Container } from "@/components/shared/Container";
// β
ALLOWED: Import from UI layer
import { Button } from "@/components/ui/button";
import "@/styles/globals.css";
// β
ALLOWED: Import feature public API (index files ONLY)
import { LoginForm } from "@/features/auth";
import { ProductList } from "@/features/products";
// β FORBIDDEN: Import feature internals
import { LoginForm } from "@/features/auth/components/LoginForm";
import { validateEmail } from "@/features/auth/utils/validation";
Pattern Explanation:
src/app/**
- Matches all files in app foldersrc/features/*/index.(ts|tsx)
- Matches ONLY index files in ANY featuresrc/features/auth/index.ts
βsrc/features/auth/index.tsx
βsrc/features/auth/components/LoginForm.tsx
β
{
name: 'Features',
pattern: 'src/features/**',
allowImportsFrom: ['{family_3}/**', '{sharedImports}', '{uiImports}'],
}
What this means:
// File: src/features/products/hooks/useProducts.ts
// β
ALLOWED: Import from own feature (family)
import { Product } from "../types";
import { fetchProducts } from "../api/productService";
import { ProductCard } from "../components/ProductCard";
// β
ALLOWED: Import from shared folders
import { useDebounce } from "@/utils/hooks";
import { API_URL } from "@/config/api";
import { useAppStore } from "@/store/app";
// β
ALLOWED: Import from UI layer
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
// β FORBIDDEN: Import from other features
import { useAuth } from "@/features/auth";
import { DashboardHeader } from "@/features/dashboard";
Pattern Explanation:
{family_3}/**
- Special pattern meaning "same feature folder"family_3
= 3 levels deep =src/features/products
- For file
src/features/products/hooks/useProducts.ts
:- Can import from
src/features/products/**
β - Cannot import from
src/features/auth/**
β
- Can import from
How family works:
src/features/products/hooks/useProducts.ts
β β β
Level 1 Level 2 Level 3 (family!)
Family = src/features/products/
Can import anything from: src/features/products/**
{
name: 'Shared',
pattern: [
'src/components/shared/**',
'src/lib/*/**',
'src/config/**',
'src/types/**',
'src/store/**',
'src/utils/**',
],
allowImportsFrom: ['{sharedImports}', '{uiImports}'],
}
What this means:
// File: src/components/shared/DataTable.tsx
// β
ALLOWED: Import from other shared folders
import { formatDate } from "@/utils/date";
import { API_URL } from "@/config/api";
import { useAppStore } from "@/store/app";
// β
ALLOWED: Import from UI layer
import { Table } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
// β FORBIDDEN: Import from features
import { useProducts } from "@/features/products";
import { useAuth } from "@/features/auth";
Pattern Explanation:
- Multiple patterns = ALL these folders follow the same rule
src/lib/*/**
matchessrc/lib/prisma/client.ts
but notsrc/lib/utils.ts
- Why?
lib/utils.ts
is UI layer,lib/*/
are shared integrations
- Why?
{
name: 'UI',
pattern: ['src/components/ui/*', 'src/styles/**', 'src/lib/*'],
allowImportsFrom: ['{uiImports}'],
}
What this means:
// File: src/components/ui/button.tsx
// β
ALLOWED: Import from other UI components
import { cn } from "@/lib/utils";
import "./button.css";
import { Icon } from "@/components/ui/icon";
// β FORBIDDEN: Import from shared layer
import { formatDate } from "@/utils/date";
// β FORBIDDEN: Import from features
import { useAuth } from "@/features/auth";
Why src/lib/*
is UI layer?
src/lib/utils.ts β UI layer (utilities)
src/lib/prisma/client.ts β Shared layer (integration)
Pattern src/lib/*
matches only files directly in lib/
, not subfolders.
{
name: 'Unknown files',
pattern: [['src/**', '!src/*']],
allowImportsFrom: [],
allowExternalImports: false,
}
What this does:
Catches any file NOT matched by previous rules!
// File: src/random/SomeFile.ts (not in any defined folder)
// β ERROR: File not in architecture
// Must be in: app/, features/, components/, lib/, etc.
Pattern Explanation:
src/**
- Match ALL files in src!src/*
- EXCEPT files directly in src root- Result: Catches files in undefined folders
Why allow files in src/*
?
src/middleware.ts β
OK (Next.js middleware)
src/instrumentation.ts β
OK (Next.js instrumentation)
src/random/file.ts β ERROR (unknown folder)
reusableImportPatterns: {
sharedImports: [
'src/components/shared/**',
'src/lib/*/**',
'src/config/**',
'src/types/**',
'src/store/**',
'src/utils/**',
],
uiImports: [
'src/components/ui/*',
'src/styles/**',
'src/lib/*'
],
neverImports: ['src/*'],
}
Why reusable patterns?
Instead of repeating the same list everywhere:
// β WITHOUT reusable patterns - repetitive
{
name: 'Features',
allowImportsFrom: [
'src/components/shared/**',
'src/lib/*/**',
'src/config/**',
'src/types/**',
'src/store/**',
'src/utils/**',
'src/components/ui/*',
'src/styles/**',
'src/lib/*',
]
}
// β
WITH reusable patterns - DRY
{
name: 'Features',
allowImportsFrom: ['{sharedImports}', '{uiImports}']
}
Benefits:
- β Update once, applies everywhere
- β Less duplication
- β Clearer intent
Real-time checking:
// File: src/features/products/hooks/useProducts.ts
// You type this:
import { useAuth } from "@/features/auth";
// ESLint immediately shows:
// π₯ A feature may only import items from shared folders
// and its own family. Importing items from another
// feature is prohibited. π₯
IDE Integration:
VS Code with ESLint extension:
βββββββββββββββββββββββββββββββββββββββββββ
β import { useAuth } from '@/features/auth'β
β ~~~~~~~~ β β
β β
β π₯ A feature may only import items β
β from shared folders and its own β
β family. Importing from another β
β feature is prohibited. π₯ β
βββββββββββββββββββββββββββββββββββββββββββ
Pre-commit hooks (recommended):
# .husky/pre-commit
npm run lint
# Prevents commits with architecture violations!
Understanding glob patterns:
Pattern | Matches | Doesn't Match |
---|---|---|
src/app/** |
src/app/page.tsx src/app/dashboard/page.tsx |
src/features/auth/index.ts |
src/features/*/index.ts |
src/features/auth/index.ts src/features/products/index.ts |
src/features/auth/components/Login.tsx src/features/auth/index.test.ts |
src/lib/* |
src/lib/utils.ts |
src/lib/prisma/client.ts |
src/lib/*/** |
src/lib/prisma/client.ts src/lib/stripe/checkout.ts |
src/lib/utils.ts |
{family_3}/** |
(Same folder at level 3) | (Different folders) |
Family pattern in action:
// File location: src/features/auth/hooks/useAuth.ts
// β β β β
// 1 2 3 4 (levels deep)
// family_3 = src/features/auth/
// β
Can import (same family):
import { Login } from "../components/Login"; // src/features/auth/components/Login
import { User } from "../types"; // src/features/auth/types
import { authService } from "../api/authService"; // src/features/auth/api/authService
// β Cannot import (different family):
import { Product } from "@/features/products/types"; // src/features/products/types
Step 1: Create the feature structure
mkdir -p src/features/notifications
cd src/features/notifications
touch index.ts
mkdir components hooks api utils types
Step 2: Build your feature
// components/NotificationBell.tsx
import { Bell } from "@/components/ui/icons";
import { useNotifications } from "../hooks/useNotifications";
export function NotificationBell() {
const { unreadCount } = useNotifications();
return (
<button className="relative">
<Bell />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 text-xs">
{unreadCount}
</span>
)}
</button>
);
}
// hooks/useNotifications.ts
import { useEffect, useState } from "react";
import { fetchNotifications } from "../api/notificationService";
import type { Notification } from "../types";
export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
fetchNotifications().then((data) => {
setNotifications(data);
setUnreadCount(data.filter((n) => !n.read).length);
});
}, []);
return { notifications, unreadCount };
}
// api/notificationService.ts (INTERNAL - not exported)
export async function fetchNotifications() {
const response = await fetch("/api/notifications");
return response.json();
}
// types.ts
export type Notification = {
id: string;
title: string;
message: string;
read: boolean;
createdAt: Date;
};
Step 3: Define the public API (CRITICAL)
// index.ts - The ONLY file app layer can import from
export { NotificationBell } from "./components/NotificationBell";
export { useNotifications } from "./hooks/useNotifications";
export type { Notification } from "./types";
// β οΈ Keep internal utilities private!
// fetchNotifications from api/notificationService is NOT exported
// This keeps implementation details hidden
Step 4: Use in app layer
// app/dashboard/page.tsx
import { NotificationBell } from "@/features/notifications";
export default function Dashboard() {
return (
<header>
<h1>Dashboard</h1>
<NotificationBell />
</header>
);
}
Step 5: Verify everything works
# Check TypeScript
npm run type-check
# Check ESLint (architecture rules)
npm run lint
# Check bundle optimization
npm run build
# Your new feature is automatically included in optimizePackageImports! π
When to create a shared component:
Create Shared When | Keep in Feature When |
---|---|
Used by 2+ features | Used by 1 feature only |
Has cross-cutting concerns | Feature-specific logic |
Generic business logic | Tightly coupled to feature domain |
Example: Creating a shared DatePicker
// components/shared/DatePicker.tsx
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/utils/date";
import { CalendarIcon } from "@/components/ui/icons";
type DatePickerProps = {
value?: Date;
onChange: (date: Date) => void;
minDate?: Date;
maxDate?: Date;
};
export function DatePicker({
value,
onChange,
minDate,
maxDate,
}: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<CalendarIcon />
{value ? formatDate(value) : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent>
<Calendar
mode="single"
selected={value}
onSelect={onChange}
disabled={(date) =>
(minDate && date < minDate) || (maxDate && date > maxDate)
}
/>
</PopoverContent>
</Popover>
);
}
Now use it in multiple features:
// features/bookings/components/BookingForm.tsx
import { DatePicker } from "@/components/shared/DatePicker";
export function BookingForm() {
const [startDate, setStartDate] = useState<Date>();
return (
<form>
<DatePicker value={startDate} onChange={setStartDate} />
</form>
);
}
// features/reports/components/ReportFilters.tsx
import { DatePicker } from "@/components/shared/DatePicker";
export function ReportFilters() {
const [fromDate, setFromDate] = useState<Date>();
return (
<div>
<DatePicker value={fromDate} onChange={setFromDate} />
</div>
);
}
Scenario: Your feature component is now used everywhere. Time to promote it!
Step 1: Identify the component
// features/products/components/PriceDisplay.tsx
import { formatCurrency } from "@/utils/format";
type PriceDisplayProps = {
amount: number;
currency?: string;
};
export function PriceDisplay({ amount, currency = "USD" }: PriceDisplayProps) {
return (
<span className="font-semibold text-lg">
{formatCurrency(amount, currency)}
</span>
);
}
Step 2: Check dependencies
# Find all imports in the file
grep -n "^import" features/products/components/PriceDisplay.tsx
# Result:
# import { formatCurrency } from '@/utils/format';
# β
Only imports from shared layer (utils)
# β
Safe to move to shared!
If it imports from features:
// β HAS feature dependency - DON'T move yet
import { useProducts } from "../hooks/useProducts";
// Fix: Remove feature dependency first
Step 3: Move to shared
git mv src/features/products/components/PriceDisplay.tsx \
src/components/shared/PriceDisplay.tsx
Step 4: Update all imports
# Find all usages
grep -r "PriceDisplay" src/
# Update imports across codebase
// features/products/index.ts
// β Remove: export { PriceDisplay } from './components/PriceDisplay';
// features/products/components/ProductCard.tsx
// β OLD: import { PriceDisplay } from './PriceDisplay';
// β
NEW: import { PriceDisplay } from '@/components/shared/PriceDisplay';
// features/orders/components/OrderItem.tsx
// β OLD: import { PriceDisplay } from '@/features/products';
// β
NEW: import { PriceDisplay } from '@/components/shared/PriceDisplay';
Step 5: Verify no violations
npm run lint
npm run type-check
npm run build
β WRONG: Direct feature imports
// features/cart/hooks/useCart.ts
import { useProducts } from "@/features/products"; // β ESLint error!
export function useCart() {
const products = useProducts(); // Direct dependency
// ...
}
β RIGHT: Dependency injection from app
// features/cart/hooks/useCart.ts
import type { Product } from "@/types/product";
export function useCart(products: Product[]) {
// Cart logic uses products passed as parameter
// No direct dependency!
const addToCart = (productId: string) => {
const product = products.find((p) => p.id === productId);
// ...
};
return { addToCart };
}
// app/checkout/page.tsx
import { useProducts } from "@/features/products";
import { useCart } from "@/features/cart";
export default function CheckoutPage() {
const products = useProducts();
const cart = useCart(products); // App layer connects them
return <CartView cart={cart} />;
}
Rule | Example | Why |
---|---|---|
Use index.ts as public API | export { LoginForm } from './components' |
Clear boundaries, encapsulation |
Keep features independent | Pass data down from app layer | Scalability, testability |
Use shared for common logic | shared/hooks/useDebounce.ts |
DRY principle, reusability |
Keep UI components pure | No useAuth() in Button |
Reusability, design system |
Name features by domain | features/authentication/ not features/stuff/ |
Clarity, maintainability |
Export types from features | export type { User } |
Type safety across boundaries |
Use absolute imports | @/features/auth not ../../auth |
Readability, refactor-safety |
One feature = one domain | auth/ not auth-and-profile/ |
Single responsibility |
Mistake | Example | Why It's Bad |
---|---|---|
Import feature internals | import X from '@/features/auth/utils/hash' |
Breaks encapsulation, coupling |
Cross-feature imports | features/products imports from features/auth |
Creates dependency web |
Business logic in UI | Button component with API calls |
Violates separation of concerns |
Export everything | export * from './components' |
Bundle bloat, unclear API |
Generic feature names | features/misc/ or features/common/ |
Becomes dumping ground |
Shared importing features | shared/Header imports features/auth |
Circular dependencies |
Deep nesting | features/auth/components/forms/login/fields/ |
Hard to navigate, import hell |
Mixing concerns | Auth logic in products feature | Unclear boundaries |
π¨ Your code might need refactoring if:
- You're importing from
../../../features/
- Your UI component needs business logic
- Your feature imports another feature
- Your index.ts has 50+ exports
- You're bypassing index.ts to import internals
- Your shared component is only used by one feature
- Your build size keeps growing mysteriously
- You have circular dependency warnings
- ESLint keeps complaining about imports
- TypeScript can't resolve imports
Found duplicate code?
β
ββ Used by 2+ features?
β β
β ββ YES β Move to shared/
β β ββ Has business logic?
β β ββ YES β components/shared/
β β ββ NO β components/ui/
β β
β ββ NO β Keep in feature
β
ββ Feature depends on another feature?
β
ββ Can logic move to shared?
β ββ YES β Move to shared/
β ββ NO β Pass data from app layer
β
ββ Should features be merged?
ββ YES β Combine into one feature
ββ NO β Keep separate, use app layer
β WRONG: Tightly coupled
// features/products/components/ProductList.tsx
import { useAuth } from "@/features/auth"; // β Cross-feature import
export function ProductList() {
const { user } = useAuth();
if (!user) return <LoginForm />; // β Products depends on auth UI
return <div>Products for {user.name}</div>;
}
Problems:
- Products feature depends on auth feature
- Can't test products without auth
- Can't reuse products in public pages
β RIGHT: Composed at app layer
// features/products/components/ProductList.tsx
type ProductListProps = {
userName: string;
};
export function ProductList({ userName }: ProductListProps) {
return <div>Products for {userName}</div>;
}
// app/products/page.tsx
import { redirect } from "next/navigation";
import { getServerSession } from "@/features/auth";
import { ProductList } from "@/features/products";
export default async function ProductsPage() {
const session = await getServerSession();
if (!session) redirect("/login");
return <ProductList userName={session.user.name} />;
}
Benefits:
- β Products feature is independent
- β Easy to test in isolation
- β Can pass different data sources
- β App layer controls the flow
Scenario: Multiple features need to display user data.
β WRONG: Duplicated in each feature
// features/products/components/ProductAuthor.tsx
import { useQuery } from "@tanstack/react-query";
export function ProductAuthor({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
if (!user) return <div>Loading...</div>;
return (
<div>
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</div>
);
}
// features/comments/components/CommentAuthor.tsx
// β Same logic duplicated!
export function CommentAuthor({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
if (!user) return <div>Loading...</div>;
return (
<div>
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</div>
);
}
Problems:
- Code duplication
- Inconsistent UI
- Hard to maintain
- Both features make separate API calls
β RIGHT: Shared component + hook
// store/users.ts
import { create } from "zustand";
type User = {
id: string;
name: string;
avatar: string;
};
type UserStore = {
users: Record<string, User>;
fetchUser: (id: string) => Promise<User>;
};
export const useUserStore = create<UserStore>((set, get) => ({
users: {},
fetchUser: async (id) => {
const existing = get().users[id];
if (existing) return existing;
const user = await fetch(`/api/users/${id}`).then((r) => r.json());
set((state) => ({ users: { ...state.users, [id]: user } }));
return user;
},
}));
// components/shared/UserDisplay.tsx
import { useEffect, useState } from "react";
import { Avatar } from "@/components/ui/avatar";
import { useUserStore } from "@/store/users";
type UserDisplayProps = {
userId: string;
showEmail?: boolean;
};
export function UserDisplay({ userId, showEmail = false }: UserDisplayProps) {
const { users, fetchUser } = useUserStore();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!users[userId]) {
setLoading(true);
fetchUser(userId).finally(() => setLoading(false));
}
}, [userId, fetchUser, users]);
const user = users[userId];
if (loading) return <div className="animate-pulse">Loading...</div>;
if (!user) return null;
return (
<div className="flex items-center gap-2">
<Avatar src={user.avatar} alt={user.name} />
<div>
<p className="font-medium">{user.name}</p>
{showEmail && <p className="text-sm text-muted">{user.email}</p>}
</div>
</div>
);
}
// features/products/components/ProductAuthor.tsx
import { UserDisplay } from "@/components/shared/UserDisplay";
export function ProductAuthor({ userId }: { userId: string }) {
return <UserDisplay userId={userId} />;
}
// features/comments/components/CommentAuthor.tsx
import { UserDisplay } from "@/components/shared/UserDisplay";
export function CommentAuthor({ userId }: { userId: string }) {
return <UserDisplay userId={userId} showEmail />;
}
Benefits:
- β Single source of truth
- β Consistent UI across features
- β Cached user data (no duplicate API calls)
- β Easy to extend (add more props)
- β Maintainable in one place
Scenario: Shopping cart needs product data.
β WRONG: Direct feature imports
// features/cart/hooks/useCart.ts
import { useProducts } from "@/features/products"; // β
export function useCart() {
const products = useProducts(); // Direct dependency
const addToCart = (productId: string) => {
const product = products.find((p) => p.id === productId);
// ...
};
return { addToCart };
}
β RIGHT: App-level data provider
// app/providers.tsx
"use client";
import { createContext, useContext, ReactNode } from "react";
import { useProducts } from "@/features/products";
import type { Product } from "@/types/product";
const ProductContext = createContext<Product[]>([]);
export function useProductContext() {
return useContext(ProductContext);
}
export function ProductProvider({ children }: { children: ReactNode }) {
const products = useProducts();
return (
<ProductContext.Provider value={products}>
{children}
</ProductContext.Provider>
);
}
// features/cart/hooks/useCart.ts
import { useProductContext } from "@/app/providers";
export function useCart() {
const products = useProductContext(); // From app layer
const addToCart = (productId: string) => {
const product = products.find((p) => p.id === productId);
// ...
};
return { addToCart };
}
// app/layout.tsx
import { ProductProvider } from "./providers";
export default function RootLayout({ children }) {
return (
<html>
<body>
<ProductProvider>{children}</ProductProvider>
</body>
</html>
);
}
Benefits:
- β Features remain independent
- β App layer controls data flow
- β Easy to test features in isolation
- β Can swap data sources easily
β WRONG: Validation scattered everywhere
// features/auth/components/SignupForm.tsx
export function SignupForm() {
const handleSubmit = (data: FormData) => {
// β Validation logic in component
const email = data.get("email") as string;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
alert("Invalid email");
return;
}
// ...
};
}
// features/profile/components/ProfileForm.tsx
export function ProfileForm() {
const handleSubmit = (data: FormData) => {
// β Same validation duplicated
const email = data.get("email") as string;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
alert("Invalid email");
return;
}
// ...
};
}
β RIGHT: Centralized validation
// utils/validation.ts
import { z } from "zod";
export const emailSchema = z.string().email("Invalid email address");
export const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters");
export const signupSchema = z
.object({
email: emailSchema,
password: passwordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const profileSchema = z.object({
email: emailSchema,
name: z.string().min(2, "Name must be at least 2 characters"),
bio: z.string().max(500, "Bio must be less than 500 characters").optional(),
});
// features/auth/components/SignupForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema } from "@/utils/validation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signupSchema),
});
const onSubmit = (data) => {
// Data is already validated!
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input {...register("email")} />
{errors.email && <p className="text-red-500">{errors.email.message}</p>}
<Input type="password" {...register("password")} />
{errors.password && (
<p className="text-red-500">{errors.password.message}</p>
)}
<Input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && (
<p className="text-red-500">{errors.confirmPassword.message}</p>
)}
<Button type="submit">Sign Up</Button>
</form>
);
}
// features/profile/components/ProfileForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { profileSchema } from "@/utils/validation";
export function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(profileSchema),
});
const onSubmit = (data) => {
// Data is already validated!
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input {...register("email")} />
{errors.email && <p className="text-red-500">{errors.email.message}</p>}
<Input {...register("name")} />
{errors.name && <p className="text-red-500">{errors.name.message}</p>}
<Input {...register("bio")} />
{errors.bio && <p className="text-red-500">{errors.bio.message}</p>}
<Button type="submit">Update Profile</Button>
</form>
);
}
Benefits:
- β Single source of truth for validation
- β Reusable schemas
- β Type-safe forms
- β Consistent error messages
- β Easy to test validation logic
β WRONG: Fetch scattered everywhere
// features/products/hooks/useProducts.ts
export function useProducts() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("https://api.example.com/products")
.then((r) => r.json())
.then(setProducts);
}, []);
return products;
}
// features/orders/hooks/useOrders.ts
export function useOrders() {
const [orders, setOrders] = useState([]);
useEffect(() => {
fetch("https://api.example.com/orders")
.then((r) => r.json())
.then(setOrders);
}, []);
return orders;
}
β RIGHT: Centralized API client
// lib/api/client.ts
type ApiConfig = {
baseURL: string;
headers?: HeadersInit;
};
class ApiClient {
private baseURL: string;
private headers: HeadersInit;
constructor(config: ApiConfig) {
this.baseURL = config.baseURL;
this.headers = config.headers || {};
}
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: "GET",
headers: this.headers,
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
async post<T>(endpoint: string, data: unknown): Promise<T> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: "POST",
headers: {
...this.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
}
export const apiClient = new ApiClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || "https://api.example.com",
headers: {
"X-API-Key": process.env.NEXT_PUBLIC_API_KEY || "",
},
});
// features/products/api/productService.ts
import { apiClient } from "@/lib/api/client";
import type { Product } from "../types";
export async function fetchProducts(): Promise<Product[]> {
return apiClient.get<Product[]>("/products");
}
export async function fetchProductById(id: string): Promise<Product> {
return apiClient.get<Product>(`/products/${id}`);
}
export async function createProduct(
product: Omit<Product, "id">
): Promise<Product> {
return apiClient.post<Product>("/products", product);
}
// features/products/hooks/useProducts.ts
import { useQuery } from "@tanstack/react-query";
import { fetchProducts } from "../api/productService";
export function useProducts() {
return useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
}
Benefits:
- β Centralized error handling
- β Consistent headers (auth, API keys)
- β Easy to add interceptors
- β Type-safe API calls
- β Easy to mock for testing
- β Can swap implementations (REST β GraphQL)
Error:
π₯ A feature may only import items from shared folders and its own family.
Importing items from another feature is prohibited. π₯
src/features/products/hooks/useProducts.ts
> import { useAuth } from '@/features/auth';
Solutions:
Option 1: Move common logic to shared
// β BEFORE
// features/products/hooks/useProducts.ts
import { useAuth } from "@/features/auth";
export function useProducts() {
const { user } = useAuth();
// ...
}
// β
AFTER
// features/products/hooks/useProducts.ts
export function useProducts(userId?: string) {
// Accept userId as parameter
// ...
}
// app/products/page.tsx
import { useAuth } from "@/features/auth";
import { useProducts } from "@/features/products";
export default function ProductsPage() {
const { user } = useAuth();
const products = useProducts(user?.id); // App layer connects them
return <ProductList products={products} />;
}
Option 2: Use app-level provider
// app/providers.tsx
import { createContext, useContext } from "react";
import { useAuth } from "@/features/auth";
const AuthContext = createContext(null);
export function useAuthContext() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const auth = useAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
// features/products/hooks/useProducts.ts
import { useAuthContext } from "@/app/providers";
export function useProducts() {
const { user } = useAuthContext(); // From app layer
// ...
}
Option 3: Consider merging features
If two features are tightly coupled, maybe they should be one feature!
// features/user-products/
// βββ auth/
// βββ products/
// βββ index.ts
Symptoms:
npm run build
Page Size First Load JS
β β /products 450 kB 525 kB # π¨ Too large!
Check 1: Are you exporting too much?
// β BAD: features/products/index.ts
export * from "./components"; // Exports ALL 20 components
export * from "./hooks"; // Exports ALL hooks
export * from "./utils"; // Exports ALL utilities
// β
GOOD: features/products/index.ts
export { ProductList } from "./components/ProductList";
export { ProductCard } from "./components/ProductCard";
export { useProducts } from "./hooks/useProducts";
Check 2: Is optimizePackageImports configured?
# Verify your next.config.ts has:
grep -A 5 "optimizePackageImports" next.config.ts
# Should see:
# optimizePackageImports: ['@/features/auth', '@/features/products', ...]
Check 3: Run bundle analyzer
ANALYZE=true npm run build
This opens an interactive visualization. Look for:
- π Duplicate packages
- π Unexpectedly large modules
- π Unused dependencies
Check 4: Are you importing the right way?
// β BAD: Imports feature internals (bypasses optimization)
import { ProductCard } from "@/features/products/components/ProductCard";
// β
GOOD: Imports from public API (benefits from optimization)
import { ProductCard } from "@/features/products";
Symptoms:
Warning: Circular dependency detected:
src/components/shared/Header.tsx
-> src/features/auth/index.ts
-> src/components/shared/UserMenu.tsx
-> src/components/shared/Header.tsx
Cause: Layers importing from higher layers
// β CIRCULAR DEPENDENCY
// components/shared/Header.tsx
import { useAuth } from "@/features/auth"; // Shared β Features
// features/auth/components/UserMenu.tsx
import { Header } from "@/components/shared/Header"; // Features β Shared
Solution: Follow the layer hierarchy
UI β Shared β Features β App
(Lower layers NEVER import from higher layers)
Fix:
// β
CORRECT
// components/shared/Header.tsx
type HeaderProps = {
user?: { name: string; avatar: string };
onLogout?: () => void;
};
export function Header({ user, onLogout }: HeaderProps) {
// No feature imports!
return (
<header>{user && <UserMenu user={user} onLogout={onLogout} />}</header>
);
}
// app/layout.tsx
import { Header } from "@/components/shared/Header";
import { useAuth } from "@/features/auth";
export default function RootLayout({ children }) {
const { user, logout } = useAuth();
return (
<html>
<body>
<Header user={user} onLogout={logout} />
{children}
</body>
</html>
);
}
Symptoms:
# After adding features/notifications/
npm run build
# Bundle still large, optimization not working
Check 1: Feature structure
ls -la src/features/notifications/
# Should have:
# index.ts β REQUIRED!
# components/
# hooks/
# etc.
Check 2: Rebuild
# Clear Next.js cache
rm -rf .next
# Rebuild
npm run build
The config dynamically reads features at build time, so a clean rebuild should detect it.
Check 3: Manual verification
# Check next.config.ts is reading features
node -e "
const { readdirSync } = require('fs');
const path = require('path');
const featuresDir = path.resolve(__dirname, 'src/features');
const features = readdirSync(featuresDir);
console.log('Features found:', features);
"
# Should output: Features found: [ 'auth', 'products', 'notifications', ... ]
Check 4: Verify in build output
npm run build -- --debug
# Look for lines like:
# Optimizing package imports: @/features/auth, @/features/products, @/features/notifications
Symptoms:
import { LoginForm } from "@/features/auth";
// ^^^^^^^^^^^^^^^^^ Cannot find module
Check 1: tsconfig.json paths
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"] // β Must be present
}
}
}
Check 2: Restart TypeScript server
In VS Code:
Cmd/Ctrl + Shift + P
- "TypeScript: Restart TS Server"
Check 3: Verify file exists
ls src/features/auth/index.ts
# If missing, create it!
Check 4: Check for typos
// β WRONG: Case sensitivity
import { LoginForm } from "@/Features/auth";
// β
CORRECT
import { LoginForm } from "@/features/auth";
Symptoms:
// This should error but doesn't:
import { useAuth } from "@/features/auth"; // In products feature
Check 1: ESLint running?
npm run lint
# If not set up:
npm run lint -- src/features/products/hooks/useProducts.ts
Check 2: Plugin installed?
npm list eslint-plugin-project-structure
# Should show version
# If missing:
npm install -D eslint-plugin-project-structure
Check 3: Config loaded?
# Verify eslint.config.mjs imports independentModuleConfig
grep "independentModulesConfig" eslint.config.mjs
# Should see: import { independentModulesConfig } from './independentModuleConfig.mjs';
Check 4: IDE integration
In VS Code, install ESLint extension:
code --install-extension dbaeumer.vscode-eslint
Restart VS Code after installation.
Add to package.json
:
{
"scripts": {
"build": "next build",
"build:analyze": "ANALYZE=true next build",
"build:check": "npm run build && ls -lh .next/static/chunks/pages/*.js"
}
}
Run regularly:
npm run build:analyze
// β
GOOD: Dynamic imports for heavy features
import dynamic from "next/dynamic";
const AdminDashboard = dynamic(() => import("@/features/admin"), {
loading: () => <div>Loading admin panel...</div>,
});
export default function AdminPage() {
return <AdminDashboard />;
}
// β
Use Next.js Image component
import Image from "next/image";
<Image
src="/product.jpg"
alt="Product"
width={500}
height={500}
loading="lazy"
/>;
// app/products/page.tsx - Server Component by default
import { ProductList } from "@/features/products";
export default async function ProductsPage() {
// Fetch data on server
const products = await fetch("https://api.example.com/products").then((r) =>
r.json()
);
return <ProductList products={products} />;
}
// features/products/components/ProductList.tsx - Client Component only when needed
("use client");
export function ProductList({ products }) {
// Interactive features
const [filter, setFilter] = useState("");
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
{products
.filter((p) => p.name.includes(filter))
.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
{
"compilerOptions": {
// Language & Runtime
"target": "ES2017", // Compile to ES2017 (async/await support)
"lib": ["dom", "dom.iterable", "esnext"], // Include DOM & latest JS features
"jsx": "preserve", // Keep JSX for Next.js to transform
// Module System
"module": "esnext", // Use latest ESM
"moduleResolution": "bundler", // Optimize for bundlers (Webpack/Next.js)
"resolveJsonModule": true, // Allow importing JSON files
"esModuleInterop": true, // Better CommonJS interop
"isolatedModules": true, // Each file can be transpiled independently
// Type Checking
"strict": true, // Enable all strict type checks
"skipLibCheck": true, // Don't type-check node_modules (faster)
"allowJs": true, // Allow JavaScript files
// Emit
"noEmit": true, // Don't output JS (Next.js handles it)
"incremental": true, // Cache for faster rebuilds
// Path Mapping
"paths": {
"@/*": ["./src/*"] // Enable @/ imports
},
// Next.js Integration
"plugins": [
{
"name": "next" // Next.js TypeScript plugin
}
]
},
"include": [
"next-env.d.ts", // Next.js types
"**/*.ts", // All TypeScript files
"**/*.tsx", // All React files
".next/types/**/*.ts", // Generated types
"eslint.config.mjs", // ESLint config
"independentModuleConfig.mjs" // Module config
],
"exclude": [
"node_modules" // Don't include dependencies
]
}
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { independentModulesConfig } from "./independentModuleConfig.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
{
ignores: ["**/.next/**", "**/node_modules/**"],
},
...compat.config({
extends: [
"next", // Next.js best practices
"next/core-web-vitals", // Performance rules
"plugin:@typescript-eslint/recommended-type-checked", // Type safety
"plugin:@typescript-eslint/strict-type-checked", // Strict types
"plugin:@typescript-eslint/stylistic-type-checked", // Code style
"plugin:jsx-a11y/recommended", // Accessibility
"prettier", // Prettier integration
"plugin:prettier/recommended", // Prettier as ESLint rules
],
parser: "@typescript-eslint/parser",
parserOptions: {
projectService: true, // Auto-find tsconfig
tsconfigRootDir: __dirname, // TypeScript root
},
root: true,
plugins: ["@typescript-eslint", "eslint-plugin-project-structure"],
rules: {
// Code Style
"linebreak-style": ["error", "unix"],
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true },
],
// Architecture Enforcement (THE KEY RULE)
"project-structure/independent-modules": [
"error",
independentModulesConfig,
],
},
}),
];
export default eslintConfig;
// @ts-check
import { createIndependentModules } from "eslint-plugin-project-structure";
export const independentModulesConfig = createIndependentModules({
modules: [
// Layer 4: App (Most Permissive)
{
name: "App folder",
pattern: "src/app/**",
allowImportsFrom: [
"src/app/global.css", // Global styles
"{sharedImports}", // Shared layer
"{uiImports}", // UI layer
"src/features/*/index.(ts|tsx)", // Feature public APIs only
],
errorMessage:
"π₯ App folder can only import from {sharedImports},{uiImports} and src/features/*/index.ts files π₯",
},
// Layer 3: Features
{
name: "Features",
pattern: "src/features/**",
allowImportsFrom: [
"{family_3}/**", // Own feature files
"{sharedImports}", // Shared layer
"{uiImports}", // UI layer
],
errorMessage:
"π₯ A feature may only import items from shared folders and its own family. Importing items from another feature is prohibited. π₯",
},
// Layer 2: Shared
{
name: "Shared",
pattern: [
"src/components/shared/**",
"src/lib/*/**",
"src/config/**",
"src/types/**",
"src/store/**",
"src/utils/**",
],
allowImportsFrom: [
"{sharedImports}", // Other shared folders
"{uiImports}", // UI layer
],
errorMessage:
"π₯ Shared folders are not allowed to import items from the `features` folder. π₯",
},
// Layer 1: UI (Most Restricted)
{
name: "UI",
pattern: ["src/components/ui/*", "src/styles/**", "src/lib/*"],
allowImportsFrom: [
"{uiImports}", // Only other UI components
],
},
// Guard: Catch unknown files
{
name: "Unknown files",
pattern: [
["src/**", "!src/*"], // All files except root level
],
allowImportsFrom: [],
allowExternalImports: false,
errorMessage:
"π₯ This file is not specified as an independent module in `independentModules.jsonc`. π₯",
},
],
// Reusable patterns for DRY configuration
reusableImportPatterns: {
sharedImports: [
"src/components/shared/**",
"src/lib/*/**",
"src/config/**",
"src/types/**",
"src/store/**",
"src/utils/**",
],
uiImports: ["src/components/ui/*", "src/styles/**", "src/lib/*"],
neverImports: [
"src/*", // Root level files (except middleware, etc.)
],
},
});
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
import path from "path";
import { readdirSync } from "fs";
// Bundle Analyzer Configuration
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
// Dynamic Feature Discovery
const featuresDir = path.resolve(__dirname, "src/features");
const featureFolders = readdirSync(featuresDir).map(
(folder) => `@/features/${folder}`
);
// Next.js Configuration
const nextConfig: NextConfig = {
// Enable React Strict Mode for better development experience
reactStrictMode: true,
// Image Optimization Configuration
images: {
remotePatterns: [
{
protocol: "https",
hostname: "randomuser.me",
},
// Add more patterns as needed
// {
// protocol: 'https',
// hostname: 'your-cdn.com',
// },
],
},
// Experimental Features
experimental: {
// Automatic barrel file optimization for all features
// This enables tree-shaking and code splitting per feature
optimizePackageImports: [...featureFolders],
},
};
export default withBundleAnalyzer(nextConfig);
Add these helpful scripts to your package.json
:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"type-check": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"build:analyze": "ANALYZE=true next build",
"clean": "rm -rf .next node_modules",
"check-all": "npm run type-check && npm run lint && npm run format:check"
}
}
Centralized feature flag management:
// config/features.ts
export const FEATURES = {
ENABLE_NEW_DASHBOARD:
process.env.NEXT_PUBLIC_FEATURE_NEW_DASHBOARD === "true",
ENABLE_DARK_MODE: process.env.NEXT_PUBLIC_FEATURE_DARK_MODE === "true",
ENABLE_ANALYTICS: process.env.NEXT_PUBLIC_FEATURE_ANALYTICS === "true",
} as const;
// utils/featureFlags.ts
import { FEATURES } from "@/config/features";
export function isFeatureEnabled(feature: keyof typeof FEATURES): boolean {
return FEATURES[feature];
}
// app/dashboard/page.tsx
import { isFeatureEnabled } from "@/utils/featureFlags";
import { DashboardV1 } from "@/features/dashboard-v1";
import { DashboardV2 } from "@/features/dashboard-v2";
export default function DashboardPage() {
if (isFeatureEnabled("ENABLE_NEW_DASHBOARD")) {
return <DashboardV2 />;
}
return <DashboardV1 />;
}
Maintain consistency across features:
// types/common.ts
export type Timestamp = {
createdAt: Date;
updatedAt: Date;
};
export type WithId<T> = T & { id: string };
export type ApiResponse<T> = {
data: T;
meta: {
page: number;
total: number;
};
};
export type AsyncState<T> = {
data: T | null;
loading: boolean;
error: Error | null;
};
// features/products/types.ts
import type { Timestamp, WithId } from "@/types/common";
export type Product = WithId<{
name: string;
price: number;
description: string;
}> &
Timestamp;
// features/orders/types.ts
import type { Timestamp, WithId } from "@/types/common";
export type Order = WithId<{
productIds: string[];
total: number;
status: "pending" | "completed" | "cancelled";
}> &
Timestamp;
Type-safe environment variable management:
// config/env.ts
import { z } from "zod";
const envSchema = z.object({
// Public (Client-side)
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_APP_NAME: z.string(),
// Private (Server-side only)
DATABASE_URL: z.string().url(),
API_SECRET_KEY: z.string().min(32),
STRIPE_SECRET_KEY: z.string(),
});
// Validate environment variables at build time
const env = envSchema.parse(process.env);
export const ENV = {
// Public
apiUrl: env.NEXT_PUBLIC_API_URL,
appName: env.NEXT_PUBLIC_APP_NAME,
// Private (only accessible on server)
databaseUrl: env.DATABASE_URL,
apiSecretKey: env.API_SECRET_KEY,
stripeSecretKey: env.STRIPE_SECRET_KEY,
} as const;
// Usage
// lib/api/client.ts
import { ENV } from "@/config/env";
export const apiClient = new ApiClient({
baseURL: ENV.apiUrl, // β
Type-safe, validated
});
Feature-level error isolation:
// components/shared/ErrorBoundary.tsx
"use client";
import { Component, ReactNode } from "react";
type ErrorBoundaryProps = {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
};
type ErrorBoundaryState = {
hasError: boolean;
error?: Error;
};
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
console.error("Error caught by boundary:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h2 className="text-red-800 font-semibold">Something went wrong</h2>
<p className="text-red-600">{this.state.error?.message}</p>
</div>
)
);
}
return this.props.children;
}
}
// app/products/page.tsx
import { ErrorBoundary } from "@/components/shared/ErrorBoundary";
import { ProductList } from "@/features/products";
export default function ProductsPage() {
return (
<ErrorBoundary fallback={<div>Failed to load products</div>}>
<ProductList />
</ErrorBoundary>
);
}
Shared loading components:
// components/shared/LoadingState.tsx
import { Loader2 } from "@/components/ui/icons";
type LoadingStateProps = {
message?: string;
fullScreen?: boolean;
};
export function LoadingState({
message = "Loading...",
fullScreen = false,
}: LoadingStateProps) {
const className = fullScreen
? "fixed inset-0 flex items-center justify-center bg-background/80"
: "flex items-center justify-center p-8";
return (
<div className={className}>
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-muted-foreground">{message}</p>
</div>
</div>
);
}
// features/products/components/ProductList.tsx
import { LoadingState } from "@/components/shared/LoadingState";
import { useProducts } from "../hooks/useProducts";
export function ProductList() {
const { data: products, loading, error } = useProducts();
if (loading) return <LoadingState message="Loading products..." />;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Feature-level testing:
// features/products/hooks/useProducts.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { useProducts } from "./useProducts";
import { fetchProducts } from "../api/productService";
// Mock the API service
jest.mock("../api/productService");
describe("useProducts", () => {
it("should fetch products on mount", async () => {
const mockProducts = [
{ id: "1", name: "Product 1", price: 100 },
{ id: "2", name: "Product 2", price: 200 },
];
(fetchProducts as jest.Mock).mockResolvedValue(mockProducts);
const { result } = renderHook(() => useProducts());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockProducts);
});
});
it("should handle errors", async () => {
const mockError = new Error("Failed to fetch");
(fetchProducts as jest.Mock).mockRejectedValue(mockError);
const { result } = renderHook(() => useProducts());
await waitFor(() => {
expect(result.current.error).toEqual(mockError);
});
});
});
// features/products/components/ProductCard.test.tsx
import { render, screen } from "@testing-library/react";
import { ProductCard } from "./ProductCard";
describe("ProductCard", () => {
const mockProduct = {
id: "1",
name: "Test Product",
price: 99.99,
image: "/test.jpg",
};
it("should render product details", () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("$99.99")).toBeInTheDocument();
});
it("should call onAddToCart when button clicked", () => {
const onAddToCart = jest.fn();
render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
const button = screen.getByRole("button", { name: /add to cart/i });
button.click();
expect(onAddToCart).toHaveBeenCalledWith(mockProduct.id);
});
});
Step 1: Analyze Current Structure
# List all components
find src -name "*.tsx" -o -name "*.ts" | grep -v node_modules
# Identify features
# Look for related files that should be grouped together
Step 2: Create Feature Folders
mkdir -p src/features/{auth,products,dashboard,profile}
# For each feature:
mkdir -p src/features/auth/{components,hooks,api,utils,types}
Step 3: Move Files Gradually
Start with one feature:
# Move auth-related files
git mv src/components/LoginForm.tsx src/features/auth/components/
git mv src/components/SignupForm.tsx src/features/auth/components/
git mv src/hooks/useAuth.ts src/features/auth/hooks/
git mv src/api/authService.ts src/features/auth/api/
Step 4: Create Index Files
// src/features/auth/index.ts
export { LoginForm } from "./components/LoginForm";
export { SignupForm } from "./components/SignupForm";
export { useAuth } from "./hooks/useAuth";
export type { User, AuthState } from "./types";
Step 5: Update Imports
# Find all imports that need updating
grep -r "import.*from.*components/LoginForm" src/
# Update them to use the new structure
# Before: import { LoginForm } from '@/components/LoginForm';
# After: import { LoginForm } from '@/features/auth';
Step 6: Add Configuration
- Add TypeScript paths (already done in tsconfig.json)
- Add ESLint rules (eslint.config.mjs + independentModuleConfig.mjs)
- Add Next.js optimization (next.config.ts)
Step 7: Verify
npm run type-check
npm run lint
npm run build
Step 8: Repeat for Each Feature
Migrate one feature at a time, testing after each migration.
Context: As applications grow, organizing by technical type (components/, hooks/, utils/) becomes difficult to navigate and maintain.
Decision: Organize code by business domain (features/) instead of technical type.
Consequences:
- β Features are self-contained and easier to understand
- β Related code is co-located
- β Easier to onboard new developers
- β Can extract features to separate packages if needed
β οΈ Requires discipline to maintain boundariesβ οΈ Initial setup takes more time
Context: Developers might accidentally create cross-feature dependencies, breaking the architecture.
Decision:
Use eslint-plugin-project-structure
to enforce import rules automatically.
Consequences:
- β Catches violations immediately during development
- β Prevents architectural decay
- β Makes code reviews easier
- β Self-documenting architecture
β οΈ Can be frustrating initially for developersβ οΈ Requires clear documentation
Context: Manually updating next.config.ts when adding features is error-prone.
Decision: Automatically read src/features/ directory at build time.
Consequences:
- β Zero configuration when adding features
- β No manual updates needed
- β Less prone to mistakes
β οΈ Depends on filesystem at build timeβ οΈ Requires consistent folder structure
Context: TypeScript has two ways to define object types.
Decision:
Enforce type aliases with @typescript-eslint/consistent-type-definitions
.
Consequences:
- β More flexible (unions, intersections)
- β Consistent codebase style
- β Better for composition
β οΈ Slight learning curve for interface users
- Feature name clearly represents a business domain
- Feature is independent enough to stand alone
- No overlap with existing features
- Clear public API planned
- Create proper folder structure (components/, hooks/, api/, etc.)
- Create index.ts with selective exports
- Keep internal utilities private
- Add types.ts for TypeScript definitions
- Only import from allowed layers
- Used by 2+ features
- No feature-specific logic
- Has clear, general purpose
- Properly tested
-
npm run type-check
passes -
npm run lint
passes -
npm run build
succeeds - No new ESLint violations
- Bundle size is reasonable
- Follows layer hierarchy
- No cross-feature imports
- Public APIs are minimal
- Shared code is truly shared
- No business logic in UI components
- Types are properly exported
Setting up a new project with this architecture:
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm install -D \
eslint-plugin-project-structure \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin \
eslint-config-prettier \
eslint-plugin-prettier \
prettier
mkdir -p src/{features,components/{shared,ui},lib,config,types,store,utils,styles}
Copy the configuration files from this guide:
tsconfig.json
eslint.config.mjs
independentModuleConfig.mjs
next.config.ts
mkdir -p src/features/auth/{components,hooks,api,types}
touch src/features/auth/index.ts
npm run lint
npm run type-check
npm run build
- Next.js Documentation
- TypeScript Handbook
- ESLint Plugin Project Structure
- Feature-Sliced Design
- Clean Architecture
- Vertical Slice Architecture: Similar approach focusing on business capabilities
- Modular Monolith: Architecture pattern this implements
- Domain-Driven Design: Inspiration for feature boundaries
- Hexagonal Architecture: Ports and adapters pattern influence
You now have a complete understanding of this Next.js architecture! Here's what makes it special:
- TypeScript - Clean imports with path aliases
- ESLint - Enforced architectural boundaries
- Next.js - Optimized bundle sizes
- π― Features are independent domains
- π Layers enforce separation of concerns
- π¦ Bundle optimization happens automatically
- π¦ ESLint catches violations immediately
- π TypeScript ensures type safety
"Architecture is about making the important things easy and the wrong things hard."
This architecture makes:
- β Easy: Adding features, maintaining code, scaling teams
- β Hard: Creating coupling, breaking boundaries, writing spaghetti code
Q: Can a feature have sub-features? A: No, keep features flat. If you need sub-grouping, it might be a sign to split into separate features or reconsider the domain boundaries.
Q: What if I need to share code between two features? A: Move it to the shared layer or pass data through the app layer. Cross-feature imports are prohibited.
Q: Can I use this with Pages Router?
A: Yes! The principles apply equally. Just replace app/
with pages/
in the configuration.
Q: How do I handle global state?
A: Use the store/
folder with your preferred state management (Zustand, Redux, etc.). Features can consume from the store.
Q: What about utility functions used in only one feature?
A: Keep them in that feature's utils/
folder. Only move to shared utils/
when used by multiple features.
Q: Can I nest features?
A: No, keep features flat for simplicity. Use clear naming instead: user-management
, user-authentication
, etc.
Q: How do I test this architecture? A: Test features in isolation. The enforced boundaries make testing easier since dependencies are explicit.
Happy coding! π
Remember: Good architecture is invisible when it works and obvious when it doesn't. This architecture works.