Introduction
NextJS App Router is a revolution in building web applications with React, bringing optimized performance and a modern development experience:
- App Router: A new routing system based on React Server Components, allowing for complex data processing directly on the server, reducing the bundle size sent to the client, and supporting excellent data streaming.
- Folder-based Routing: Routing is defined based on the folder structure. Each folder represents a URL segment (segment), helping manage code intuitively and easily set up shared layouts.
- Server Actions: Allows you to write data processing functions (such as writing to a database) that run directly on the server but can be easily called from client components without needing to build manual API endpoints.
Here, when using the App Router, there will be special files to distinguish between global (root app) and local as follows:
- Global:
- global-error.tsx: This must be a client component, used as an error boundary for the entire app.
- layout.tsx: The shared layout part for the entire app.
- not-found.tsx: When accessing a route that has not been defined, it will redirect here.
- page.tsx: The first main page when you access.
- Local:
- Besides pages like layout.tsx, not-found.tsx, page.tsx similar to global, if you define them within a specific local route, these pages only take effect when accessing that route.
- error.tsx: Handles errors for the local route.
- route.ts: Route Handlers to define APIs, note that you cannot place the route.ts file in the same folder as the page.tsx file because NextJS will not be able to distinguish whether this is an API or a page accessible on the UI. Typically, you should place APIs into a separate folder (such as api for easier management).
- Private Folders: These are folders starting with the character _ (e.g., _component) used to store logic files. NextJS will completely ignore this directory in the routing system.
- Dynamic Routes: Folder names take the form [segment], used to create pages with dynamic data on the URL, for example, /product/[id]/page.tsx, then when accessed it will be /product/1.

Detail
First, create a .env file and change the value information appropriately if you are not using the default NextJS configuration:
PUBLIC_API = http://localhost:3000/api
Create the file app/_data/common.ts to simulate data; in reality, we will use data from a database:
export let productMap = new Map<string, any>([
['1', {id: '1', productName: 'productName 1', price: 999}],
['2', {id: '2', productName: 'productName 2', price: 1200}],
['3', {id: '3', productName: 'productName 3', price: 800}],
])
Create the file app/_type/common.ts:
export interface Product {
id: string
productName: string
price: number
}
Create the file app/_util/common.ts, with PUBLIC_API from the .env file defined above; note that this value is only used on the server side, so it will not exist on the client side:
const baseUrl = process.env.PUBLIC_API
export const callApi = async (url: string, init?: RequestInit) => {
const res = await fetch(baseUrl + '/' + url, init)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(errorData.message || 'Fetch failed')
}
return res.json()
}
Folder structure look like this
Create the file app/api/products/route.ts:
import {productMap} from '@/app/_data/common'
import {randomUUID} from 'crypto'
import {NextResponse, type NextRequest} from 'next/server'
export async function GET(request: NextRequest) {
try {
const {searchParams} = request.nextUrl
const countParam = searchParams.get('count')
let products = Array.from(productMap.values())
if (countParam) {
const count = parseInt(countParam, 10)
if (!isNaN(count) && count >= 0) {
products = products.slice(0, count)
}
}
await new Promise(resolve => setTimeout(resolve, 1000))
return NextResponse.json(products)
} catch (error) {
return NextResponse.json(
{message: 'Error', error: String(error)},
{status: 500}
)
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {productName, price} = body
if (!productName || price === undefined) {
return NextResponse.json(
{message: 'Missing productName or price'},
{status: 400}
)
}
const newId = randomUUID()
const newProduct = {
id: newId,
productName,
price: Number(price),
}
productMap.set(newId, newProduct)
return NextResponse.json(newProduct, {status: 201})
} catch (error) {
return NextResponse.json({message: 'Invalid JSON body'}, {status: 400})
}
}
The above code defines Route Handlers for the product list. The GET method supports pagination via a query string and simulates a delay, while POST handles creating a unique ID (UUID) and storing data in the Map.
Create the file app/api/products/[id]/route.ts:
import {productMap} from '@/app/_data/common'
import {NextResponse, type NextRequest} from 'next/server'
type Props = {
params: Promise<{id: string}>
}
export async function GET(request: NextRequest, {params}: Props) {
const {id} = await params
const product = productMap.get(id)
if (!product) {
return NextResponse.json({message: 'Product not found'}, {status: 404})
}
return NextResponse.json(product)
}
export async function PUT(request: NextRequest, {params}: Props) {
try {
const {id} = await params
const body = await request.json()
const existingProduct = productMap.get(id)
if (!existingProduct) {
return NextResponse.json({message: 'Product not found'}, {status: 404})
}
const updatedProduct = {
...existingProduct,
productName: body.productName || existingProduct.productName,
price: body.price ?? existingProduct.price,
}
productMap.set(id, updatedProduct)
return NextResponse.json({
message: 'Updated successfully',
product: updatedProduct,
})
} catch (error) {
return NextResponse.json({message: 'Invalid data'}, {status: 400})
}
}
export async function DELETE(request: NextRequest, {params}: Props) {
const {id} = await params
const isDeleted = productMap.delete(id)
if (!isDeleted) {
return NextResponse.json({message: 'Product not found'}, {status: 404})
}
return NextResponse.json({message: `Deleted product ${id} successfully`})
}
- This file manages Dynamic Routes. It allows getting details, updating, or deleting a specific product based on the ID passed from the URL.
- You can see that when using Server Actions, you can now define most of the necessary Restful API CRUD operations right within NextJS.
- This is precisely Dynamic Routes, with ID being dynamic content that can change arbitrarily.
Folder structure look like this
Create the file app/global-error.tsx; this will be the error boundary to catch errors of the entire app:
'use client'
import {Button, Result} from 'antd'
export default function GlobalError({
error,
reset,
}: {
error: Error & {digest?: string}
reset: () => void
}) {
return (
<html>
<body>
<div className="flex items-center justify-center min-h-screen bg-gray-100">
<Result
status="500"
title="Critical System Error"
subTitle="A global error occurred. Please try refreshing the application."
extra={
<Button
type="primary"
size="large"
onClick={reset}
className="bg-blue-600"
>
Try Again
</Button>
}
/>
</div>
</body>
</html>
)
}
Create the file app/layout.tsx; this is the main layout, note the {children} part is where the content of each page will be shown:
import {AntdRegistry} from '@ant-design/nextjs-registry'
import type {Metadata} from 'next'
import {Geist, Geist_Mono} from 'next/font/google'
import './globals.css'
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
})
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
})
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full font-sans">
<nav className="border-b border-gray-300 p-5">
<div className="flex gap-2">
<a href="/" className="hover:text-blue-600 transition-colors">
Home
</a>
<span className="text-gray-400">|</span>
<a
href="/products"
className="hover:text-blue-600 transition-colors"
>
Products
</a>
</div>
</nav>
<main className="p-5">
<AntdRegistry>{children}</AntdRegistry>
</main>
</body>
</html>
)
}
Create the file app/not-found.tsx, used in case of accessing a page that has not been defined:
import {HomeOutlined} from '@ant-design/icons'
import {Button, Result} from 'antd'
import Link from 'next/link'
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-[80vh] px-4">
<Result
status="404"
title={<span className="text-4xl font-bold text-gray-800">404</span>}
subTitle={
<div className="text-lg text-gray-500">
Sorry, the page you are looking for does not exist or has been
moved.
</div>
}
extra={
<Link href="/" passHref>
<Button
type="primary"
size="large"
icon={<HomeOutlined />}
className="bg-blue-600 hover:bg-blue-500 rounded-md h-auto py-2 px-6"
>
Back to Home
</Button>
</Link>
}
/>
</div>
)
}
Create the file app/products/layout.tsx; this is a global layout only used specifically when you access the /product route:
export default function ProductsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<header className="bg-[#f0f0f0] p-[10px]">
<h2 className="text-xl font-bold">Product</h2>
</header>
<div>{children}</div>
</section>
)
}
Create the file app/products/loading.tsx; the loading interface will show during the data loading process:
import {LoadingOutlined} from '@ant-design/icons'
export default function Loading() {
return (
<div className="flex items-center gap-3 p-5 text-blue-600 font-medium">
<LoadingOutlined className="text-2xl animate-spin" />
<p className="animate-pulse">Loading products 🚀</p>
</div>
)
}
Create the file app/products/ProductItem.tsx:
'use client'
import {DeleteOutlined, EyeOutlined} from '@ant-design/icons'
import {Button, message, Popconfirm} from 'antd'
import Link from 'next/link'
import {useRouter} from 'next/navigation'
import {useState} from 'react'
export default function ProductItem({
id,
productName,
}: {
id: string
productName: string
}) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const handleDelete = async () => {
setLoading(true)
try {
const res = await fetch(`/api/products/${id}`, {method: 'DELETE'})
if (!res.ok) throw new Error()
message.success('Product deleted successfully')
router.refresh()
} catch (error) {
message.error('Failed to delete product')
} finally {
setLoading(false)
}
}
return (
<li className="flex items-center justify-between p-3 bg-white border rounded-lg hover:shadow-md transition-shadow">
<div className="flex items-center gap-4">
<span className="font-medium text-gray-700">{productName}</span>
</div>
<div className="flex gap-2">
<Link href={`/products/${id}`}>
<Button icon={<EyeOutlined />} size="small">
View
</Button>
</Link>
<Popconfirm
title="Delete product"
description="Are you sure you want to delete this product?"
onConfirm={handleDelete}
okText="Yes"
cancelText="No"
okButtonProps={{danger: true, loading}}
>
<Button
danger
icon={<DeleteOutlined />}
size="small"
loading={loading}
>
Delete
</Button>
</Popconfirm>
</div>
</li>
)
}
- This component handles displaying each line of products. It uses useRouter().refresh() to request NextJS to update data from the server immediately after a successful deletion without reloading the entire page.
- Because there are operations directly on the browser, this must be a client component (use 'use client') rather than being defined as a server component.
- Note that you can use /api/products/ directly without PUBLIC_API in front because this is a client component, so the host part will default to adjusting according to the page you are using (both locally and when deploying to production).
Create the file app/products/page.tsx:
import {PlusOutlined} from '@ant-design/icons'
import {Button} from 'antd'
import Link from 'next/link'
import type {Product} from '../_type/common'
import {callApi} from '../_util/common'
import ProductItem from './ProductItem'
async function getProducts(count: number): Promise<Product[]> {
return callApi(`products?count=${count}`, {
cache: 'no-store',
})
}
export default async function ProductsPage() {
const products = await getProducts(10)
return (
<main className="p-8 max-w-2xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h2>Product List</h2>
<Link href="/products/create">
<Button type="primary" icon={<PlusOutlined />} size="large">
Create Product
</Button>
</Link>
</div>
{products.length === 0 ? (
<div className="text-center py-10 bg-gray-50 rounded-lg border-dashed border-2">
<p className="text-gray-500">
No products found. Start by creating one!
</p>
</div>
) : (
<ul className="space-y-3">
{products.map(product => (
<ProductItem
key={product.id}
id={product.id}
productName={product.productName}
/>
))}
</ul>
)}
</main>
)
}
This is a Server Component. Data is fetched directly from the server using the getProducts function. With cache: 'no-store', the website will always ensure the latest data upon each access.
Create the file app/products/create/ProductForm.tsx:
'use client'
import {ArrowLeftOutlined, BugOutlined, PlusOutlined} from '@ant-design/icons'
import {Button, Card, Form, Input, InputNumber, message} from 'antd'
import Link from 'next/link'
import {useRouter} from 'next/navigation'
import {useState} from 'react'
export default function ProductForm() {
const router = useRouter()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [shouldThrow, setShouldThrow] = useState(false)
if (shouldThrow) {
throw new Error(
'This is a simulated critical error for Global Error test! 💥'
)
}
const onFinish = async (values: any) => {
setLoading(true)
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(values),
})
if (!res.ok) throw new Error()
const newProduct = await res.json()
message.success('Product created successfully!')
router.push(`/products/${newProduct.id}`)
router.refresh()
} catch (error) {
message.error('An error occurred while creating the product.')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-xl mx-auto">
<Link
href="/products"
className="flex items-center gap-2 mb-4 text-gray-500 hover:text-blue-600 transition-colors"
>
<ArrowLeftOutlined /> Back to list
</Link>
<Card
title={<span className="text-xl font-bold">Add New Product</span>}
className="shadow-md border-none"
extra={
<Button
danger
type="text"
icon={<BugOutlined />}
onClick={() => setShouldThrow(true)}
>
Test Global Error
</Button>
}
>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
disabled={loading}
>
<Form.Item
label="Product Name"
name="productName"
rules={[{required: true, message: 'Please enter the name!'}]}
>
<Input placeholder="e.g. Product Name 1" size="large" />
</Form.Item>
<Form.Item
label="Price"
name="price"
rules={[{required: true, message: 'Please enter the price!'}]}
>
<InputNumber
className="w-full"
min={0}
size="large"
placeholder="0.00"
/>
</Form.Item>
<Form.Item className="mb-0">
<Button
type="primary"
htmlType="submit"
icon={<PlusOutlined />}
block
size="large"
loading={loading}
className="bg-blue-600"
>
Create Product
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
This Client Component manages the product creation form. It includes submission processing logic and a button to simulate a system error to test the Global Error Boundary feature.
Create the file app/products/create/page.tsx to use the ProductForm component:
import ProductForm from './ProductForm'
export default function CreateProductPage() {
return (
<main className="p-8 bg-gray-50 min-h-screen">
<ProductForm />
</main>
)
}
Create the file app/products/[id]/error.tsx:
'use client'
import {ReloadOutlined} from '@ant-design/icons'
import {Button, Result, Typography} from 'antd'
import {useRouter} from 'next/navigation'
import {startTransition, useEffect} from 'react'
const {Paragraph, Text} = Typography
interface ErrorProps {
error: Error & {digest?: string}
reset: () => void
}
export default function Error({error, reset}: ErrorProps) {
const router = useRouter()
const handleRetry = () => {
startTransition(() => {
router.refresh()
reset()
})
}
return (
<div className="flex items-center justify-center min-h-[400px] p-5">
<Result
status="error"
title="Error!"
subTitle="System encountered an unexpected error while loading data."
extra={[
<Button
type="primary"
danger
key="retry"
icon={<ReloadOutlined />}
onClick={handleRetry}
className="hover:scale-105 transition-transform"
>
Try again
</Button>,
]}
>
<div className="bg-red-50 p-4 rounded-lg border border-red-100">
<Paragraph>
<Text strong className="text-red-600">
Detail:
</Text>
</Paragraph>
<pre className="text-xs text-red-500 overflow-auto max-w-full whitespace-pre-wrap">
{error.message || 'No specific error message available.'}
</pre>
{error.digest && (
<p className="mt-2 text-[10px] text-gray-400">
Error ID: {error.digest}
</p>
)}
</div>
</Result>
</div>
)
}
This file plays the role of a separate Error Boundary for the product detail page. When an error occurs (e.g., losing database connection), NextJS will show this interface instead of crashing the entire application.
Create the file app/products/[id]/ProductDetailClient.tsx:
'use client'
import {
AlertOutlined,
CloseOutlined,
EditOutlined,
SaveOutlined,
} from '@ant-design/icons'
import {
Button,
Card,
Input,
InputNumber,
Space,
Typography,
message,
} from 'antd'
import {useRouter} from 'next/navigation'
import {useState} from 'react'
const {Title, Text} = Typography
interface Product {
id: number
productName: string
price: number
}
export default function ProductDetailClient({
initialProduct,
}: {
initialProduct: Product
}) {
const [isEditing, setIsEditing] = useState(false)
const [formData, setFormData] = useState(initialProduct)
const [loading, setLoading] = useState(false)
const [triggerError, setTriggerError] = useState(false)
const router = useRouter()
if (triggerError) {
throw new Error('This is a simulated error to test Error Boundary! 💥')
}
const handleSave = async () => {
setLoading(true)
try {
const res = await fetch(`/api/products/${initialProduct.id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formData),
})
if (!res.ok) throw new Error('Update failed')
message.success('Product updated successfully!')
setIsEditing(false)
router.refresh()
} catch (err) {
message.error('Failed to update product.')
} finally {
setLoading(false)
}
}
return (
<Card className="max-w-2xl mx-auto mt-10 shadow-lg border-t-4 border-blue-500">
<div className="flex justify-between items-start mb-6">
<div>
<Title level={3} className="!mb-0">
Product Details
</Title>
<Text type="secondary">ID: {initialProduct.id}</Text>
</div>
<Button
danger
icon={<AlertOutlined />}
onClick={() => setTriggerError(true)}
>
Test Error
</Button>
</div>
<div className="space-y-6">
{isEditing ? (
<div className="bg-gray-50 p-4 rounded-lg space-y-4">
<div>
<Text strong>Product Name:</Text>
<Input
value={formData.productName}
onChange={e =>
setFormData({...formData, productName: e.target.value})
}
className="mt-1"
/>
</div>
<div>
<Text strong>Price:</Text>
<InputNumber
className="w-full mt-1"
value={formData.price}
onChange={val => setFormData({...formData, price: val || 0})}
/>
</div>
<Space className="mt-4">
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={loading}
>
Save
</Button>
<Button
icon={<CloseOutlined />}
onClick={() => {
setIsEditing(false)
setFormData(initialProduct)
}}
>
Cancel
</Button>
</Space>
</div>
) : (
<div className="p-4 border border-dashed border-gray-200 rounded-lg group relative">
<div className="space-y-2">
<Title level={4} className="text-blue-600">
{initialProduct.productName}
</Title>
<Text className="text-2xl font-semibold text-red-500">
Price: {initialProduct.price?.toLocaleString()}
</Text>
<p className="text-xs text-gray-400 italic">
Updated at: {new Date().toLocaleTimeString()}
</p>
</div>
<Button
type="primary"
ghost
icon={<EditOutlined />}
className="mt-4"
onClick={() => setIsEditing(true)}
>
Edit
</Button>
</div>
)}
</div>
</Card>
)
}
This component allows users to view and edit product information in place. When save is clicked, it calls the API to update and refresh to show the latest data.
Create the file app/products/[id]/page.tsx:
import {callApi} from '@/app/_util/common'
import ProductDetailClient from './ProductDetailClient'
async function getProduct(id: string) {
return callApi(`products/${id}`, {
cache: 'no-store',
})
}
export default async function ProductDetailPage({params}: Props) {
const {id} = await params
const product = await getProduct(id)
return (
<main className="p-8 bg-gray-100 min-h-screen">
<ProductDetailClient initialProduct={product} />
</main>
)
}
type Props = {
params: Promise<{id: string}>
}
The product detail page combines the power of Server Component (for fast data fetching and good SEO) with Client Component (for user interaction and editing).
Folder structure look like this
The results when using the pages will be as follows:
Checking the not found page:
Checking Error Boundary operational for global and local product:
Happy coding!
See more articles here.
Comments
Post a Comment