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

- Name
- Rakesh Tembhurne
- @tembhurnerakesh
Feature Management with Real CRUD Example
Adding, removing, or managing features as independent modules matters when you run a SaaS platform, an enterprise dashboard, or a modular CMS. Git submodules plus clear separation of concerns give you room to extend functionality without tangling everything together.
This guide shows how to implement that in Next.js 15, using a complete products CRUD (Create, Read, Update, Delete) example. It covers modular component design through feature isolation, with working code you can use directly.
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 rule of plugin architecture is component modularity. Each feature should be built from reusable, independent components that avoid global state and tight coupling.
// 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 working plugin setup needs a clear API layer for data fetching, caching, and error handling. Separate client and server APIs keep performance up and types consistent.
// 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 performance and SEO, use server-side data fetching functions 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 keeps bad data out and gives you autocomplete and clear errors.
// 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 keep the plugin consistent. Here is the full 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
Plugins need state management that survives page reloads and works 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
Let users pick which table columns they see:
// 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
API routes for CRUD operations with 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 makes scaling and maintenance easier. With the products CRUD example above, new features can be:
- 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 validation that rejects bad data
- 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
Teams can work on features independently while keeping one user experience. The products CRUD code lives in the ai-starter-with-rocky repository as a template for this setup in your own Next.js apps.
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.