Post

Next.js App Router - Hướng dẫn cho Người mới bắt đầu

Next.js App Router - Hướng dẫn cho Người mới bắt đầu

Next.js App Router - Hướng dẫn cho Người mới bắt đầu

1. Khởi tạo Project

Cài đặt Next.js với App Router

1
npx create-next-app@latest my-app

Trong quá trình cài đặt, chọn:

1
2
3
4
5
6
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? … Yes ← QUAN TRỌNG!
✔ Would you like to customize the default import alias? … No

Chạy project:

1
2
cd my-app
npm run dev

Truy cập: http://localhost:3000

Cấu trúc thư mục ban đầu:

1
2
3
4
5
6
7
8
9
10
my-app/
├── app/                    ← Thư mục chính cho App Router
│   ├── layout.tsx         ← Root layout
│   ├── page.tsx           ← Trang chủ (/)
│   └── globals.css        ← CSS toàn cục
├── public/                ← Static files (images, fonts...)
├── node_modules/
├── package.json
├── tsconfig.json
└── next.config.js

2. Quy tắc Chia Folder và Define Routes

2.1. Nguyên tắc cơ bản

📁 Mỗi folder = 1 route segment

1
2
3
4
5
6
7
8
app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── blog/
│   └── page.tsx          → /blog
└── contact/
    └── page.tsx          → /contact

2.2. Các file đặc biệt trong App Router

FileMục đíchBắt buộc?
layout.tsxLayout bao bọc các page con✅ (root)
page.tsxNội dung trang, tạo route
loading.tsxUI loading khi page đang load
error.tsxUI hiển thị khi có lỗi
not-found.tsxUI cho trang 404
route.tsAPI endpoint (backend)

2.3. Dynamic Routes (Route động)

Tạo route với tham số:

1
2
3
4
5
app/
└── blog/
    ├── page.tsx                    → /blog
    └── [slug]/
        └── page.tsx                → /blog/bai-viet-1

File: app/blog/[slug]/page.tsx

1
2
3
4
5
6
7
export default function BlogPost({ 
  params 
}: { 
  params: { slug: string } 
}) {
  return <h1>Bài viết: {params.slug}</h1>
}

Catch-all routes:

1
2
3
4
app/
└── docs/
    └── [...slug]/
        └── page.tsx                → /docs/a/b/c
1
2
3
4
5
6
7
export default function DocsPage({ 
  params 
}: { 
  params: { slug: string[] } 
}) {
  return <h1>Docs: {params.slug.join('/')}</h1>
}

2.4. Route Groups (Nhóm routes không ảnh hưởng URL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app/
├── (marketing)/              ← Không xuất hiện trong URL
│   ├── layout.tsx           ← Layout riêng cho marketing
│   ├── about/
│   │   └── page.tsx         → /about
│   └── contact/
│       └── page.tsx         → /contact
├── (shop)/
│   ├── layout.tsx           ← Layout riêng cho shop
│   ├── products/
│   │   └── page.tsx         → /products
│   └── cart/
│       └── page.tsx         → /cart
└── page.tsx                 → /

Lợi ích: Tổ chức code tốt hơn, mỗi nhóm có layout riêng mà không làm URL dài dòng.

2.5. Parallel Routes & Intercepting Routes (Nâng cao)

Parallel Routes:

1
2
3
4
5
6
app/
├── @team/
│   └── page.tsx
├── @analytics/
│   └── page.tsx
└── layout.tsx

Intercepting Routes (Modal):

1
2
3
4
5
6
7
8
9
app/
├── photos/
│   ├── page.tsx             → /photos
│   └── [id]/
│       └── page.tsx         → /photos/123
└── @modal/
    └── (..)photos/
        └── [id]/
            └── page.tsx     → Hiển thị modal khi click

3. Viết API Routes và Thao tác với Frontend

3.1. Tạo API Endpoint đơn giản

File: app/api/hello/route.ts

1
2
3
4
5
6
import { NextResponse } from 'next/server'

// GET /api/hello
export async function GET() {
  return NextResponse.json({ message: 'Hello World!' })
}

3.2. CRUD API hoàn chỉnh

Structure:

1
2
3
4
5
6
app/
└── api/
    └── todos/
        ├── route.ts              → GET, POST /api/todos
        └── [id]/
            └── route.ts          → GET, PATCH, DELETE /api/todos/:id

File: app/api/todos/route.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { NextRequest, NextResponse } from 'next/server'

// Giả lập database (trong thực tế dùng Prisma, MongoDB...)
let todos = [
  { id: 1, title: 'Học Next.js', completed: false },
  { id: 2, title: 'Build API', completed: true }
]

// GET /api/todos - Lấy tất cả todos
export async function GET() {
  return NextResponse.json(todos)
}

// POST /api/todos - Tạo todo mới
export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    
    // Validation
    if (!body.title || body.title.trim() === '') {
      return NextResponse.json(
        { error: 'Title is required' },
        { status: 400 }
      )
    }
    
    const newTodo = {
      id: Date.now(),
      title: body.title,
      completed: false
    }
    
    todos.push(newTodo)
    
    return NextResponse.json(newTodo, { status: 201 })
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid request' },
      { status: 400 }
    )
  }
}

File: app/api/todos/[id]/route.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { NextRequest, NextResponse } from 'next/server'

// Giả lập database
let todos = [
  { id: 1, title: 'Học Next.js', completed: false }
]

// GET /api/todos/:id - Lấy 1 todo
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const id = parseInt(params.id)
  const todo = todos.find(t => t.id === id)
  
  if (!todo) {
    return NextResponse.json(
      { error: 'Todo not found' },
      { status: 404 }
    )
  }
  
  return NextResponse.json(todo)
}

// PATCH /api/todos/:id - Cập nhật todo
export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const id = parseInt(params.id)
  const body = await request.json()
  
  const todoIndex = todos.findIndex(t => t.id === id)
  
  if (todoIndex === -1) {
    return NextResponse.json(
      { error: 'Todo not found' },
      { status: 404 }
    )
  }
  
  // Update fields
  todos[todoIndex] = {
    ...todos[todoIndex],
    ...body
  }
  
  return NextResponse.json(todos[todoIndex])
}

// DELETE /api/todos/:id - Xóa todo
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const id = parseInt(params.id)
  const initialLength = todos.length
  
  todos = todos.filter(t => t.id !== id)
  
  if (todos.length === initialLength) {
    return NextResponse.json(
      { error: 'Todo not found' },
      { status: 404 }
    )
  }
  
  return NextResponse.json({ message: 'Deleted successfully' })
}

3.3. Gọi API từ Frontend

Client Component (với useState, useEffect)

File: app/todos/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
'use client' // Bắt buộc khi dùng hooks

import { useState, useEffect } from 'react'

interface Todo {
  id: number
  title: string
  completed: boolean
}

export default function TodosPage() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [newTodo, setNewTodo] = useState('')
  const [loading, setLoading] = useState(true)

  // Fetch todos khi component mount
  useEffect(() => {
    fetchTodos()
  }, [])

  const fetchTodos = async () => {
    try {
      const res = await fetch('/api/todos')
      const data = await res.json()
      setTodos(data)
    } catch (error) {
      console.error('Error fetching todos:', error)
    } finally {
      setLoading(false)
    }
  }

  const addTodo = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!newTodo.trim()) return

    try {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: newTodo })
      })
      
      const data = await res.json()
      setTodos([...todos, data])
      setNewTodo('')
    } catch (error) {
      console.error('Error adding todo:', error)
    }
  }

  const toggleTodo = async (id: number) => {
    const todo = todos.find(t => t.id === id)
    if (!todo) return

    try {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: !todo.completed })
      })
      
      const data = await res.json()
      setTodos(todos.map(t => t.id === id ? data : t))
    } catch (error) {
      console.error('Error updating todo:', error)
    }
  }

  const deleteTodo = async (id: number) => {
    try {
      await fetch(`/api/todos/${id}`, {
        method: 'DELETE'
      })
      
      setTodos(todos.filter(t => t.id !== id))
    } catch (error) {
      console.error('Error deleting todo:', error)
    }
  }

  if (loading) return <div>Loading...</div>

  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Todo List</h1>
      
      <form onSubmit={addTodo} className="mb-8">
        <input
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="Add new todo..."
          className="border p-2 mr-2"
        />
        <button 
          type="submit"
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          Add
        </button>
      </form>

      <ul className="space-y-2">
        {todos.map(todo => (
          <li key={todo.id} className="flex items-center gap-2 p-4 border">
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span className={todo.completed ? 'line-through' : ''}>
              {todo.title}
            </span>
            <button
              onClick={() => deleteTodo(todo.id)}
              className="ml-auto text-red-500"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Server Component (fetch trực tiếp, không cần useState)

File: app/posts/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Không cần 'use client' - đây là Server Component

interface Post {
  id: number
  title: string
  body: string
}

async function getPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    cache: 'no-store' // Hoặc: next: { revalidate: 60 }
  })
  
  if (!res.ok) throw new Error('Failed to fetch posts')
  
  return res.json()
}

export default async function PostsPage() {
  const posts: Post[] = await getPosts()

  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Posts</h1>
      <div className="space-y-4">
        {posts.map(post => (
          <div key={post.id} className="border p-4">
            <h2 className="font-bold">{post.title}</h2>
            <p>{post.body}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

3.4. Request với Headers, Cookies, Search Params

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { NextRequest, NextResponse } from 'next/server'
import { cookies, headers } from 'next/headers'

export async function GET(request: NextRequest) {
  // 1. Lấy Search Params
  const searchParams = request.nextUrl.searchParams
  const query = searchParams.get('q')
  
  // 2. Lấy Headers
  const authorization = request.headers.get('authorization')
  
  // 3. Lấy Cookies
  const cookieStore = cookies()
  const token = cookieStore.get('token')
  
  // 4. Set Cookies trong response
  const response = NextResponse.json({ data: 'success' })
  response.cookies.set('session', 'abc123', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 // 1 day
  })
  
  return response
}

4. Lưu ý và Best Practices

4.1. Server Components vs Client Components

📊 So sánh:

Tiêu chíServer ComponentClient Component
Khai báoMặc định'use client' ở đầu file
Chạy ởServerBrowser
Hooks❌ Không dùng được✅ useState, useEffect…
Event handlers❌ onClick, onChange…✅ Tất cả events
Browser APIs❌ window, localStorage…✅ Tất cả APIs
Fetch data✅ Trực tiếp async/await⚠️ Dùng useEffect
Bundle size✅ Không gửi JS về client❌ Tăng bundle size

✅ Khi nào dùng Server Component:

  • Fetch data từ database/API
  • Truy cập backend resources
  • Giữ sensitive info ở server (API keys, tokens)
  • Giảm JavaScript gửi về client

✅ Khi nào dùng Client Component:

  • Cần interactivity (onClick, onChange…)
  • Dùng React hooks (useState, useEffect…)
  • Dùng browser APIs (localStorage, geolocation…)
  • Dùng các thư viện chỉ chạy trên client

4.2. Data Fetching Strategies

Caching và Revalidation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. Force cache (mặc định)
fetch('https://api.example.com/data')

// 2. No cache - luôn fresh
fetch('https://api.example.com/data', { cache: 'no-store' })

// 3. Revalidate sau mỗi 60 giây
fetch('https://api.example.com/data', { 
  next: { revalidate: 60 } 
})

// 4. Revalidate theo tag
fetch('https://api.example.com/data', { 
  next: { tags: ['posts'] } 
})
// Sau đó revalidate bằng: revalidateTag('posts')

4.3. Error Handling

File: app/error.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
      <p className="mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="bg-blue-500 text-white px-4 py-2 rounded"
      >
        Try again
      </button>
    </div>
  )
}

File: app/not-found.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h2 className="text-4xl font-bold mb-4">404 - Not Found</h2>
      <p className="mb-4">Could not find requested resource</p>
      <Link 
        href="/"
        className="text-blue-500 underline"
      >
        Return Home
      </Link>
    </div>
  )
}

4.4. Loading States

File: app/dashboard/loading.tsx

1
2
3
4
5
6
7
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
    </div>
  )
}

4.5. Metadata và SEO

Static Metadata:

1
2
3
4
5
6
7
8
9
10
11
// app/about/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn more about our company',
}

export default function AboutPage() {
  return <h1>About Us</h1>
}

Dynamic Metadata:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ 
  params 
}: { 
  params: { slug: string } 
}): Promise<Metadata> {
  const post = await getPost(params.slug)
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.image],
    },
  }
}

4.6. Environment Variables

File: .env.local

# Public (accessible từ browser)
NEXT_PUBLIC_API_URL=https://api.example.com

# Private (chỉ trên server)
DATABASE_URL=postgresql://...
API_SECRET_KEY=abc123xyz

Sử dụng:

1
2
3
4
5
// Client Component
const apiUrl = process.env.NEXT_PUBLIC_API_URL

// Server Component hoặc API Route
const dbUrl = process.env.DATABASE_URL

4.7. Middleware

File: middleware.ts (root level)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Check authentication
  const token = request.cookies.get('token')
  
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  
  return NextResponse.next()
}

// Config: chỉ chạy middleware cho các route này
export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*']
}

4.8. TypeScript Tips

Định nghĩa types chung:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// types/index.ts
export interface User {
  id: number
  name: string
  email: string
}

export interface Todo {
  id: number
  title: string
  completed: boolean
  userId: number
}

export interface ApiResponse<T> {
  data: T
  message?: string
  error?: string
}

4.9. Folder Structure Tốt nhất

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
my-app/
├── app/
│   ├── (auth)/                    # Route group cho authentication
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/               # Route group cho dashboard
│   │   ├── layout.tsx
│   │   ├── analytics/
│   │   │   └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   ├── auth/
│   │   │   └── [...nextauth]/
│   │   │       └── route.ts
│   │   ├── todos/
│   │   │   ├── route.ts
│   │   │   └── [id]/
│   │   │       └── route.ts
│   │   └── users/
│   │       └── route.ts
│   ├── layout.tsx                 # Root layout
│   ├── page.tsx                   # Home page
│   ├── error.tsx                  # Global error
│   ├── loading.tsx                # Global loading
│   └── not-found.tsx              # 404 page
├── components/                    # Reusable components
│   ├── ui/                       # UI components
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Card.tsx
│   └── features/                 # Feature-specific components
│       ├── TodoList.tsx
│       └── UserProfile.tsx
├── lib/                          # Utility functions
│   ├── db.ts                     # Database connection
│   ├── auth.ts                   # Auth helpers
│   └── utils.ts                  # Helper functions
├── types/                        # TypeScript types
│   └── index.ts
├── public/                       # Static assets
│   ├── images/
│   └── fonts/
├── .env.local                    # Environment variables
└── package.json

4.10. Performance Tips

1. Sử dụng Image Component:

1
2
3
4
5
6
7
8
9
import Image from 'next/image'

<Image
  src="/hero.jpg"
  alt="Hero"
  width={800}
  height={600}
  priority // Load ngay cho above-the-fold images
/>

2. Dynamic Import (Lazy Loading):

1
2
3
4
5
6
import dynamic from 'next/dynamic'

const HeavyComponent = dynamic(() => import('@/components/HeavyComponent'), {
  loading: () => <p>Loading...</p>,
  ssr: false // Không render trên server
})

3. Suspense Boundaries:

1
2
3
4
5
6
7
8
9
10
11
12
import { Suspense } from 'react'

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<LoadingSkeleton />}>
        <DataComponent />
      </Suspense>
    </div>
  )
}

📚 Tài nguyên học thêm


This post is licensed under CC BY 4.0 by the author.