Cấu trúc thư mục Next.js App Router: dựng blog từ trang đầu tiên

Cấu trúc thư mục Next.js App Router cho blog, lấy từ repo thật của Tech Notes, gồm page, layout, trang động [slug], nhóm route (site) và deploy lên Vercel.

Cấu trúc thư mục Next.js App Router nên tổ chức thế nào? Mỗi thư mục trong app là một đoạn URL, thư mục chỉ thành trang khi có page.tsx, còn layout.tsx bọc mọi trang bên dưới nó. Cùng mình dựng blog đầu tiên và xem Tech Notes áp dụng ba quy tắc này ra sao nhé!

Bài trước dừng ở đâu?

Ở bài trước, mình kể vì sao Tech Notes chọn Next.js, Supabase và Vercel, và ước lượng 500 MB database chứa được bao nhiêu bài. Stack đã chọn xong, nhưng một câu hỏi rất thực tế vẫn còn đó: mở terminal ra thì gõ lệnh gì, và các thư mục nên xếp thế nào để sau này thêm vài chục trang mà không rối?

Bài này trả lời câu hỏi đó bằng hai thứ. Thứ nhất là một project nhỏ tên blog-mini, dựng từ đầu theo đúng các bước bên dưới. Thứ hai là lịch sử Git của chính Tech Notes, để bạn thấy cấu trúc thư mục của một blog thật đã thay đổi thế nào sau ba ngày làm việc.

Khởi tạo project bằng một lệnh

Next.js có công cụ create-next-app để tạo project. Lệnh nhanh nhất là:

Tạo project
npx create-next-app@latest blog-mini --yes --src-dir
cd blog-mini
npm run dev

Mở http://localhost:3000 là bạn thấy trang chào mừng mặc định của Next.js. Cờ --src-dir đặt code vào thư mục src/ giống Tech Notes, lý do mình kể ở phần sau. Cờ --yes bỏ qua các câu hỏi khác và dùng cấu hình mặc định. Theo tài liệu cài đặt của Next.js 16, cấu hình đó gồm TypeScript, Tailwind CSS, ESLint, App Router, Turbopack, đường dẫn rút gọn @/*, và một file AGENTS.md. Next.js cần Node.js 20.9 trở lên, nên nếu lệnh báo lỗi phiên bản, bạn nâng Node trước.

File AGENTS.md là thứ mới, và mình thấy nó khá đáng chú ý. Nó dặn các công cụ AI viết code hãy đọc tài liệu Next.js đi kèm trong node_modules/next/dist/docs/ thay vì dựa vào trí nhớ. Lý do là Next.js thay đổi nhanh, và AI rất hay viết code theo phiên bản cũ. Trong repo Tech Notes, đoạn hướng dẫn này nằm ngay cuối AGENTS.md, và bài bạn đang đọc cũng được kiểm tra lại với đúng bộ tài liệu đó.

Còn nếu bạn không dùng --yes, công cụ sẽ hỏi từng lựa chọn. Có một câu đáng dừng lại suy nghĩ: "Would you like your code inside a src/ directory?". Mặc định là không, nhưng Tech Notes chọn có, và đó là việc cờ --src-dir ở trên làm thay bạn.

Commit đầu tiên của Tech Notes có gì?

Commit f1233d3 với tiêu đề "establish Next.js blog foundation" là commit dựng nền cho blog. Trong thư mục src của nó chỉ có đúng năm file:

git show --stat f1233d3 (phần src)
src/app/globals.css  |  111 +
src/app/layout.tsx   |   19 +
src/app/page.tsx     |  147 +
src/lib/site.test.ts |   10 +
src/lib/site.ts      |    5 +

Năm file này chia làm hai nhóm rất rõ. Nhóm thứ nhất nằm trong src/app, là những thứ Next.js đọc để tạo website: layout gốc, trang chủ và CSS chung. Nhóm thứ hai nằm trong src/lib, là code bình thường của dự án, ở đây là file site.ts chứa tên blog và mô tả, kèm một file test.

Việc đặt code vào src giúp tách code của bạn khỏi các file cấu hình ở thư mục gốc như package.json, tsconfig.json, next.config.ts, eslint.config.mjs. Khi project lớn lên, thư mục gốc có thêm docs, scripts, supabase, content, và bạn sẽ thấy dễ chịu khi mọi thứ thuộc về ứng dụng nằm gọn trong một chỗ. Đây là sở thích nhiều hơn là quy tắc, nhưng theo mình nên chọn ngay từ đầu, vì đổi giữa chừng phải sửa rất nhiều đường dẫn.

Layout gốc ở commit đầu tiên chỉ dài 19 dòng:

Tech Notes: src/app/layout.tsx ở commit f1233d3
import type { Metadata } from "next";

import "./globals.css";

export const metadata: Metadata = {
  title: {
    default: "Tech Notes — Ghi chép thực hành cho developer",
    template: "%s | Tech Notes",
  },
  description: "Bài viết thực hành về lập trình, công cụ AI và hành trình sự nghiệp developer.",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="vi">
      <body>{children}</body>
    </html>
  );
}

Có hai chi tiết nhỏ đáng học. Layout gốc là nơi duy nhất được và phải có thẻ <html> và <body>, nên lang="vi" khai báo ở đây cho cả website. Còn template: "%s | Tech Notes" giúp mỗi trang chỉ cần khai báo tên riêng, ví dụ "Bài viết", và tiêu đề trên tab trình duyệt sẽ tự thành "Bài viết | Tech Notes".

Dựng các trang đầu tiên của blog-mini

Giờ mình biến trang chào mừng của blog-mini thành một blog nhỏ có trang chủ, trang danh sách bài và trang cho từng bài. Khi xong phần này và phần [slug] bên dưới, thư mục src sẽ như sau:

blog-mini/src
app/
  layout.tsx              # layout gốc: <html>, <body>, header chung
  page.tsx                # trang chủ "/"
  globals.css             # giữ nguyên file create-next-app tạo ra
  bai-viet/
    page.tsx              # "/bai-viet": danh sách bài
    [slug]/page.tsx       # "/bai-viet/<slug>": một bài (phần sau)
components/
  post-card.tsx           # thẻ hiển thị một bài, dùng ở nhiều trang
content/
  posts.ts                # dữ liệu bài viết, tạm để trong code
lib/
  site.ts                 # tên và mô tả của blog

Hai file đầu tiên không liên quan gì tới Next.js, chỉ là dữ liệu. File site.ts giữ tên blog để mọi trang dùng chung. File posts.ts chứa danh sách bài viết và một hàm tìm bài theo slug (slug là phần cuối của đường dẫn, như xin-chao trong /bai-viet/xin-chao):

src/lib/site.ts
export const site = {
  name: "Blog mini",
  description: "Blog mẫu của series Xây blog với Next.js + Supabase.",
};
src/content/posts.ts
// Bài viết nằm thẳng trong code. Tiện để bắt đầu, nhưng mỗi lần sửa một chữ là một lần commit và deploy.
export type Post = {
  slug: string;
  title: string;
  description: string;
  date: string;
  body: string;
};

export const posts: Post[] = [
  {
    slug: "xin-chao",
    title: "Xin chào",
    description: "Bài đầu tiên của blog.",
    date: "2026-11-03",
    body: "Đây là bài viết đầu tiên.\n\nBài viết đang nằm trong file src/content/posts.ts.",
  },
  {
    slug: "vi-sao-viet-blog",
    title: "Vì sao mình viết blog",
    description: "Ghi lại những gì học được khi đi làm.",
    date: "2026-11-04",
    body: "Viết ra giúp mình hiểu kỹ hơn.\n\nVà người đi sau đỡ mất thời gian hơn.",
  },
];

export function getPost(slug: string): Post | undefined {
  return posts.find((post) => post.slug === slug);
}

Component PostCard hiển thị tiêu đề và mô tả của một bài, kèm link tới trang của bài đó. Trang chủ và trang danh sách đều dùng nó:

src/components/post-card.tsx
import Link from "next/link";

import type { Post } from "@/content/posts";

export function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>
        <Link href={`/bai-viet/${post.slug}`}>{post.title}</Link>
      </h2>
      <p>{post.description}</p>
    </article>
  );
}

Đường dẫn @/content/posts là cách viết rút gọn của src/content/posts, nhờ alias @/* mà create-next-app đã cấu hình sẵn trong tsconfig.json. Nhờ vậy, file nằm sâu bao nhiêu cấp cũng không phải viết ../../../.

Tiếp theo là ba file trong app. Bạn thay hẳn nội dung layout.tsx và page.tsx mà create-next-app tạo ra, và tạo thêm bai-viet/page.tsx. Layout gốc có header với link về trang chủ và trang danh sách bài, nên header này xuất hiện ở mọi trang:

src/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";

import { site } from "@/lib/site";

import "./globals.css";

export const metadata: Metadata = {
  title: { default: site.name, template: `%s | ${site.name}` },
  description: site.description,
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="vi">
      <body>
        <header>
          <Link href="/">{site.name}</Link>
          <nav>
            <Link href="/bai-viet">Bài viết</Link>
          </nav>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

Trang chủ lấy ba bài mới nhất. Nó là Server Component, tức là chạy trên máy chủ, nên đọc thẳng mảng posts mà không cần useEffect hay gọi API:

src/app/page.tsx
import { PostCard } from "@/components/post-card";
import { posts } from "@/content/posts";

export default function HomePage() {
  const latest = [...posts].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 3);
  return (
    <>
      <h1>Ghi chép thực hành cho developer</h1>
      {latest.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </>
  );
}

Trang danh sách thì hiện tất cả các bài. Dòng metadata đặt tiêu đề "Bài viết", và nhờ template ở layout, tab trình duyệt sẽ hiện "Bài viết | Blog mini":

src/app/bai-viet/page.tsx
import type { Metadata } from "next";

import { PostCard } from "@/components/post-card";
import { posts } from "@/content/posts";

export const metadata: Metadata = { title: "Bài viết" };

export default function PostsPage() {
  return (
    <>
      <h1>Bài viết</h1>
      {posts.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </>
  );
}

Lúc này mở http://localhost:3000 bạn thấy hai bài, bấm "Bài viết" thì sang /bai-viet. Bấm vào một bài thì nhận trang 404, vì trang cho từng bài chưa có. Mình làm trang đó ở phần sau.

Khi nào một thư mục thành một trang?

Đây là chỗ người mới hay nhầm nhất, nên mình làm một ví dụ hỏng trước.

Trong blog-mini, bạn tạo thư mục src/app/gioi-thieu và đặt vào đó một file noi-dung.md chứa vài dòng giới thiệu. Nghe hợp lý đúng không? Thư mục tên gioi-thieu thì đường dẫn sẽ là /gioi-thieu. Nhưng mở trình duyệt ra thì bạn nhận về trang 404.

Lý do là Next.js chỉ coi một thư mục là trang công khai khi trong đó có file page.tsx (hoặc route.ts cho API). Tài liệu Project structure nói rõ rằng thư mục định nghĩa cấu trúc đường dẫn, nhưng đường dẫn chưa truy cập được cho tới khi có page hoặc route. Mặt tốt của quy tắc này là bạn có thể để file khác trong app mà không sợ chúng vô tình thành trang.

Để không phải nhớ trong đầu, mình viết một script nhỏ tên xem-duong-dan.ts, đặt ở thư mục gốc của blog-mini (ngang hàng với package.json, không nằm trong src/app). Script nhận đường dẫn tới thư mục app, đi qua từng thư mục con, và áp dụng bốn quy ước của App Router được ghi ở đầu file. Thư mục có page.tsx thì in ra đường dẫn tương ứng. Thư mục không có gì để hiển thị thì được liệt kê riêng ở cuối:

xem-duong-dan.ts
// In ra bảng "thư mục → đường dẫn" của một thư mục app/ theo quy ước App Router.
//   - Chỉ thư mục có page.tsx (hoặc route.ts) mới thành đường dẫn công khai.
//   - (ten-nhom): nhóm route, không xuất hiện trong URL.
//   - _ten: thư mục riêng tư, bị bỏ qua cùng mọi thư mục con.
//   - @slot: slot của layout cha, không phải một đoạn URL.
import { readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";

const root = process.argv[2];
if (!root) {
  console.error("Cách dùng: node xem-duong-dan.ts <thư-mục-app>");
  process.exit(1);
}

type Row = { url: string; file: string; kind: "trang" | "route" | "layout" };
const rows: Row[] = [];
const khongPhaiTrang: string[] = [];
const riengTu: string[] = [];
const rel = (path: string) => relative(root, path).split(sep).join("/");

function toUrl(dir: string): string {
  const segments = rel(dir)
    .split("/")
    .filter((s) => s && !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"));
  return "/" + segments.join("/");
}

function walk(dir: string) {
  const entries = readdirSync(dir);
  const files = entries.filter((e) => statSync(join(dir, e)).isFile());
  const subdirs = entries.filter((e) => statSync(join(dir, e)).isDirectory());
  const page = files.find((f) => /^page\.(tsx|ts|jsx|js|mdx)$/.test(f));
  const route = files.find((f) => /^route\.(ts|js)$/.test(f));
  const layout = files.find((f) => /^layout\.(tsx|ts|jsx|js)$/.test(f));

  if (page) rows.push({ url: toUrl(dir), file: rel(join(dir, page)), kind: "trang" });
  if (route) rows.push({ url: toUrl(dir), file: rel(join(dir, route)), kind: "route" });
  if (layout) rows.push({ url: toUrl(dir), file: rel(join(dir, layout)), kind: "layout" });
  if (dir !== root && !page && !route && !layout && subdirs.length === 0) khongPhaiTrang.push(rel(dir) + "/");

  for (const sub of subdirs) {
    if (sub.startsWith("_")) riengTu.push(rel(join(dir, sub)) + "/");
    else walk(join(dir, sub));
  }
}

walk(root);

const width = Math.max(...rows.map((r) => r.url.length), 10);
const headings = {
  trang: "Trang công khai:",
  route: "Route handler (trả dữ liệu, không phải trang):",
  layout: "Layout (bọc các trang bên dưới):",
};
for (const kind of ["trang", "route", "layout"] as const) {
  const list = rows.filter((r) => r.kind === kind).sort((a, b) => a.url.localeCompare(b.url));
  if (list.length === 0) continue;
  console.log(headings[kind]);
  for (const r of list) console.log(`  ${r.url.padEnd(width)}  ←  ${r.file}`);
  console.log();
}
if (khongPhaiTrang.length) console.log(`Thư mục không có page.tsx, nên KHÔNG thành trang: ${khongPhaiTrang.join(", ")}`);
if (riengTu.length) console.log(`Thư mục riêng tư (bắt đầu bằng _), Next.js bỏ qua: ${riengTu.join(", ")}`);

Script chạy thẳng bằng Node.js 22.18 trở lên. Đây là kết quả khi chạy trên blog-mini, sau khi đã có cả trang [slug] ở phần tiếp theo. Mỗi dòng đọc là "đường dẫn này do file này tạo ra":

node xem-duong-dan.ts src/app
Trang công khai:
  /                 ←  page.tsx
  /bai-viet         ←  bai-viet/page.tsx
  /bai-viet/[slug]  ←  bai-viet/[slug]/page.tsx

Layout (bọc các trang bên dưới):
  /                 ←  layout.tsx

Thư mục không có page.tsx, nên KHÔNG thành trang: gioi-thieu/

Dòng cuối chính là cái 404 lúc nãy. Thêm một file page.tsx vào gioi-thieu là trang xuất hiện.

Trang cho từng bài viết dùng [slug] thế nào?

Blog có hàng chục bài, không ai tạo hàng chục thư mục. Next.js cho phép đặt tên thư mục trong ngoặc vuông, như [slug], để một trang khớp với mọi giá trị ở đoạn đó của URL. Trang bai-viet/[slug]/page.tsx sẽ phục vụ cả /bai-viet/xin-chao lẫn /bai-viet/vi-sao-viet-blog.

src/app/bai-viet/[slug]/page.tsx
import { notFound } from "next/navigation";

import { getPost, posts } from "@/content/posts";

// Tạo sẵn trang cho từng bài lúc build.
export function generateStaticParams() {
  return posts.map((post) => ({ slug: post.slug }));
}

type Props = { params: Promise<{ slug: string }> };

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      {post.body.split("\n\n").map((paragraph, index) => (
        <p key={index}>{paragraph}</p>
      ))}
    </article>
  );
}

Chỗ dễ sai nhất là params. Nếu bạn học Next.js từ bài hướng dẫn cũ hoặc nhờ AI viết, rất có thể bạn sẽ thấy params.slug được dùng thẳng.

Từ Next.js 15, params là một Promise, nên phải await trước khi đọc. Next.js 15 vẫn tạm cho đọc kiểu cũ trong giai đoạn chuyển tiếp, còn theo hướng dẫn nâng cấp lên bản 16 thì cách đọc đồng bộ đã bị bỏ hẳn. Viết params.slug bây giờ thì TypeScript báo lỗi ngay, vì một Promise không có thuộc tính slug. Đây chính là loại thay đổi mà file AGENTS.md ở trên muốn nhắc các công cụ AI.

Hàm generateStaticParams cho Next.js biết trước danh sách các slug, để nó tạo sẵn HTML cho từng bài lúc build. Còn notFound() trả về trang 404 khi slug không tồn tại, thay vì để trang vỡ vì post là undefined. Nếu bạn còn nhớ lỗi Cannot read properties of undefined trong bài đọc stack trace, thì đây là cách chặn nó ngay từ chỗ đọc dữ liệu.

Mình đã kiểm tra kiểu cho toàn bộ blog-mini với Next.js 16.4 và React 19.3, là đúng phiên bản Tech Notes đang dùng.

Layout lồng nhau và nhóm route (site)

Sau commit đầu tiên, blog lớn rất nhanh. Chỉ trong ngày 3/11/2026, Tech Notes có thêm trang bài viết, chủ đề, series, tìm kiếm, rồi đến trang quản trị để viết bài. Lúc đó mình gặp vấn đề đầu tiên về cấu trúc thư mục.

Trang công khai và trang quản trị khác nhau ở hai điểm. Về giao diện, người đọc cần header, menu, footer của blog, còn tác giả cần thanh điều hướng của trang quản trị. Về cách tạo trang, trang công khai được tạo sẵn và làm mới tối đa mỗi giờ một lần cho nhanh, còn trang quản trị phụ thuộc vào người đang đăng nhập nên phải tạo mới ở mọi lượt truy cập.

Nếu để mọi thứ chung dưới layout gốc, mình chỉ có hai lựa chọn tệ. Một là layout gốc chứa header của blog, và trang quản trị phải tìm cách giấu nó đi. Hai là mỗi trang công khai tự gắn header, và mười mấy trang lặp lại cùng một đoạn code.

Commit f55fc16 giải quyết bằng nhóm route. Tên thư mục đặt trong ngoặc tròn, như (site), được Next.js dùng để gom trang nhưng không đưa vào URL. Các trang công khai được chuyển vào src/app/(site)/, nên (site)/bai-viet/page.tsx vẫn là /bai-viet. Nhóm này có layout riêng:

Tech Notes: src/app/(site)/layout.tsx (file thật, không cần chép vào blog-mini)
// Public pages are static and refresh at most hourly; publishing from the admin revalidates
// them immediately, and the hourly pass picks up scheduled posts once their time arrives.
export const revalidate = 3600;

export default function SiteLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <>
      <SiteFrame>{children}</SiteFrame>
      {/* The analytics scripts are served by Vercel itself; elsewhere (local, CI) they would 404. */}
      {process.env.VERCEL ? <SiteAnalytics /> : null}
    </>
  );
}

SiteFrame là component chứa header, menu và footer của blog, còn SiteAnalytics gắn script đo lượt xem của Vercel. Bạn không cần hai component này để hiểu ý chính. Điều cần để ý là dòng revalidate = 3600, nghĩa là mọi trang trong nhóm (site) được tạo sẵn và làm mới tối đa mỗi giờ một lần.

Còn src/app/admin/layout.tsx thì khai báo export const dynamic = "force-dynamic" và dùng khung giao diện của trang quản trị. Hai thế giới tách bạch, mỗi bên một layout, chung một layout gốc chỉ lo <html>, font và metadata.

Chạy script lúc nãy trên thư mục src/app thật của Tech Notes hôm nay, mình được 27 trang công khai, 2 route handler và 3 layout. Đây là phần đầu và phần cuối của kết quả:

node xem-duong-dan.ts tech-notes/src/app (trích)
Trang công khai:
  /                        ←  (site)/page.tsx
  /admin                   ←  admin/page.tsx
  /admin/bai-viet/[id]     ←  admin/bai-viet/[id]/page.tsx
  /bai-viet                ←  (site)/bai-viet/page.tsx
  /bai-viet/[slug]         ←  (site)/bai-viet/[slug]/page.tsx
  …

Route handler (trả dữ liệu, không phải trang):
  /admin/ban-tin/xuat-csv  ←  admin/ban-tin/xuat-csv/route.ts
  /rss.xml                 ←  rss.xml/route.ts

Layout (bọc các trang bên dưới):
  /                        ←  layout.tsx
  /                        ←  (site)/layout.tsx
  /admin                   ←  admin/layout.tsx

Bạn để ý hai layout cùng ứng với /. Layout gốc bọc tất cả, còn (site)/layout.tsx chỉ bọc các trang nằm trong nhóm. Đường dẫn giống nhau nhưng phạm vi khác nhau, và đó là toàn bộ ý nghĩa của nhóm route.

Component và hàm dùng chung để ở đâu?

Next.js cho phép đặt file khác cạnh page.tsx trong thư mục app, vì chỉ page và route mới thành đường dẫn. Bạn cũng có thể đặt tên thư mục bắt đầu bằng gạch dưới, như _components, để Next.js bỏ qua hẳn thư mục đó và mọi thứ bên trong.

Tech Notes chọn cách khác, là để app chỉ chứa trang và layout. Component nằm ở src/components (hiện có 27 file), hàm xử lý nằm ở src/lib (30 file không tính test), và nội dung mẫu nằm ở src/content. Mình có hai lý do.

Lý do thứ nhất là phần lớn component được dùng ở nhiều trang. post-card.tsx xuất hiện ở trang chủ, trang danh sách bài, trang chủ đề và trang series. Đặt nó cạnh một trang nào thì cũng sai chỗ với ba trang còn lại.

Lý do thứ hai là các hàm trong src/lib cần test, và chúng không phụ thuộc vào Next.js. Hàm tạo slug, hàm đọc Markdown, hàm kiểm tra SEO đều chạy được trong Vitest mà không cần dựng trang nào. Khi mở src/app, mình chỉ thấy đường dẫn của website, và khi mở src/lib, mình chỉ thấy logic. Theo mình, cách chia này dễ đọc hơn cho người mới vào dự án, nhưng nếu dự án của bạn có nhiều component chỉ dùng cho đúng một trang thì để cạnh trang đó cũng hoàn toàn ổn.

Đưa trang đầu tiên lên Vercel

Khi npm run dev chạy được trên máy, việc đưa lên mạng chỉ mất vài phút. Bạn đẩy code lên một repo GitHub, vào Vercel, chọn tạo project mới và import repo đó. Vercel tự nhận ra đây là project Next.js nên bạn giữ cấu hình mặc định và bấm Deploy. Từ đó, mỗi lần đẩy code lên nhánh main là có bản production mới, còn mỗi pull request có một đường dẫn xem trước riêng.

Có một chi tiết mình khuyên bạn làm ngay từ ngày đầu, là không cho Google lập chỉ mục các bản xem trước. Nếu Google tìm thấy đường dẫn preview, một bản chưa hoàn thiện có thể xuất hiện trong kết quả tìm kiếm và tranh chỗ với bản thật. Vercel mặc định đã gắn tín hiệu noindex cho bản preview, nhưng Tech Notes vẫn tự khai báo trong code, để quy tắc này nằm trong repo và có test, không phụ thuộc vào một cài đặt mà ai đó có thể tắt. Việc này nằm trong một hàm nhỏ ở src/lib/site.ts:

Tech Notes: src/lib/site.ts (trích hàm isIndexable)
/** Only Vercel's production deployment may be indexed; previews and local builds stay `noindex`. */
export function isIndexable(): boolean {
  return process.env.VERCEL_ENV === "production";
}

Layout gốc dùng hàm này để khai báo robots: { index: indexable, follow: indexable }. Vercel tự đặt biến VERCEL_ENV thành production, preview hoặc development tùy bản deploy, nên chỉ bản production được phép xuất hiện trên Google. Chi tiết về các loại môi trường có trong tài liệu Environments của Vercel.

Một lỗi chính tả, một lần deploy

Quay lại blog-mini. Bài viết đang nằm trong src/content/posts.ts, là một mảng TypeScript bình thường. Tech Notes cũng bắt đầu đúng như vậy, ở commit b7e58d5, file posts.ts dài 330 dòng chứa các bài mẫu đầu tiên.

Cách này rất tiện để dựng giao diện, nhưng nó có một cái giá mà bạn sẽ thấy ngay khi bắt đầu viết thật. Muốn sửa một lỗi chính tả, bạn phải mở code, sửa file, commit, push và chờ Vercel build lại. Viết bài trên điện thoại thì không thể. Đó chính là yêu cầu số 2 trong danh sách ở bài trước, và stack hiện tại chưa đáp ứng được.

Các bài viết sau của series sẽ chuyển bài viết từ code vào một bảng trên Supabase, viết migration để tạo bảng đó, và giữ nguyên tốc độ của trang tĩnh. Bạn theo dõi cả series ở Xây blog với Next.js + Supabase. Nếu đang ôn phỏng vấn, câu hỏi về nhóm route và thư mục riêng tư có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.