0

Next.js từ Cơ bản đến Nâng cao: Tổng hợp kiến thức cần biết

Next.js từ Cơ bản đến Nâng cao: Tổng hợp kiến thức cần biết

Bài viết tổng hợp kiến thức về Next.js — framework React phổ biến nhất cho việc xây dựng ứng dụng web full-stack — theo lộ trình từ cơ bản đến nâng cao, kèm ví dụ code minh họa (tập trung vào App Router, chuẩn hiện đại từ Next.js 13+).

Mục lục

  1. Giới thiệu chung về Next.js
  2. Cài đặt và cấu trúc project
  3. App Router và File-based Routing
  4. Dynamic Routes
  5. Layout lồng nhau (Nested Layouts)
  6. Server Component và Client Component
  7. Data Fetching
  8. Các chiến lược Render: SSR, SSG, ISR
  9. Route Handlers (API Routes)
  10. Server Actions
  11. Loading UI và Streaming
  12. Error Handling
  13. Middleware
  14. Tối ưu hình ảnh và Font
  15. Metadata và SEO
  16. Environment Variables
  17. Xác thực (Authentication)
  18. Kết nối Database
  19. Caching trong Next.js
  20. Testing trong Next.js
  21. Triển khai (Deployment)
  22. So sánh Pages Router và App Router
  23. Best Practice
  24. Tài liệu tham khảo

1. Giới thiệu chung về Next.js

Next.js là framework React được phát triển bởi Vercel, ra mắt năm 2016, giúp xây dựng ứng dụng web full-stack (cả frontend lẫn backend) trên nền React. Các điểm nổi bật:

  • Nhiều chiến lược render: SSR (Server-side Rendering), SSG (Static Site Generation), ISR (Incremental Static Regeneration), và CSR (Client-side Rendering) — có thể kết hợp linh hoạt trong cùng một app.
  • File-based Routing: cấu trúc thư mục quyết định route, không cần cấu hình router thủ công như react-router-dom.
  • React Server Components (RSC): component mặc định chạy ở server, giảm lượng JavaScript gửi về client.
  • Tối ưu sẵn: tự động tối ưu hình ảnh, font, code splitting, prefetching.
  • Full-stack: có thể viết API ngay trong project (Route Handlers) mà không cần backend riêng cho các tác vụ đơn giản.

So với React thuần (CSR), Next.js giải quyết tốt vấn đề SEO và tốc độ tải trang đầu (First Contentful Paint) nhờ render HTML sẵn ở server.

2. Cài đặt và cấu trúc project

npx create-next-app@latest my-app
cd my-app
npm run dev

Cấu trúc project cơ bản (App Router):

my-app/
├── app/
│   ├── layout.tsx      # Layout gốc, bắt buộc phải có
│   ├── page.tsx        # Trang chủ ("/")
│   ├── globals.css
│   └── gia-su/
│       └── page.tsx    # Route "/gia-su"
├── public/              # Static assets
├── components/
├── lib/
├── next.config.js
└── package.json

3. App Router và File-based Routing

Trong App Router, mỗi thư mục trong app/ tương ứng với một segment của URL, và file page.tsx bên trong quyết định nội dung route đó.

// app/page.tsx -> route "/"
export default function TrangChu() {
  return <h1>Trang chủ</h1>;
}

// app/gia-su/page.tsx -> route "/gia-su"
export default function TrangGiaSu() {
  return <h1>Danh sách gia sư</h1>;
}

// app/gia-su/dang-ky/page.tsx -> route "/gia-su/dang-ky"
export default function DangKyGiaSu() {
  return <h1>Đăng ký làm gia sư</h1>;
}

Các file có ý nghĩa đặc biệt trong mỗi thư mục route:

File Vai trò
page.tsx Nội dung UI của route, bắt buộc để route hiển thị công khai
layout.tsx Giao diện bao ngoài, dùng chung cho route con
loading.tsx UI hiển thị khi đang tải (Suspense tự động)
error.tsx UI hiển thị khi có lỗi runtime
not-found.tsx UI hiển thị khi gọi notFound() hoặc route không tồn tại
route.ts Định nghĩa API endpoint (Route Handler)

4. Dynamic Routes

// app/gia-su/[id]/page.tsx -> route "/gia-su/123"
export default function ChiTietGiaSu({ params }: { params: { id: string } }) {
  return <p>Chi tiết gia sư ID: {params.id}</p>;
}

Catch-all Routes

// app/blog/[...slug]/page.tsx -> khớp "/blog/a", "/blog/a/b", "/blog/a/b/c"...
export default function Blog({ params }: { params: { slug: string[] } }) {
  return <p>Đường dẫn: {params.slug.join("/")}</p>;
}

generateStaticParams (dùng cho SSG với dynamic route)

export async function generateStaticParams() {
  const danhSach = await layDanhSachGiaSu();
  return danhSach.map((gs) => ({ id: gs.id.toString() }));
}

5. Layout lồng nhau (Nested Layouts)

// app/layout.tsx (Root Layout - bắt buộc)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="vi">
      <body>
        <header>Header chung toàn site</header>
        {children}
        <footer>Footer chung toàn site</footer>
      </body>
    </html>
  );
}

// app/gia-su/layout.tsx (Layout riêng cho nhóm route /gia-su/*)
export default function GiaSuLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="gia-su-container">
      <aside>Sidebar bộ lọc gia sư</aside>
      <main>{children}</main>
    </div>
  );
}

Layout không bị re-render khi chuyển route giữa các trang con cùng layout, giúp giữ nguyên state (ví dụ: vị trí scroll, trạng thái sidebar) và tăng hiệu năng chuyển trang.

Route Groups (nhóm route không ảnh hưởng URL)

app/
├── (marketing)/
│   ├── layout.tsx      # Layout riêng cho trang giới thiệu
│   └── gioi-thieu/page.tsx
└── (app)/
    ├── layout.tsx      # Layout riêng cho khu vực ứng dụng chính
    └── dashboard/page.tsx

Thư mục đặt trong dấu ngoặc đơn (ten) không xuất hiện trong URL, chỉ dùng để tổ chức code và áp dụng layout khác nhau cho từng nhóm.

6. Server Component và Client Component

Trong App Router, mọi component mặc định là Server Component — chạy hoàn toàn ở server, không gửi JavaScript của nó về client.

// Server Component (mặc định, không cần khai báo gì thêm)
async function DanhSachGiaSu() {
  const danhSach = await fetch("https://api.example.com/gia-su").then((r) => r.json());
  return <ul>{danhSach.map((gs: any) => <li key={gs.id}>{gs.ten}</li>)}</ul>;
}

Dùng "use client" khi cần: useState, useEffect, sự kiện (onClick...), hoặc các API chỉ có ở trình duyệt (window, localStorage).

"use client";

import { useState } from "react";

export default function BoDemSo() {
  const [soDem, setSoDem] = useState(0);
  return <button onClick={() => setSoDem(soDem + 1)}>{soDem}</button>;
}
Server Component Client Component
Chạy ở đâu Server Trình duyệt (sau khi hydrate)
Dùng Hook (useState, useEffect) Không
Truy cập trực tiếp DB, file system Không
Gửi JS về client Không
Tương tác người dùng (onClick...) Không

Lưu ý: nên đẩy "use client" xuống component nhỏ nhất cần tương tác, giữ phần lớn cây component ở dạng Server Component để tối ưu lượng JS gửi về client.

7. Data Fetching

Fetch trực tiếp trong Server Component (cách khuyến nghị)

async function TrangGiaSu() {
  const res = await fetch("https://api.example.com/gia-su", {
    cache: "force-cache", // mặc định, cache vĩnh viễn cho đến khi revalidate
  });
  const danhSach = await res.json();

  return <ul>{danhSach.map((gs: any) => <li key={gs.id}>{gs.ten}</li>)}</ul>;
}

Fetch song song để tránh waterfall

async function TrangChiTiet({ params }: { params: { id: string } }) {
  const [giaSu, danhGia] = await Promise.all([
    layGiaSu(params.id),
    layDanhGia(params.id),
  ]);

  return (
    <div>
      <ThongTinGiaSu data={giaSu} />
      <DanhSachDanhGia data={danhGia} />
    </div>
  );
}

Fetch dữ liệu trong Client Component (khi cần tương tác realtime)

Dùng thư viện như TanStack Query hoặc SWR (do chính đội Vercel phát triển) thay vì useEffect + fetch thủ công.

"use client";
import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((res) => res.json());

function DanhSachGiaSuRealtime() {
  const { data, isLoading } = useSWR("/api/gia-su", fetcher, { refreshInterval: 5000 });
  if (isLoading) return <p>Đang tải...</p>;
  return <ul>{data.map((gs: any) => <li key={gs.id}>{gs.ten}</li>)}</ul>;
}

8. Các chiến lược Render: SSR, SSG, ISR

Chiến lược Khi nào HTML được tạo Phù hợp cho
SSG (Static Generation) Lúc build Trang ít thay đổi: giới thiệu, blog, tài liệu
SSR (Server-side Rendering) Mỗi request Dữ liệu thay đổi liên tục, cần luôn mới nhất
ISR (Incremental Static Regen.) Lúc build + tự tái tạo theo chu kỳ Cân bằng giữa tốc độ tĩnh và dữ liệu cập nhật
CSR (Client-side Rendering) Trên trình duyệt Nội dung cá nhân hóa cao, tương tác nhiều
// SSG mặc định (không cấu hình gì thêm, fetch cache mặc định "force-cache")

// SSR - luôn lấy dữ liệu mới mỗi request
async function Trang() {
  const data = await fetch("https://api.example.com/data", { cache: "no-store" });
  ...
}

// ISR - tái tạo lại trang sau mỗi 60 giây
async function Trang() {
  const data = await fetch("https://api.example.com/data", {
    next: { revalidate: 60 },
  });
  ...
}

9. Route Handlers (API Routes)

// app/api/gia-su/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const danhSach = await layDanhSachGiaSu();
  return NextResponse.json(danhSach);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const giaSuMoi = await taoGiaSu(body);
  return NextResponse.json(giaSuMoi, { status: 201 });
}
// app/api/gia-su/[id]/route.ts
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
  const giaSu = await timGiaSuTheoId(params.id);
  if (!giaSu) {
    return NextResponse.json({ error: "Không tìm thấy" }, { status: 404 });
  }
  return NextResponse.json(giaSu);
}

Route Handler phù hợp khi cần expose API cho bên thứ ba (mobile app, webhook) — còn nếu chỉ dùng nội bộ trong chính app Next.js, Server Actions (mục 10) thường là lựa chọn gọn hơn.

10. Server Actions

Server Actions cho phép gọi hàm chạy ở server trực tiếp từ Client Component (thường dùng cho form submit), không cần tự viết API endpoint riêng.

// app/gia-su/actions.ts
"use server";

export async function taoGiaSu(formData: FormData) {
  const ten = formData.get("ten") as string;
  const moTa = formData.get("moTa") as string;

  await db.giaSu.create({ data: { ten, moTa } });
  revalidatePath("/gia-su"); // làm mới cache của trang danh sách
}
// app/gia-su/FormTaoGiaSu.tsx
import { taoGiaSu } from "./actions";

export default function FormTaoGiaSu() {
  return (
    <form action={taoGiaSu}>
      <input name="ten" placeholder="Tên gia sư" />
      <textarea name="moTa" placeholder="Mô tả" />
      <button type="submit">Tạo</button>
    </form>
  );
}

Dùng Server Action với useTransition (loading state phía client)

"use client";
import { useTransition } from "react";
import { taoGiaSu } from "./actions";

export default function FormTaoGiaSu() {
  const [dangXuLy, startTransition] = useTransition();

  return (
    <form action={(fd) => startTransition(() => taoGiaSu(fd))}>
      <input name="ten" />
      <button disabled={dangXuLy}>{dangXuLy ? "Đang xử lý..." : "Tạo"}</button>
    </form>
  );
}

11. Loading UI và Streaming

// app/gia-su/loading.tsx
export default function Loading() {
  return <p>Đang tải danh sách gia sư...</p>;
}

Next.js tự động bọc page.tsx bằng <Suspense> với fallback là nội dung loading.tsx, cho phép streaming: phần HTML tĩnh (layout, header) hiển thị ngay, phần dữ liệu chậm hơn sẽ "đổ" vào sau khi sẵn sàng.

import { Suspense } from "react";

export default function TrangChiTiet() {
  return (
    <div>
      <ThongTinCoBan />
      <Suspense fallback={<p>Đang tải đánh giá...</p>}>
        <DanhSachDanhGia />
      </Suspense>
    </div>
  );
}

12. Error Handling

// app/gia-su/error.tsx
"use client"; // error.tsx bắt buộc phải là Client Component

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>Đã có lỗi xảy ra: {error.message}</p>
      <button onClick={() => reset()}>Thử lại</button>
    </div>
  );
}
// app/gia-su/[id]/page.tsx
import { notFound } from "next/navigation";

export default async function ChiTietGiaSu({ params }: { params: { id: string } }) {
  const giaSu = await timGiaSuTheoId(params.id);
  if (!giaSu) notFound(); // render app/gia-su/[id]/not-found.tsx hoặc not-found.tsx gần nhất
  return <p>{giaSu.ten}</p>;
}

13. Middleware

Middleware chạy trước khi request đến route, phù hợp cho xác thực, redirect, ghi log, A/B testing.

// middleware.ts (đặt ở gốc project, ngang hàng với app/)
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("token");

  if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/dang-nhap", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

14. Tối ưu hình ảnh và Font

import Image from "next/image";
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export default function TheGiaSu() {
  return (
    <div className={inter.className}>
      <Image
        src="/gia-su-avatar.jpg"
        alt="Ảnh đại diện gia sư"
        width={200}
        height={200}
        priority // ưu tiên tải sớm cho ảnh above-the-fold
      />
    </div>
  );
}

next/image tự động: lazy load ảnh ngoài viewport, tạo nhiều kích thước ảnh phù hợp thiết bị (responsive), chuyển sang định dạng hiện đại (WebP/AVIF), và tránh layout shift nhờ khai báo width/height bắt buộc.

15. Metadata và SEO

// app/gia-su/[id]/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const giaSu = await timGiaSuTheoId(params.id);
  return {
    title: `${giaSu.ten} — Nền tảng gia sư Đà Nẵng`,
    description: giaSu.moTaNgan,
    openGraph: {
      images: [giaSu.anhDaiDien],
    },
  };
}
// app/layout.tsx - metadata mặc định toàn site
export const metadata = {
  title: {
    default: "Nền tảng gia sư Đà Nẵng",
    template: "%s | Nền tảng gia sư Đà Nẵng",
  },
  description: "Kết nối gia sư và học viên tại Đà Nẵng",
};

16. Environment Variables

# .env.local
DATABASE_URL=postgresql://user:password@localhost/mydb
NEXT_PUBLIC_API_URL=https://api.example.com
// Chỉ dùng được ở server (Server Component, Route Handler, Server Action)
const dbUrl = process.env.DATABASE_URL;

// Dùng được cả ở client - PHẢI có tiền tố NEXT_PUBLIC_
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Lưu ý bảo mật: chỉ biến có tiền tố NEXT_PUBLIC_ mới được bundle vào JavaScript phía client — tuyệt đối không đặt secret key, mật khẩu DB dưới tiền tố này.

17. Xác thực (Authentication)

Thư viện phổ biến nhất hiện nay là Auth.js (trước đây là NextAuth.js) và Clerk.

// auth.ts (Auth.js v5)
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      async authorize(credentials) {
        const user = await xacThucNguoiDung(credentials);
        return user ?? null;
      },
    }),
  ],
});
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";
// Sử dụng trong Server Component
import { auth } from "@/auth";

export default async function TrangCaNhan() {
  const session = await auth();
  if (!session) return <p>Vui lòng đăng nhập</p>;
  return <p>Xin chào, {session.user.name}</p>;
}

18. Kết nối Database

Với Prisma ORM (phổ biến nhất trong hệ sinh thái Next.js)

// schema.prisma
model GiaSu {
  id        Int      @id @default(autoincrement())
  ten       String
  email     String   @unique
  createdAt DateTime @default(now())
}
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
// Sử dụng trong Server Component
import { prisma } from "@/lib/prisma";

export default async function DanhSachGiaSu() {
  const danhSach = await prisma.giaSu.findMany();
  return <ul>{danhSach.map((gs) => <li key={gs.id}>{gs.ten}</li>)}</ul>;
}

Việc khởi tạo PrismaClient như một singleton toàn cục (đoạn code globalForPrisma) giúp tránh mở quá nhiều connection tới database trong môi trường development, do Next.js hot-reload code liên tục.

19. Caching trong Next.js

Next.js có 4 lớp cache chính, cần hiểu rõ để tránh dữ liệu bị "cũ" không mong muốn:

Lớp cache Mục đích
Request Memoization Tránh gọi trùng cùng một fetch request trong 1 lần render
Data Cache Cache kết quả fetch() giữa các request/deployment (điều khiển bằng cache, next.revalidate)
Full Route Cache Cache HTML + RSC payload đã render tại build time
Router Cache (client) Cache trên trình duyệt khi điều hướng giữa các route đã ghé thăm
// Chủ động xóa cache khi dữ liệu thay đổi
import { revalidatePath, revalidateTag } from "next/cache";

revalidatePath("/gia-su");        // làm mới cache của 1 route cụ thể
revalidateTag("danh-sach-gia-su"); // làm mới mọi fetch được gắn cùng tag
fetch("https://api.example.com/gia-su", {
  next: { tags: ["danh-sach-gia-su"] },
});

20. Testing trong Next.js

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import TheGiaSu from "@/components/TheGiaSu";

describe("TheGiaSu", () => {
  it("hiển thị đúng tên gia sư", () => {
    render(<TheGiaSu ten="Nguyễn Văn An" />);
    expect(screen.getByText("Nguyễn Văn An")).toBeInTheDocument();
  });
});

Với E2E test (kiểm thử toàn luồng người dùng thực tế), nên dùng Playwright — được chính đội Next.js khuyến nghị chính thức trong tài liệu.

21. Triển khai (Deployment)

Vercel (đơn giản nhất, cùng đội phát triển Next.js)

npm install -g vercel
vercel

Chỉ cần kết nối Git repository, Vercel tự động build và deploy mỗi khi push code, tự cấu hình CDN, Serverless Functions cho Route Handlers.

Self-hosted với Docker

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]

Nên bật output: "standalone" trong next.config.js để giảm đáng kể kích thước image Docker (chỉ copy đúng phần cần thiết thay vì toàn bộ node_modules).

22. So sánh Pages Router và App Router

Tiêu chí Pages Router (cũ, pages/) App Router (mới, app/, từ v13+)
Data fetching getServerSideProps, getStaticProps fetch() trực tiếp trong Server Component
Layout _app.tsx, _document.tsx toàn cục Layout lồng nhau theo từng thư mục
Server Component Không hỗ trợ Mặc định
Loading/Error UI Tự viết thủ công loading.tsx, error.tsx tự động
Trạng thái hiện tại Vẫn được hỗ trợ, dùng cho project cũ Khuyến nghị cho project mới

Với project mới, nên bắt đầu bằng App Router vì đây là hướng phát triển chính thức của Next.js hiện nay.

23. Best Practice

  • Mặc định giữ component ở dạng Server Component, chỉ thêm "use client" khi thực sự cần tương tác hoặc Hook.
  • Fetch dữ liệu song song (Promise.all) thay vì tuần tự để tránh request waterfall làm chậm trang.
  • Đặt "use client"component lá (component nhỏ nhất cần state/sự kiện), không đặt ở component cha bao trùm nhiều phần tĩnh.
  • Dùng next/imagenext/font thay vì thẻ <img>/import font thủ công để tận dụng tối ưu hóa tự động.
  • Hiểu rõ chiến lược cache (cache, next.revalidate, next.tags) cho từng loại dữ liệu — tránh trường hợp dữ liệu hiển thị "cũ" ngoài mong muốn do cache mặc định.
  • Dùng Server Actions cho các thao tác ghi dữ liệu nội bộ (form, mutation) thay vì tự dựng thêm Route Handler không cần thiết.
  • Cấu trúc project theo feature/domain, đặt component, hook, action liên quan gần nhau thay vì tách rời theo loại file.

24. Tài liệu tham khảo


Bài viết được tổng hợp nhằm mục đích chia sẻ kiến thức, phù hợp làm tài liệu ôn tập hoặc tra cứu nhanh khi xây dựng ứng dụng full-stack với Next.js.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí