Supabase migration: thiết kế bảng bài viết cho blog Next.js

Viết Supabase migration tạo bảng bài viết cho blog Next.js: chọn cột, ràng buộc chặn dữ liệu sai, seed bài mẫu và thêm cột mới mà không làm hỏng dữ liệu cũ.

Supabase migration là gì? Đó là một file SQL nằm trong supabase/migrations/, mô tả một thay đổi của database, được commit vào Git và chạy theo thứ tự. Nhờ vậy database ở máy bạn và trên production luôn giống nhau. Cùng mình tạo bảng bài viết cho blog bằng migration nhé!

Vì sao bài viết phải rời khỏi code?

Ở bài trước, mình dựng blog-mini, một blog Next.js nhỏ với bài viết nằm trong file src/content/posts.ts. Mỗi bài là một object có slug, title, description, date và body. Cách này đủ để dựng giao diện, nhưng sửa một lỗi chính tả cũng phải commit, push và chờ Vercel build lại.

Tech Notes đi đúng con đường đó. Bài viết ban đầu nằm trong một file posts.ts dài 330 dòng, rồi tới commit f55fc16 ngày 3/11/2026 mới chuyển sang bảng posts trên Supabase, kèm trang admin để viết bài. Bài này làm phần nền của bước chuyển đó: thiết kế bảng và tạo nó bằng migration.

Tạo bảng bằng cách bấm trên Dashboard có gì sai?

Supabase có trình sửa bảng trên Dashboard, bấm vài nút là có bảng mới. Mình cũng từng nghĩ "cho nhanh, sau này sửa sau". Vấn đề là cách đó không để lại dấu vết gì trong code.

Bạn tạo bảng trên project production, nhưng database ở máy bạn không có bảng đó. Người khác clone repo về cũng không có. Ba tháng sau bạn không nhớ cột status từng có ràng buộc gì, và cũng không ai review được thay đổi trước khi nó chạy trên dữ liệu thật.

Migration giải quyết đúng chuyện này. Mỗi thay đổi là một file SQL có tên bắt đầu bằng thời điểm tạo, nằm trong Git như mọi file code khác, đi qua pull request và được review. Tài liệu về database migration của Supabase hướng dẫn tạo và chạy các file này bằng Supabase CLI:

Các lệnh Supabase CLI dùng trong bài
npx supabase migration new tao_bang_bai_viet   # tạo file rỗng supabase/migrations/<thời điểm>_tao_bang_bai_viet.sql
npx supabase db reset                          # máy bạn: tạo lại database, chạy mọi migration rồi supabase/seed.sql (cần Docker)
npx supabase db push                           # đẩy các migration chưa chạy lên project Supabase đã link

Repo Tech Notes hiện có tám migration, từ 20261103141232_create_posts.sql tới 20261110011701_content_locale.sql (file thêm cột ngôn ngữ cho bản tiếng Anh). Quy tắc của repo được ghi thẳng trong file hướng dẫn: mọi thay đổi schema, quyền hay chính sách bảo mật đều phải đi qua migration có trong Git, không sửa tay trên production.

Bảng cho nhanh thì hỏng ở đâu?

Trước khi viết migration đúng, mình thử cách nhiều người làm lần đầu: tạo bảng mà cột nào cũng là text và được phép để trống, rồi đưa thử bốn dòng dữ liệu.

Mỗi dòng có một lỗi mà ngoài đời rất dễ gặp. Dòng thứ nhất có slug viết hoa và có dấu cách. Dòng thứ hai gõ sai "published" thành "publised". Dòng thứ ba đã đăng nhưng chưa có ngày. Dòng thứ tư trùng slug với bài đã có và viết ngày kiểu Việt Nam.

Để chạy SQL mà không cần Docker hay tài khoản Supabase, mình dùng PGlite, một bản PostgreSQL thật chạy ngay trong Node.js. Script sau tạo bảng lỏng lẻo đó, lưu bốn dòng, rồi chạy truy vấn mà trang chủ sẽ dùng:

scripts/bang-long-leo.mjs
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(`
  create table posts (id serial primary key, slug text, title text, body text, status text, published_at text);
`);

const duLieu = [
  ["Xin Chao", "Xin chào", "published", "2026-11-03"],
  ["vi-sao-viet-blog", "Vì sao mình viết blog", "publised", "2026-11-04"],
  ["bai-chua-xong", "Bài chưa xong", "published", null],
  ["xin-chao", "Xin chào (bản trùng)", "published", "03/11/2026"],
];
for (const [slug, title, status, publishedAt] of duLieu) {
  await db.query("insert into posts (slug, title, status, published_at) values ($1, $2, $3, $4)", [slug, title, status, publishedAt]);
  console.log(`đã lưu: ${slug} | ${status} | ${publishedAt}`);
}

const trangChu = await db.query("select slug from posts where status = 'published' order by published_at desc");
console.log(`\nTrang chủ (status = 'published', mới nhất trước): ${trangChu.rows.map((r) => r.slug).join(", ")}`);
node scripts/bang-long-leo.mjs
đã lưu: Xin Chao | published | 2026-11-03
đã lưu: vi-sao-viet-blog | publised | 2026-11-04
đã lưu: bai-chua-xong | published | null
đã lưu: xin-chao | published | 03/11/2026

Trang chủ (status = 'published', mới nhất trước): bai-chua-xong, Xin Chao, xin-chao

Database nhận hết, không một lời phàn nàn. Bạn nhìn dòng cuối sẽ thấy hậu quả.

Bài "Vì sao mình viết blog" biến mất khỏi trang chủ, chỉ vì một chữ gõ sai, và không có lỗi nào báo cho bạn biết. Bài chưa có ngày lại đứng đầu, vì khi sắp xếp giảm dần, PostgreSQL mặc định đặt giá trị null lên trước. Còn ngày lưu dạng chữ thì được so sánh như chữ, nên "03/11/2026" xếp sau "2026-11-03" dù cả hai là cùng một ngày. Hai bài cùng slug xin-chao nghĩa là đường dẫn /bai-viet/xin-chao không biết phải hiện bài nào.

Mỗi lỗi này đều có thể chặn bằng code TypeScript trong trang admin. Nhưng dữ liệu không chỉ đi vào qua trang admin. Nó còn đến từ script import, từ người sửa tay trong Dashboard, từ một phiên bản app cũ. Ràng buộc đặt ngay trong database thì chặn được tất cả các đường đó.

Thiết kế bảng posts thế nào?

Mình bắt đầu từ object Post trong posts.ts và tự hỏi mỗi trường cần gì khi đã nằm trong database.

slug vẫn là đường dẫn của bài, nhưng mình thêm một cột id riêng làm khóa chính. Slug là thứ có thể đổi, ví dụ khi bạn sửa tiêu đề cho chuẩn SEO hơn. Các bảng khác (lượt xem, câu hỏi liên quan) nên trỏ tới một giá trị không bao giờ đổi.

date tách thành hai ý. status cho biết bài là nháp hay đã đăng, còn published_at là thời điểm đăng. Mình dùng kiểu timestamptz, tức thời điểm có múi giờ, chứ không dùng date, vì sau này blog sẽ cần hẹn giờ đăng lúc 8:00 sáng giờ Việt Nam. Hai cột created_at và updated_at để biết bài được tạo và sửa lần cuối khi nào. updated_at sẽ có ích khi làm sitemap.

body đổi tên thành body_markdown để ai đọc schema cũng biết nội dung lưu ở định dạng nào.

Đây là migration đầu tiên. Mình tạo nó bằng npx supabase migration new tao_bang_bai_viet rồi viết nội dung vào file vừa sinh ra:

supabase/migrations/20261110080000_tao_bang_bai_viet.sql
-- Bảng bài viết của blog-mini. Mỗi ràng buộc dưới đây chặn một kiểu dữ liệu sai cụ thể.

create table public.posts (
  id bigint generated always as identity primary key,
  slug text not null unique
    constraint posts_slug_format check (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
  title text not null
    constraint posts_title_length check (char_length(title) between 1 and 200),
  description text not null default '',
  body_markdown text not null default '',
  status text not null default 'draft'
    constraint posts_status_known check (status in ('draft', 'published')),
  published_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  constraint posts_published_has_date check (status = 'draft' or published_at is not null)
);

comment on table public.posts is 'Bài viết của blog. Nội dung lưu dạng Markdown.';

-- Trang chủ liệt kê bài đã đăng, mới nhất trước: index cho đúng truy vấn đó.
create index posts_published_at_idx on public.posts (published_at desc) where status = 'published';

-- Tự cập nhật updated_at mỗi lần sửa, không phụ thuộc vào code phía client.
create function public.set_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
  new.updated_at := now();
  return new;
end;
$$;

create trigger posts_set_updated_at
before update on public.posts
for each row execute function public.set_updated_at();

Mỗi ràng buộc ở đây trả lời một dòng dữ liệu sai ở mục trước. posts_slug_format chỉ cho phép chữ thường, số và dấu gạch ngang, không có hai dấu gạch liền nhau. posts_status_known chỉ nhận hai giá trị, nên "publised" bị chặn. posts_published_has_date là ràng buộc trên cả dòng: bài đã đăng thì bắt buộc có ngày, còn bài nháp thì không. unique trên slug chặn hai bài cùng đường dẫn.

Index posts_published_at_idx là một partial index, chỉ chứa các bài đã đăng, vì người đọc không bao giờ thấy bài nháp. Với hai bài thì chưa cần, nhưng nó khớp đúng truy vấn của trang chủ và gần như không tốn gì để thêm từ đầu.

Trigger posts_set_updated_at tự đặt lại updated_at mỗi lần một dòng bị sửa. Nếu để code phía client tự gửi giá trị này, sớm muộn sẽ có chỗ quên gửi, và sitemap sẽ báo sai ngày cập nhật.

Dữ liệu cũ đưa vào bảng bằng cách nào?

Hai bài trong posts.ts chuyển sang file supabase/seed.sql. Lệnh npx supabase db reset chạy file này sau khi chạy xong các migration, nên database ở máy bạn luôn có sẵn dữ liệu để thử giao diện:

supabase/seed.sql
-- Hai bài mẫu chuyển từ src/content/posts.ts sang database.
insert into public.posts (slug, title, description, body_markdown, status, published_at) values
  ('xin-chao', 'Xin chào', 'Bài đầu tiên của blog.',
   E'Đây là bài viết đầu tiên.\n\nBài viết giờ nằm trong bảng posts trên Supabase.',
   'published', '2026-11-03 08:00+07'),
  ('vi-sao-viet-blog', 'Vì sao mình viết blog', 'Ghi lại những gì học được khi đi làm.',
   E'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.',
   'published', '2026-11-04 08:00+07');

Chuỗi bắt đầu bằng E'...' cho phép viết \n để xuống dòng trong nội dung Markdown. Ngày viết kèm +07 để PostgreSQL biết đó là 8:00 giờ Việt Nam chứ không phải 8:00 giờ UTC.

Seed chỉ dành cho máy của bạn. db push không chạy file seed lên production, và bạn cũng không muốn hai bài mẫu này xuất hiện trên blog thật.

Ràng buộc có chặn được dữ liệu sai thật không?

Để kiểm chứng, script tiếp theo làm đúng việc db reset làm. Nó chạy các file trong supabase/migrations/ theo thứ tự tên, rồi chạy seed.sql. Sau đó nó thử lại các dòng dữ liệu sai, cộng thêm hai dòng viết ngày kiểu Việt Nam:

scripts/chay-migration.mjs
import { readdirSync, readFileSync } from "node:fs";
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
for (const file of readdirSync("supabase/migrations").sort()) {
  await db.exec(readFileSync(`supabase/migrations/${file}`, "utf8"));
  console.log(`migration ok: ${file}`);
}
await db.exec(readFileSync("supabase/seed.sql", "utf8"));
console.log("seed ok: supabase/seed.sql\n");

const duLieuSai = [
  ["Xin Chao", "Xin chào", "published", "2026-11-03"],
  ["vi-sao-viet-blog-2", "Vì sao mình viết blog", "publised", "2026-11-04"],
  ["bai-chua-xong", "Bài chưa xong", "published", null],
  ["xin-chao", "Xin chào (bản trùng)", "published", "2026-11-03"],
  ["ngay-sai", "Ngày viết kiểu Việt", "published", "13/11/2026"],
  ["ngay-03-11", "Ngày 3 tháng 11 viết kiểu Việt", "published", "03/11/2026"],
];
for (const [slug, title, status, publishedAt] of duLieuSai) {
  try {
    await db.query("insert into public.posts (slug, title, status, published_at) values ($1, $2, $3, $4)", [slug, title, status, publishedAt]);
    const { rows } = await db.query("select published_at::date::text as ngay from public.posts where slug = $1", [slug]);
    console.log(`đã lưu ${slug}, nhưng published_at = ${rows[0].ngay}`);
  } catch (error) {
    console.log(`từ chối ${slug}: ${error.message}`);
  }
}

const truoc = await db.query("select updated_at from public.posts where slug = 'xin-chao'");
await db.query("update public.posts set title = 'Xin chào!' where slug = 'xin-chao'");
const sau = await db.query("select updated_at from public.posts where slug = 'xin-chao'");
console.log(`\nupdated_at tự đổi khi sửa bài: ${truoc.rows[0].updated_at.getTime() !== sau.rows[0].updated_at.getTime()}`);

const trangChu = await db.query("select slug, tags from public.posts where status = 'published' order by published_at desc");
console.log(`Trang chủ: ${trangChu.rows.map((r) => `${r.slug} (tags: ${JSON.stringify(r.tags)})`).join(", ")}`);

Script này đọc cả migration thứ hai (thêm cột tags) mà mình viết ở mục sau, nên dòng cuối có in tags. Kết quả:

node scripts/chay-migration.mjs
migration ok: 20261110080000_tao_bang_bai_viet.sql
migration ok: 20261110090000_them_the_bai_viet.sql
seed ok: supabase/seed.sql

từ chối Xin Chao: new row for relation "posts" violates check constraint "posts_slug_format"
từ chối vi-sao-viet-blog-2: new row for relation "posts" violates check constraint "posts_status_known"
từ chối bai-chua-xong: new row for relation "posts" violates check constraint "posts_published_has_date"
từ chối xin-chao: duplicate key value violates unique constraint "posts_slug_key"
từ chối ngay-sai: date/time field value out of range: "13/11/2026"
đã lưu ngay-03-11, nhưng published_at = 2026-03-11

updated_at tự đổi khi sửa bài: true
Trang chủ: vi-sao-viet-blog (tags: []), xin-chao (tags: []), ngay-03-11 (tags: [])

Bốn lỗi đầu bị chặn đúng chỗ, và thông báo lỗi nói thẳng tên ràng buộc bị vi phạm. Đặt tên ràng buộc rõ ràng như posts_status_known thay vì để PostgreSQL tự đặt giúp bạn đọc lỗi nhanh hơn nhiều khi nó hiện ra trong log.

Bạn chú ý hai dòng ngày tháng. "13/11/2026" bị từ chối, vì PostgreSQL mặc định đọc ngày theo kiểu Mỹ, tháng trước ngày sau (thiết lập DateStyle là ISO, MDY), và không có tháng 13. Còn "03/11/2026" thì được lưu, nhưng thành ngày 11 tháng 3. Bài đó bị đẩy xuống cuối trang chủ, và không ràng buộc nào phát hiện được, vì 11/3 vẫn là một ngày hợp lệ.

Bài học mình rút ra là ràng buộc chặn được dữ liệu sai hình dạng, nhưng không đoán được ý của người nhập. Ngày tháng nên luôn đi từ code vào database ở dạng ISO (2026-11-03T08:00:00+07:00), không bao giờ ở dạng người đọc quen nhìn.

Thêm cột mới vào bảng đã có dữ liệu thế nào?

Một tuần sau, blog cần thẻ (tags) cho bài viết. Cách viết đầu tiên mình nghĩ tới là thêm một cột bắt buộc có giá trị. Script sau chạy migration đầu tiên và seed, rồi thử hai cách viết:

scripts/them-cot-sai.mjs
import { readFileSync } from "node:fs";
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(readFileSync("supabase/migrations/20261110080000_tao_bang_bai_viet.sql", "utf8"));
await db.exec(readFileSync("supabase/seed.sql", "utf8"));
console.log("Bảng posts đang có 2 bài.\n");

try {
  await db.exec("alter table public.posts add column tags text[] not null;");
  console.log("cách 1 chạy được");
} catch (error) {
  console.log(`cách 1 (not null, không có default) lỗi: ${error.message}`);
}

await db.exec(readFileSync("supabase/migrations/20261110090000_them_the_bai_viet.sql", "utf8"));
console.log("cách 2 (not null default '{}') chạy được");

// Bản app cũ chưa biết cột tags: câu insert của nó không nhắc tới tags.
await db.query("insert into public.posts (slug, title) values ('bai-tu-app-cu', 'Bài thêm từ bản app cũ')");
const rows = await db.query("select slug, tags from public.posts order by id");
for (const row of rows.rows) console.log(`  ${row.slug}: tags = ${JSON.stringify(row.tags)}`);

Cách 2 là migration thứ hai của dự án:

supabase/migrations/20261110090000_them_the_bai_viet.sql
-- Thêm thẻ (tags) cho bài viết. Tương thích ngược: các bài đã có nhận mảng rỗng,
-- và bản app cũ (chưa biết cột này) vẫn thêm bài được vì cột có giá trị mặc định.
alter table public.posts
  add column tags text[] not null default '{}'
    constraint posts_tags_limit check (cardinality(tags) <= 10);
node scripts/them-cot-sai.mjs
Bảng posts đang có 2 bài.

cách 1 (not null, không có default) lỗi: column "tags" of relation "posts" contains null values
cách 2 (not null default '{}') chạy được
  xin-chao: tags = []
  vi-sao-viet-blog: tags = []
  bai-tu-app-cu: tags = []

Cách 1 hỏng vì hai bài cũ không có giá trị nào cho cột mới, tức là null, mà cột lại cấm null. Trên máy bạn với bảng rỗng, migration này chạy trơn tru. Lên production có hàng trăm bài, nó mới hỏng, và đó là lúc tệ nhất để phát hiện.

Cách 2 cho cột một giá trị mặc định là mảng rỗng. Bài cũ nhận mảng rỗng, và dòng bai-tu-app-cu cho thấy thêm một điều. Ở Tech Notes, mình chạy db push trước rồi mới merge code mới, nên trong lúc Vercel build, bản app cũ vẫn phục vụ người đọc trên database đã có cột mới. Bản app cũ không biết cột tags, câu insert của nó không nhắc tới cột này, nhưng nó vẫn chạy được nhờ giá trị mặc định.

Tech Notes gặp đúng tình huống này khi thêm cấp độ Cơ bản, Trung cấp, Nâng cao cho bài viết. Đây là migration thật trong repo, viết ngày 3/11/2026, chỉ khác tên cột:

supabase/migrations/20261103144514_add_post_level.sql (Tech Notes)
-- Reader-facing difficulty level for each post (Cơ bản / Trung cấp / Nâng cao).
-- Backward compatible: existing rows and older app versions get the 'co-ban' default.
alter table public.posts
  add column level text not null default 'co-ban'
    constraint posts_level_known check (level in ('co-ban', 'trung-cap', 'nang-cao'));

comment on column public.posts.level is 'Difficulty shown to readers: co-ban, trung-cap or nang-cao.';

Bảng đã có, vậy trang web đọc nó thế nào?

Bảng posts đã sẵn sàng, nhưng blog-mini vẫn chưa đọc được nó. Trang Next.js sẽ gọi Supabase bằng khóa publishable, và khóa này nằm trong mã JavaScript gửi xuống trình duyệt, ai mở DevTools cũng thấy.

Có một thay đổi gần đây bạn nên biết. Theo thông báo của Supabase, từ ngày 30/10/2026, bảng mới trong schema public không còn tự động mở cho Data API trên mọi project. Bạn phải tự grant quyền cho vai anon (người đọc chưa đăng nhập) thì trang mới đọc được. Migration thật của Tech Notes có đúng đoạn grant đó.

Chỗ nguy hiểm nằm ở đây. Khi trang báo lỗi "permission denied", cách sửa nhanh nhất là grant all on public.posts to anon. Trang chạy lại ngay, nhưng từ lúc đó, bất kỳ ai cầm khóa publishable cũng có thể sửa hoặc xóa bài viết của bạn. Cấp quyền đọc đúng cách, và chỉ cho đúng những dòng nên thấy, là việc của Row Level Security, chủ đề của bài viết tiếp theo. Bạn theo dõi cả series ở Xây blog với Next.js + Supabase, và nếu đang ôn phỏng vấn, câu hỏi về index trong database 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.