- Published on
Building Modular Architecture in Next.js
- Authors

- Name
- Rakesh Tembhurne
- @tembhurnerakesh
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:
- Modular Design: Keep features self-contained with clear boundaries
- Type Safety: Use TypeScript and Zod for robust validation
- Separation of Concerns: Separate client and server logic appropriately
- Local Configuration: Use local tsconfig.json for path aliases
- 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
Why I Built My Own Next.js Boilerplate: Breaking Free from One-Size-Fits-All Solutions
In an AI-driven world where technology evolves rapidly, discover why I created a developer-centric Next.js boilerplate that prioritizes your preferred tools, instant theme switching, and automated UI generation.
Docker Swarm: Complete Deployment Guide for NestJS & Next.js Apps
Complete guide to deploying NestJS 11 backend and Next.js 15 frontend using Docker Swarm - single stack file for both dev and production, explicit 3-server setup with step-by-step commands.