Back to blog

Build a Personal Blog — Phase 3: PostgreSQL + Drizzle ORM

nextjspostgresqldrizzletypescriptdatabase
Build a Personal Blog — Phase 3: PostgreSQL + Drizzle ORM

Đây là Phase 3 của series Build a Personal Blog. Blog Next.js của bạn đã render được MDX posts và trông rất ổn. Giờ là lúc thêm database — không phải để thay thế MDX content, mà để track dynamic data: bao nhiêu người đọc mỗi bài, ai đã subscribe newsletter, và (tùy chọn) comment của người đọc.

Series: Build a Personal Blog — Complete Roadmap
Trước: Phase 2 — MDX On-Demand Rendering
Tiếp: Phase 4 — Blog Features: Tags, Search & Pagination
Source Code: GitHub — personal-blog-phase-3


Tại Sao Lại Cần Database?

MDX files rất tốt cho blog content — có thể version-control, dễ viết, và đọc từ disk gần như không có latency. Nhưng có những dữ liệu động: chúng thay đổi mỗi khi có người truy cập trang. MDX không xử lý được điều đó.

FeatureMDX (File System)PostgreSQL (Database)
Nội dung bài viết✅ Hoàn hảo❌ Overkill
View count của bài❌ Không thể update file mỗi lượt xem✅ UPDATE post_views SET count = count + 1
Newsletter subscribers❌ Không✅ Insert, verify, query
Comment threads❌ Không✅ Insert, approve, query
Likes / reactions❌ Không✅ Upsert

Pattern này gọi là hybrid approach: MDX cho content, PostgreSQL cho dữ liệu do người dùng tạo ra hoặc dữ liệu tương tác. Hầu hết các blog và CMS nghiêm túc đều làm theo cách này.


Bạn Sẽ Build Được Gì

Sau phase này:

✅ PostgreSQL chạy local qua Docker Compose
✅ Drizzle ORM cài đặt và cấu hình xong
✅ Ba schema: post_views, subscribers, comments
✅ Migration được generate và apply với drizzle-kit
✅ CRUD helper functions trong lib/db/
✅ View counter thời gian thực trên mỗi bài viết
✅ Subscribe form hoạt động và ghi vào database

Time commitment: 2–4 giờ
Prerequisites: Phase 1: Next.js + ShadCN/UI Setup và Phase 2: MDX On-Demand Rendering


1. Chạy PostgreSQL với Docker Compose

Thay vì cài PostgreSQL trực tiếp lên máy, dùng Docker cho sạch. Dev environment sẽ giống production hơn và không làm lộn xộn máy tính của bạn.

Nếu chưa có docker-compose.yml, tạo file này ở root project:

# docker-compose.yml
services:
  db:
    image: postgres:17-alpine
    container_name: blog-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: blog_user
      POSTGRES_PASSWORD: blog_password
      POSTGRES_DB: blog_db
    ports:
      - "5432:5432"
    volumes:
      - blog_db_data:/var/lib/postgresql/data
 
volumes:
  blog_db_data:

Khởi động:

docker compose up -d db

Kiểm tra đang chạy chưa:

docker compose ps
# NAME       IMAGE               STATUS
# blog-db    postgres:17-alpine  Up 10 seconds

Kết nối vào để confirm:

docker exec -it blog-db psql -U blog_user -d blog_db
# blog_db=# \l
# blog_db=# \q

2. Cài Drizzle ORM

Tại Sao Chọn Drizzle Thay Vì Prisma / TypeORM?

Câu trả lời ngắn gọn:

Drizzle ORMPrismaTypeORM
Type safetySchema-first TypeScriptSchema-first (tự quản lý types)Decorator-based
Bundle size~30 KB~200 KB+ (engine + client)~250 KB
SQL controlSQL đầy đủ kèm type safetyÍt raw SQL escape hatchRaw SQL được nhưng phức tạp
Migrationsdrizzle-kit generate → file SQL thuầnprisma migrate devClass-based
Edge-compatible✅ Chạy trên Cloudflare/Vercel Edge⚠️ Hạn chế (Prisma Accelerate)❌ Thường không
Learning curveThấp (trông giống SQL)Trung bìnhCao

Điểm mạnh nhất của Drizzle: schema là TypeScript thuần. Không có ngôn ngữ .prisma riêng phải học. Biết SQL là biết 80% Drizzle rồi.

Cài packages

npm install drizzle-orm postgres
npm install -D drizzle-kit
  • drizzle-orm — ORM runtime
  • postgres — PostgreSQL driver (nhẹ hơn pg)
  • drizzle-kit — CLI để generate và chạy migration

3. Environment Variables

Thêm DATABASE_URL vào .env.local:

# .env.local
DATABASE_URL=postgres://blog_user:blog_password@localhost:5432/blog_db

Lưu ý khi deploy production: Trên VPS, biến này sẽ trỏ đến PostgreSQL trong Docker cùng host — hoặc đến managed service như Neon, Supabase, hay Railway. Bạn sẽ thay giá trị này khi deploy ở Phase 6.

Đảm bảo .env.local đã có trong .gitignore — create-next-app thường tự thêm sẵn rồi.


4. Cấu Hình Drizzle

Tạo drizzle.config.ts ở root project:

// drizzle.config.ts
import type { Config } from "drizzle-kit";
 
export default {
  schema: "./lib/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

Giải thích các field:

  • schema — nơi chứa định nghĩa các table
  • out — nơi Drizzle generate file SQL migration
  • dialect — dùng PostgreSQL
  • dbCredentials.url — đọc từ .env.local

5. Thiết Kế Schema

Tạo thư mục lib/db/ và các file cần thiết:

mkdir -p lib/db
touch lib/db/schema.ts
touch lib/db/index.ts

5.1 Table Post Views

// lib/db/schema.ts
import { pgTable, text, integer, timestamp, boolean, serial, varchar } from "drizzle-orm/pg-core";
 
// ───────────────────────────────────────────────
// post_views
// Mỗi bài viết có một row. view_count tăng dần mỗi lượt xem.
// ───────────────────────────────────────────────
export const postViews = pgTable("post_views", {
  id: serial("id").primaryKey(),
  slug: varchar("slug", { length: 255 }).notNull().unique(),
  viewCount: integer("view_count").notNull().default(0),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

5.2 Table Subscribers

// lib/db/schema.ts  (tiếp theo — thêm bên dưới postViews)
 
// ───────────────────────────────────────────────
// subscribers
// Danh sách email newsletter với double opt-in verification.
// ───────────────────────────────────────────────
export const subscribers = pgTable("subscribers", {
  id: serial("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  verifiedAt: timestamp("verified_at"),
  verifyToken: varchar("verify_token", { length: 64 }).notNull(),
  unsubscribeToken: varchar("unsubscribe_token", { length: 64 }).notNull(),
  isActive: boolean("is_active").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

5.3 Table Comments (Tùy Chọn)

// lib/db/schema.ts  (tiếp theo)
 
// ───────────────────────────────────────────────
// comments
// Comment của người đọc theo bài viết — cần duyệt trước khi hiển thị.
// ───────────────────────────────────────────────
export const comments = pgTable("comments", {
  id: serial("id").primaryKey(),
  slug: varchar("slug", { length: 255 }).notNull(),
  authorName: varchar("author_name", { length: 100 }).notNull(),
  authorEmail: varchar("author_email", { length: 255 }),
  content: text("content").notNull(),
  isApproved: boolean("is_approved").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

File schema đầy đủ

Đây là lib/db/schema.ts hoàn chỉnh với cả ba table:

// lib/db/schema.ts
import {
  pgTable,
  text,
  integer,
  timestamp,
  boolean,
  serial,
  varchar,
} from "drizzle-orm/pg-core";
 
export const postViews = pgTable("post_views", {
  id: serial("id").primaryKey(),
  slug: varchar("slug", { length: 255 }).notNull().unique(),
  viewCount: integer("view_count").notNull().default(0),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
 
export const subscribers = pgTable("subscribers", {
  id: serial("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  verifiedAt: timestamp("verified_at"),
  verifyToken: varchar("verify_token", { length: 64 }).notNull(),
  unsubscribeToken: varchar("unsubscribe_token", { length: 64 }).notNull(),
  isActive: boolean("is_active").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});
 
export const comments = pgTable("comments", {
  id: serial("id").primaryKey(),
  slug: varchar("slug", { length: 255 }).notNull(),
  authorName: varchar("author_name", { length: 100 }).notNull(),
  authorEmail: varchar("author_email", { length: 255 }),
  content: text("content").notNull(),
  isApproved: boolean("is_approved").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

6. Database Client

Tạo Drizzle client dưới dạng singleton — quan trọng trong Next.js vì dev server hay hot-reload:

// lib/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
 
// Tránh tạo nhiều connection khi hot-reload trong development
const globalForDb = globalThis as unknown as {
  _db: ReturnType<typeof drizzle> | undefined;
  _sql: ReturnType<typeof postgres> | undefined;
};
 
function createDb() {
  if (!process.env.DATABASE_URL) {
    throw new Error("DATABASE_URL environment variable is not set");
  }
 
  const sql = postgres(process.env.DATABASE_URL, {
    max: 10, // kích thước connection pool
  });
 
  return drizzle(sql, { schema });
}
 
export const db =
  globalForDb._db ??
  (globalForDb._db = createDb());

Tại sao dùng globalThis? Trong development, Next.js hot-reload lại các module nhưng không restart Node process. Nếu không có guard này, mỗi lần thay code sẽ mở thêm một connection pool mới và bạn sẽ nhanh chóng đụng limit connection của PostgreSQL. Pattern singleton này giữ đúng một pool cho suốt vòng đời process.


7. Chạy Migration

Sau khi đã có schema, generate file SQL migration:

npx drizzle-kit generate

Output trông như này:

Generating migration...
  → drizzle/0000_initial_schema.sql

Xem SQL được generate — sẽ trông như sau:

-- drizzle/0000_initial_schema.sql
CREATE TABLE IF NOT EXISTS "post_views" (
  "id" serial PRIMARY KEY NOT NULL,
  "slug" varchar(255) NOT NULL,
  "view_count" integer DEFAULT 0 NOT NULL,
  "created_at" timestamp DEFAULT now() NOT NULL,
  "updated_at" timestamp DEFAULT now() NOT NULL,
  CONSTRAINT "post_views_slug_unique" UNIQUE("slug")
);
 
CREATE TABLE IF NOT EXISTS "subscribers" (
  "id" serial PRIMARY KEY NOT NULL,
  "email" varchar(255) NOT NULL,
  "verified_at" timestamp,
  "verify_token" varchar(64) NOT NULL,
  "unsubscribe_token" varchar(64) NOT NULL,
  "is_active" boolean DEFAULT false NOT NULL,
  "created_at" timestamp DEFAULT now() NOT NULL,
  CONSTRAINT "subscribers_email_unique" UNIQUE("email")
);
 
CREATE TABLE IF NOT EXISTS "comments" (
  "id" serial PRIMARY KEY NOT NULL,
  "slug" varchar(255) NOT NULL,
  "author_name" varchar(100) NOT NULL,
  "author_email" varchar(255),
  "content" text NOT NULL,
  "is_approved" boolean DEFAULT false NOT NULL,
  "created_at" timestamp DEFAULT now() NOT NULL
);

Apply migration vào database local:

npx drizzle-kit migrate

Xác nhận các table đã được tạo:

docker exec -it blog-db psql -U blog_user -d blog_db -c "\dt"
#            List of relations
#  Schema |    Name     | Type  |   Owner
# --------+-------------+-------+-----------
#  public | comments    | table | blog_user
#  public | post_views  | table | blog_user
#  public | subscribers | table | blog_user

Thêm các migration script vào package.json cho tiện:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "db:push": "drizzle-kit push"
  }
}
  • db:generate — tạo file SQL migration mới từ thay đổi schema
  • db:migrate — apply các migration chưa chạy
  • db:studio — mở Drizzle Studio (database browser tại http://localhost:4983)
  • db:push — bỏ qua migration, push schema trực tiếp (chỉ dùng khi dev)

8. CRUD Helpers

Tạo lib/db/queries.ts với tất cả các database operation mà blog cần:

// lib/db/queries.ts
import { eq, desc, and, sql } from "drizzle-orm";
import { db } from "./index";
import { postViews, subscribers, comments } from "./schema";
import crypto from "crypto";
 
// ─────────────────────────────────────────────────────────────────────────────
// Post Views
// ─────────────────────────────────────────────────────────────────────────────
 
/**
 * Lấy view count hiện tại của một bài viết.
 * Trả về 0 nếu bài chưa được xem lần nào.
 */
export async function getPostViewCount(slug: string): Promise<number> {
  const result = await db
    .select({ viewCount: postViews.viewCount })
    .from(postViews)
    .where(eq(postViews.slug, slug))
    .limit(1);
 
  return result[0]?.viewCount ?? 0;
}
 
/**
 * Tăng view count thêm 1.
 * Tự tạo row nếu chưa tồn tại (upsert pattern).
 */
export async function incrementPostView(slug: string): Promise<void> {
  await db
    .insert(postViews)
    .values({ slug, viewCount: 1 })
    .onConflictDoUpdate({
      target: postViews.slug,
      set: {
        viewCount: sql`${postViews.viewCount} + 1`,
        updatedAt: new Date(),
      },
    });
}
 
// ─────────────────────────────────────────────────────────────────────────────
// Newsletter Subscribers
// ─────────────────────────────────────────────────────────────────────────────
 
function generateToken(): string {
  return crypto.randomBytes(32).toString("hex");
}
 
/**
 * Tạo subscriber mới (mặc định chưa verified).
 * Trả về verification token để gửi qua email.
 */
export async function createSubscriber(
  email: string
): Promise<{ verifyToken: string; unsubscribeToken: string } | null> {
  const verifyToken = generateToken();
  const unsubscribeToken = generateToken();
 
  try {
    await db.insert(subscribers).values({
      email,
      verifyToken,
      unsubscribeToken,
      isActive: false,
    });
    return { verifyToken, unsubscribeToken };
  } catch (error) {
    // Email đã tồn tại
    return null;
  }
}
 
/**
 * Đánh dấu subscriber là đã verified và active.
 */
export async function verifySubscriber(verifyToken: string): Promise<boolean> {
  const result = await db
    .update(subscribers)
    .set({ isActive: true, verifiedAt: new Date() })
    .where(
      and(
        eq(subscribers.verifyToken, verifyToken),
        eq(subscribers.isActive, false)
      )
    )
    .returning({ id: subscribers.id });
 
  return result.length > 0;
}
 
/**
 * Hủy kích hoạt subscriber (unsubscribe).
 */
export async function unsubscribeByToken(
  unsubscribeToken: string
): Promise<boolean> {
  const result = await db
    .update(subscribers)
    .set({ isActive: false })
    .where(eq(subscribers.unsubscribeToken, unsubscribeToken))
    .returning({ id: subscribers.id });
 
  return result.length > 0;
}
 
/**
 * Lấy tất cả subscriber đang active (đã verified) để gửi newsletter.
 */
export async function getActiveSubscribers(): Promise<
  { email: string; unsubscribeToken: string }[]
> {
  return db
    .select({ email: subscribers.email, unsubscribeToken: subscribers.unsubscribeToken })
    .from(subscribers)
    .where(eq(subscribers.isActive, true));
}
 
// ─────────────────────────────────────────────────────────────────────────────
// Comments
// ─────────────────────────────────────────────────────────────────────────────
 
/**
 * Lấy tất cả comment đã được duyệt của một bài viết.
 */
export async function getApprovedComments(slug: string) {
  return db
    .select()
    .from(comments)
    .where(and(eq(comments.slug, slug), eq(comments.isApproved, true)))
    .orderBy(desc(comments.createdAt));
}
 
/**
 * Gửi comment mới (cần được duyệt trước khi hiển thị).
 */
export async function createComment(data: {
  slug: string;
  authorName: string;
  authorEmail?: string;
  content: string;
}): Promise<void> {
  await db.insert(comments).values({
    ...data,
    isApproved: false, // luôn pending moderation
  });
}

9. View Counter cho Bài Viết

Bây giờ kết nối mọi thứ lại để mỗi bài viết tự động ghi nhận lượt xem.

9.1 API Route

Tạo API route xử lý việc tăng view count:

// app/api/views/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getPostViewCount, incrementPostView } from "@/lib/db/queries";
 
export async function GET(
  _req: NextRequest,
  { params }: { params: { slug: string } }
) {
  const { slug } = params;
  const count = await getPostViewCount(slug);
  return NextResponse.json({ views: count });
}
 
export async function POST(
  _req: NextRequest,
  { params }: { params: { slug: string } }
) {
  const { slug } = params;
  await incrementPostView(slug);
  const count = await getPostViewCount(slug);
  return NextResponse.json({ views: count });
}

9.2 Component ViewCounter

Tạo client component gọi API và hiển thị số lượt xem:

// components/ViewCounter.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Eye } from "lucide-react";
 
interface ViewCounterProps {
  slug: string;
  shouldCount?: boolean; // false trên trang danh sách, true trên trang chi tiết
}
 
export function ViewCounter({ slug, shouldCount = false }: ViewCounterProps) {
  const [views, setViews] = useState<number | null>(null);
 
  useEffect(() => {
    const method = shouldCount ? "POST" : "GET";
 
    fetch(`/api/views/${slug}`, { method })
      .then((res) => res.json())
      .then((data) => setViews(data.views))
      .catch(() => setViews(null));
  }, [slug, shouldCount]);
 
  if (views === null) return null;
 
  return (
    <span className="flex items-center gap-1 text-sm text-muted-foreground">
      <Eye className="h-4 w-4" />
      {views.toLocaleString()} lượt xem
    </span>
  );
}

9.3 Thêm ViewCounter vào Trang Bài Viết

Trong trang chi tiết bài viết, thêm ViewCounter với shouldCount={true}:

// app/blog/[slug]/page.tsx  (một phần)
import { ViewCounter } from "@/components/ViewCounter";
 
// Trong JSX của component:
<div className="flex items-center gap-4 text-sm text-muted-foreground">
  <time dateTime={post.frontmatter.date}>
    {formatDate(post.frontmatter.date)}
  </time>
  <ViewCounter slug={params.slug} shouldCount={true} />
</div>

Trên trang danh sách bài viết, hiển thị view count nhưng không tăng:

// Trong PostCard.tsx hoặc nơi render các post card:
<ViewCounter slug={post.slug} shouldCount={false} />

9.4 Test thử

  1. Mở một bài viết trên trình duyệt
  2. Kiểm tra terminal — sẽ thấy request POST /api/views/[slug]
  3. Refresh trang — count phải là 2
  4. Kiểm tra trực tiếp trong database:
docker exec -it blog-db psql -U blog_user -d blog_db \
  -c "SELECT slug, view_count FROM post_views;"
#          slug          | view_count
# -----------------------+------------
#  my-first-post         |          2

10. Subscribe Form cho Newsletter

Thêm form subscribe đơn giản để insert vào table subscribers.

10.1 API Route

// app/api/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSubscriber } from "@/lib/db/queries";
 
export async function POST(req: NextRequest) {
  const { email } = await req.json();
 
  if (!email || !email.includes("@")) {
    return NextResponse.json({ error: "Email không hợp lệ" }, { status: 400 });
  }
 
  const result = await createSubscriber(email);
 
  if (!result) {
    // Email đã đăng ký — không tiết lộ để bảo vệ privacy
    return NextResponse.json({ message: "Kiểm tra hộp thư của bạn nhé!" });
  }
 
  // TODO Phase 5: gửi email verification với result.verifyToken
  // Tạm thời log token ra để test local
  console.log(`Verification token cho ${email}: ${result.verifyToken}`);
 
  return NextResponse.json({ message: "Kiểm tra hộp thư của bạn nhé!" });
}

10.2 Component SubscribeForm

// components/SubscribeForm.tsx
"use client";
 
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
 
export function SubscribeForm() {
  const [email, setEmail] = useState("");
  const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
  const [message, setMessage] = useState("");
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setStatus("loading");
 
    try {
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });
 
      const data = await res.json();
 
      if (res.ok) {
        setStatus("success");
        setMessage(data.message);
        setEmail("");
      } else {
        setStatus("error");
        setMessage(data.error ?? "Có lỗi xảy ra. Thử lại nhé.");
      }
    } catch {
      setStatus("error");
      setMessage("Lỗi mạng. Vui lòng thử lại.");
    }
  }
 
  return (
    <div className="rounded-xl border bg-card p-6 shadow-sm">
      <h3 className="text-lg font-semibold">Nhận bài viết mới</h3>
      <p className="mt-1 text-sm text-muted-foreground">
        Bài viết mới gửi thẳng vào inbox. Không spam, unsubscribe bất cứ lúc nào.
      </p>
 
      {status === "success" ? (
        <p className="mt-4 text-sm font-medium text-green-600">
          ✅ {message}
        </p>
      ) : (
        <form onSubmit={handleSubmit} className="mt-4 flex gap-2">
          <Input
            type="email"
            placeholder="[email protected]"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            disabled={status === "loading"}
            className="flex-1"
          />
          <Button type="submit" disabled={status === "loading"}>
            {status === "loading" ? "Đang đăng ký…" : "Đăng ký"}
          </Button>
        </form>
      )}
 
      {status === "error" && (
        <p className="mt-2 text-sm text-destructive">{message}</p>
      )}
    </div>
  );
}

Thêm SubscribeForm vào trang bài viết hoặc footer — tùy bạn muốn người đọc thấy nó ở đâu.


11. Drizzle Studio

Drizzle có sẵn một database browser chạy trên trình duyệt. Mở thêm terminal và chạy:

npm run db:studio

Vào https://local.drizzle.studio để xem table, row, và chạy query trực tiếp. Cực kỳ tiện để debug data trong khi dev.


12. Cấu Trúc Project Sau Phase 3

Project bây giờ trông như thế này:

my-blog/
├── app/
│   ├── api/
│   │   ├── views/
│   │   │   └── [slug]/
│   │   │       └── route.ts       ← view count API
│   │   └── subscribe/
│   │       └── route.ts           ← newsletter subscription API
│   └── blog/
│       └── [slug]/
│           └── page.tsx
├── components/
│   ├── ViewCounter.tsx             ← MỚI
│   └── SubscribeForm.tsx           ← MỚI
├── drizzle/
│   └── 0000_initial_schema.sql    ← migration được generate
├── lib/
│   └── db/
│       ├── index.ts               ← Drizzle client
│       ├── schema.ts              ← định nghĩa table
│       └── queries.ts             ← CRUD helpers
├── docker-compose.yml
├── drizzle.config.ts
└── .env.local

Các Lỗi Thường Gặp

DATABASE_URL is not set

Chắc chắn bạn đang dùng .env.local (không phải .env) và đã restart Next.js dev server sau khi thêm biến này.

connection refused đến PostgreSQL

Kiểm tra Docker đang chạy và container đang up:

docker compose ps
docker compose up -d db

relation "post_views" does not exist

Bạn đã generate migration nhưng chưa apply. Chạy:

npm run db:migrate

Type error trên sql helper

Chắc chắn import sql từ drizzle-orm, không phải từ postgres:

import { eq, desc, and, sql } from "drizzle-orm"; // ✅

Hot-reload tạo quá nhiều DB connection

Đây là lý do cần pattern singleton với globalThis. Nếu vẫn thấy cảnh báo connection, thêm giới hạn max vào postgres() client:

const sql = postgres(process.env.DATABASE_URL!, { max: 5 });

Tổng Kết

Trong phase này bạn đã:

✅ Khởi chạy PostgreSQL local bằng Docker Compose
✅ Cài Drizzle ORM và postgres driver
✅ Thiết kế ba schema: post_views, subscribers, comments
✅ Generate và apply SQL migration với drizzle-kit
✅ Tạo singleton DB client an toàn cho hot-reload của Next.js
✅ Viết CRUD helper functions trong lib/db/queries.ts
✅ Thêm view counter thời gian thực vào mỗi bài viết
✅ Kết nối newsletter subscribe form với API route

Hybrid approach MDX + PostgreSQL cho bạn tận dụng lợi thế của cả hai: nội dung dạng prose được version-control trong MDX, và dữ liệu động của người dùng trong database thực sự — không cần overhead của một CMS phức tạp.


Tiếp Theo Là Gì?

Trong Phase 4, bạn sẽ thêm tag filtering, full-text search, và pagination vào trang danh sách bài viết — những feature mà người đọc thực sự dùng để tìm nội dung.

Bài tiếp: Phase 4 — Blog Features: Tags, Search & Pagination


Mục Lục Series

BàiTiêu đềTrạng thái
BLOG-1Xây Dựng Blog Cá Nhân — Lộ Trình✅ Hoàn thành
BLOG-2Phase 1: Khởi Tạo Project — Next.js 16 + ShadCN/UI✅ Hoàn thành
BLOG-3Phase 2: Render MDX Theo Yêu Cầu✅ Hoàn thành
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Bạn đang ở đây
BLOG-5Phase 4: Tags, Search & Pagination✅ Hoàn thành
BLOG-6Phase 5: Docker Compose✅ Hoàn thành
BLOG-7Phase 6: Deploy lên Ubuntu VPS trên Hostinger✅ Hoàn thành
BLOG-8Phase 7: Thiết Lập Domain trên Hostinger✅ Hoàn thành

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.