Published on

Building Modular Architecture in Next.js

Authors

Feature Management with Real CRUD Example

In modern web applications, the ability to add, remove, or manage features as independent modules is becoming increasingly crucial. Whether you're building a SaaS platform, an enterprise dashboard, or a modular content management system, modular architecture offers the flexibility to extend functionality through git submodules and clear separation of concerns.

This comprehensive guide explores how to implement a modular architecture in Next.js 15, using a complete products CRUD (Create, Read, Update, Delete) example. We'll cover everything from modular component design to feature isolation, with production-ready code you can implement immediately.

Why Modular Architecture Matters

Traditional monolithic applications bundle all features together, making it difficult to:

  • Scale selectively: Add new features without affecting existing ones
  • Maintain flexibility: Manage features as independent git submodules
  • Reduce complexity: Keep features isolated and testable
  • Iterate faster: Deploy features independently without full application releases

Modular architecture solves these problems by treating features as independent, self-contained modules that can be:

  • Isolated: Features don't interfere with each other
  • Versioned: Each feature has its own git history and versioning
  • Testable: Test features in isolation
  • Reusable: Copy features between projects using git submodules

The Products CRUD: A Complete Plugin Example

Our example implements a full-featured products management system that demonstrates plugin architecture principles. The entire feature can be added or removed from your Next.js application with minimal configuration changes.

Project Structure Overview

app/(protected)/products/
├── page.tsx                    # Main products listing page
├── create/page.tsx            # Product creation form
├── [id]/edit/page.tsx         # Product editing form
├── _components/               # Reusable UI components
│   ├── product-list.tsx       # Data table with filtering
│   └── product-form.tsx       # Create/edit form
├── _lib/                      # Business logic and API clients
│   ├── api-client.ts          # Client-side API functions
│   ├── server-api.ts          # Server-side data fetching
│   ├── api-response.ts        # Response type definitions
│   └── cache.ts               # Caching utilities
├── _types/                    # TypeScript type definitions
│   └── index.ts
└── _validations/              # Form validation schemas
    └── product.ts

This modular structure ensures each feature is self-contained and can be easily moved, modified, or removed.

Core Architecture Patterns

1. Modular Component Design

The first principle of plugin architecture is component modularity. Each feature should be composed of reusable, independent components that don't rely on global state or tightly coupled dependencies.

// app/(protected)/products/_components/product-list.tsx
'use client'

import * as React from 'react'
import { Button } from '@/components/ui/button'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { getProducts, deleteProduct, type Product } from '../_lib/api-client'

export default function ProductList({ data, pagination }: ProductListProps) {
  const handleDelete = async (productId: string) => {
    if (confirm('Are you sure you want to delete this product?')) {
      try {
        await deleteProduct(productId)
        // Trigger re-fetch or state update
        window.location.reload() // Or use proper state management
      } catch (error) {
        console.error('Failed to delete product:', error)
      }
    }
  }

  return (
    <div className="rounded-lg border">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Product Name</TableHead>
            <TableHead>Price</TableHead>
            <TableHead>Stock</TableHead>
            <TableHead>Status</TableHead>
            <TableHead className="w-[70px]">Actions</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {data.map((product) => (
            <TableRow key={product.id}>
              <TableCell className="font-medium">{product.name}</TableCell>
              <TableCell>${product.price}</TableCell>
              <TableCell>{product.stock}</TableCell>
              <TableCell>
                <Badge variant={product.status === 'ACTIVE' ? 'default' : 'secondary'}>
                  {product.status}
                </Badge>
              </TableCell>
              <TableCell>
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => handleDelete(product.id)}
                  className="text-red-600 hover:text-red-700"
                >
                  Delete
                </Button>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

2. Type-Safe API Layer

A robust plugin architecture requires a well-defined API layer that handles data fetching, caching, and error management. Separate client and server APIs ensure optimal performance and type safety.

// app/(protected)/products/_lib/api-client.ts
import type { Product } from '@/products/_types'
import type { ProductFormData } from '@/products/_validations/product'

// Client-side API functions for components
export async function getProducts(params: GetProductsParams = {}): Promise<{
  products: Product[]
  pagination: PaginationMeta
}> {
  const queryParams = new URLSearchParams()

  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined) {
      if (Array.isArray(value)) {
        queryParams.set(key, value.join(','))
      } else {
        queryParams.set(key, String(value))
      }
    }
  })

  const response = await fetch(`/api/products?${queryParams}`)
  const result: ApiResponse<Product[]> = await response.json()

  if (!result.success) {
    throw new Error(result.error || 'Failed to fetch products')
  }

  return result.data
}

export async function createProduct(data: ProductFormData): Promise<Product> {
  const response = await fetch('/api/products', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  })

  const result: ApiResponse<Product> = await response.json()

  if (!result.success) {
    throw new Error(result.error || 'Failed to create product')
  }

  return result.data
}

3. Server-Side Data Fetching

For optimal performance and SEO, implement server-side data fetching functions that can be used in Server Components or API routes.

// app/(protected)/products/_lib/server-api.ts
import { prisma } from '@/lib/db'
import type { Product } from '../_types'

export async function getProducts(params: GetProductsParams = {}) {
  const { page = 1, pageSize = 10, search = '', categories = [], statuses = [] } = params

  const skip = (page - 1) * pageSize
  const where: any = {}

  if (search) {
    where.OR = [
      { name: { contains: search, mode: 'insensitive' } },
      { sku: { contains: search, mode: 'insensitive' } },
    ]
  }

  if (categories.length > 0) {
    where.category = { in: categories }
  }

  if (statuses.length > 0) {
    where.status = { in: statuses }
  }

  const [total, products] = await Promise.all([
    prisma.product.count({ where }),
    prisma.product.findMany({
      where,
      orderBy: { createdAt: 'desc' },
      skip,
      take: pageSize,
    }),
  ])

  // Convert Decimal to string for client components
  const serializedProducts = products.map((product) => ({
    ...product,
    price: product.price.toString(),
    discountedPrice: product.discountedPrice?.toString() || null,
  }))

  return {
    products: serializedProducts,
    pagination: {
      total,
      page,
      pageSize,
      totalPages: Math.ceil(total / pageSize),
    },
  }
}

4. Zod Validation Schemas

Type-safe validation ensures data integrity and provides excellent developer experience with autocomplete and error handling.

// app/(protected)/products/_validations/product.ts
import * as z from 'zod'

export const productSchema = z.object({
  name: z.string().min(2, 'Product name must be at least 2 characters'),
  description: z.string().optional(),
  sku: z.string().optional(),
  price: z.string().min(1, 'Price is required'),
  discountedPrice: z.string().optional(),
  stock: z.string().default('0'),
  category: z.string().optional(),
  status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).default('DRAFT'),
  inStock: z.boolean().default(true),
  chargeTax: z.boolean().default(false),
})

5. TypeScript Type Definitions

Centralized type definitions ensure consistency across the entire plugin. Here's the complete types file:

// app/(protected)/products/_types/index.ts
import { Product as PrismaProduct } from '@prisma/client'

// Client-safe product type (Decimal -> string conversion)
export type Product = Omit<PrismaProduct, 'price' | 'discountedPrice'> & {
  price: string
  discountedPrice: string | null
}

// API response types
export interface ApiResponse<T> {
  success: boolean
  data: T
  error?: string
  meta?: Record<string, any>
}

// Pagination types
export interface PaginationMeta {
  page: number
  pageSize: number
  total: number
  totalPages: number
}

// API parameters
export interface GetProductsParams {
  page?: number
  pageSize?: number
  search?: string
  categories?: string[]
  statuses?: string[]
  sortBy?: string
  sortOrder?: 'asc' | 'desc'
  dateFrom?: string
  dateTo?: string
}

// Form data types
export interface ProductFormData {
  name: string
  description?: string
  sku?: string
  barcode?: string
  price: string
  discountedPrice?: string
  stock: string
  category?: string
  subCategory?: string
  status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'
  inStock: boolean
  chargeTax: boolean
}

// Component props
export interface ProductListProps {
  data: Product[]
  pagination: PaginationMeta
}

export interface PaginationData {
  total: number
  page: number
  pageSize: number
  totalPages: number
}

// Filter types
export interface ProductFilters {
  categories: string[]
  statuses: Array<{ value: string; label: string }>
}

6. Local TypeScript Configuration

Add a local tsconfig.json in the products folder to configure the @/products path alias:

// app/(protected)/products/tsconfig.json
{
  "extends": "../../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/products/*": ["./*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Advanced Features: Filtering and State Management

Modern plugins need sophisticated state management that persists across page reloads and integrates with URL routing.

URL-Based State Management

// Advanced filtering with URL state persistence
'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'

export function useProductFilters() {
  const router = useRouter()
  const searchParams = useSearchParams()

  const [filters, setFilters] = useState({
    search: searchParams.get('search') || '',
    categories: searchParams.get('categories')?.split(',') || [],
    statuses: searchParams.get('statuses')?.split(',') || [],
    page: parseInt(searchParams.get('page') || '1'),
  })

  const updateFilters = (newFilters: Partial<typeof filters>) => {
    const updated = { ...filters, ...newFilters }
    const params = new URLSearchParams()

    Object.entries(updated).forEach(([key, value]) => {
      if (value !== undefined && value !== '') {
        if (Array.isArray(value)) {
          if (value.length > 0) params.set(key, value.join(','))
        } else {
          params.set(key, String(value))
        }
      }
    })

    router.push(`/products?${params.toString()}`)
    setFilters(updated)
  }

  return { filters, updateFilters }
}

Dynamic Column Visibility

Allow users to customize their data table experience:

// Column visibility management
const [columnVisibility, setColumnVisibility] = useState({
  sku: true,
  category: true,
  stock: true,
  price: true,
  status: true,
})

// Persist to localStorage
useEffect(() => {
  const saved = localStorage.getItem('product-table-columns')
  if (saved) {
    setColumnVisibility(JSON.parse(saved))
  }
}, [])

useEffect(() => {
  localStorage.setItem('product-table-columns', JSON.stringify(columnVisibility))
}, [columnVisibility])

API Route Implementation

Complete API routes that handle CRUD operations with proper error handling and authentication.

// app/api/products/route.ts
import { NextRequest } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/db'
import { productSchema } from '@/products/_validations/product'
import { successResponse, ErrorResponses } from '@/products/_lib/api-response'

// GET /api/products
export async function GET(request: NextRequest) {
  try {
    const session = await auth()
    if (!session?.user) {
      return ErrorResponses.unauthorized()
    }

    const { searchParams } = new URL(request.url)
    const page = parseInt(searchParams.get('page') || '1')
    const pageSize = parseInt(searchParams.get('pageSize') || '10')
    const search = searchParams.get('search') || ''

    // Build filters...
    const where: any = {}
    if (search) {
      where.OR = [
        { name: { contains: search, mode: 'insensitive' } },
        { sku: { contains: search, mode: 'insensitive' } },
      ]
    }

    const [total, products] = await Promise.all([
      prisma.product.count({ where }),
      prisma.product.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * pageSize,
        take: pageSize,
      }),
    ])

    const serializedProducts = products.map((product) => ({
      ...product,
      price: product.price.toString(),
      discountedPrice: product.discountedPrice?.toString() || null,
    }))

    return successResponse(serializedProducts, {
      meta: {
        page,
        pageSize,
        total,
        totalPages: Math.ceil(total / pageSize),
      },
    })
  } catch (error) {
    console.error('Get products error:', error)
    return ErrorResponses.internalError()
  }
}

// POST /api/products
export async function POST(request: NextRequest) {
  try {
    const session = await auth()
    if (!session?.user) {
      return ErrorResponses.unauthorized()
    }

    const body = await request.json()
    const result = productSchema.safeParse(body)

    if (!result.success) {
      return ErrorResponses.validationError(result.error.errors)
    }

    const product = await prisma.product.create({
      data: {
        ...result.data,
        price: new Decimal(result.data.price),
        discountedPrice: result.data.discountedPrice
          ? new Decimal(result.data.discountedPrice)
          : null,
        stock: parseInt(result.data.stock, 10),
      },
    })

    const serialized = {
      ...product,
      price: product.price.toString(),
      discountedPrice: product.discountedPrice?.toString() || null,
    }

    return successResponse(serialized, { status: 201 })
  } catch (error) {
    console.error('Create product error:', error)
    return ErrorResponses.internalError()
  }
}

Database Schema

-- Products table (plugin-specific)
CREATE TABLE products (
  id VARCHAR(255) PRIMARY KEY DEFAULT gen_random_uuid()::text,
  name VARCHAR(255) NOT NULL,
  description TEXT,
  sku VARCHAR(100),
  barcode VARCHAR(100),
  price DECIMAL(10,2) NOT NULL,
  discounted_price DECIMAL(10,2),
  stock INTEGER DEFAULT 0,
  category VARCHAR(100),
  sub_category VARCHAR(100),
  status VARCHAR(20) DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')),
  in_stock BOOLEAN DEFAULT true,
  charge_tax BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

Best Practices and Performance Considerations

1. Code Splitting and Lazy Loading

// Lazy load feature components
const ProductManagement = dynamic(() => import('@/products/page'), {
  loading: () => <Skeleton className="h-96 w-full" />,
})

// Conditional loading based on user permissions
{
  userCanAccessProducts && <ProductManagement />
}

2. Feature Isolation

// Isolate feature state and side effects
function useFeatureState(featureId: string) {
  return useSWR(`feature-${featureId}`, () =>
    fetch(`/api/features/${featureId}/state`).then((r) => r.json())
  )
}

// Feature-specific error boundaries
class FeatureErrorBoundary extends Component {
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    // Log to feature-specific error tracking
    logFeatureError(this.props.featureId, error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return <FeatureErrorFallback featureId={this.props.featureId} />
    }
    return this.props.children
  }
}

3. Testing Strategies

// Feature testing utilities
export function createFeatureTestHarness() {
  return {
    mockApi: (endpoint: string, response: any) => {
      // Mock API responses for testing
    },
    renderWithProviders: (component: React.ReactElement) => {
      // Render with feature-specific context providers
    },
    simulateFeatureLoad: () => {
      // Simulate feature loading lifecycle
    },
  }
}

// Example test
describe('Products Feature', () => {
  const harness = createFeatureTestHarness()

  it('should create product successfully', async () => {
    harness.mockApi('/api/products', { success: true, data: mockProduct })

    const { result } = harness.renderWithProviders(<ProductForm />)

    await userEvent.type(screen.getByLabelText('Name'), 'Test Product')
    await userEvent.click(screen.getByText('Create'))

    expect(result.current).toBeDefined()
  })
})

Implementation Guide: Adding New Features

Follow these steps to add new modular features to your Next.js application:

Step 1: Create Feature Structure

# Create feature directory
mkdir -p app/(protected)/{feature-name}/_components
mkdir -p app/(protected)/{feature-name}/_lib
mkdir -p app/(protected)/{feature-name}/_types
mkdir -p app/(protected)/{feature-name}/_validations
mkdir -p app/api/{feature-name}

# Create local tsconfig for path aliases
touch app/(protected)/{feature-name}/tsconfig.json

Step 2: Implement Core Components

// app/(protected)/{feature-name}/_components/{feature-name}-list.tsx
'use client'

export default function FeatureList({ data, onAction }) {
  return <div className="feature-container">{/* Feature-specific UI */}</div>
}

Step 3: Add API Routes

// app/api/{feature-name}/route.ts
import { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  // Feature-specific API logic
}

export async function POST(request: NextRequest) {
  // Feature-specific API logic
}

Step 4: Configure Local TypeScript Paths

// app/(protected)/{feature-name}/tsconfig.json
{
  "extends": "../../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/{feature-name}/*": ["./*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Step 5: Add Navigation

// Update navigation component
<Link href="/{feature-name}">Feature Name</Link>

Conclusion

Modular architecture in Next.js 15 offers unprecedented flexibility for building scalable, maintainable applications. By following the patterns demonstrated in this products CRUD example, you can create features that are:

  • Modular: Easy to add, remove, or modify using git submodules
  • Type-safe: Full TypeScript support with runtime validation
  • Performant: Lazy loading and code splitting
  • Testable: Isolated testing and error boundaries
  • Scalable: Independent deployment and versioning

The key principles to remember:

  1. Modular Design: Keep features self-contained with clear boundaries
  2. Type Safety: Use TypeScript and Zod for robust validation
  3. Separation of Concerns: Separate client and server logic appropriately
  4. Local Configuration: Use local tsconfig.json for path aliases
  5. Error Isolation: Prevent feature failures from affecting the entire application

This architecture enables true modular development within Next.js, allowing teams to work independently on features while maintaining a cohesive user experience. Whether you're building a SaaS platform, an enterprise dashboard, or a modular CMS, this approach provides the foundation for long-term scalability and maintainability.

The complete code for this products CRUD feature is available in the ai-starter-with-rocky repository, serving as a production-ready template for implementing modular architecture in your Next.js applications.

Related Posts