Hướng Dẫn CRUD Full-Stack với Next.js, Drizzle ORM và Neon PostgreSQL

Hướng dẫn chi tiết xây dựng ứng dụng CRUD full-stack với Next.js 16, Drizzle ORM và Neon PostgreSQL. Bao gồm Server Actions, Zod validation, Relational Queries và deploy lên Vercel với code mẫu hoàn chỉnh.

Tại sao Drizzle ORM + Neon PostgreSQL là combo lý tưởng cho Next.js

Nếu bạn đang xây dựng một ứng dụng full-stack với Next.js App Router, câu hỏi đầu tiên chắc chắn sẽ là: chọn ORM nào và kết nối database ra sao? Mình đã thử qua kha khá lựa chọn, và nói thật là đến năm 2026, câu trả lời ngày càng rõ ràng: Drizzle ORM + Neon PostgreSQL là một trong những combo mạnh mẽ và phù hợp nhất cho hệ sinh thái Next.js hiện tại.

Drizzle ORM là một TypeScript ORM siêu nhẹ, cú pháp gần giống SQL thuần, type-safe 100%. Và đặc biệt — nó chạy ngon lành trên mọi serverless/edge runtime mà không cần adapter bổ sung. Cái này quan trọng hơn bạn tưởng đấy. Còn Neon PostgreSQL thì là nền tảng serverless Postgres hàng đầu, hỗ trợ autoscaling, database branching giống Git, và cold start gần như không đáng kể nhờ PgBouncer tích hợp sẵn.

Trong bài viết này, mình sẽ hướng dẫn bạn xây dựng một ứng dụng quản lý bài viết (blog) full-stack hoàn chỉnh — từ thiết lập database, định nghĩa schema, đến CRUD operations với Server Actions và Zod validation. Mọi thứ đều cập nhật theo Next.js 16 và Drizzle ORM phiên bản mới nhất, nên bạn yên tâm là không bị outdated nhé.

Thiết lập dự án và cài đặt dependencies

Khởi tạo dự án Next.js 16

Nào, cùng bắt đầu thôi. Tạo một dự án Next.js mới với App Router:

npx create-next-app@latest my-blog-app
cd my-blog-app

Khi được hỏi thì chọn: TypeScript — Yes, ESLint — Yes, Tailwind CSS — Yes, App Router — Yes. Cơ bản là Yes hết.

Cài đặt Drizzle ORM và các dependencies

Tiếp theo, cài Drizzle ORM cùng driver PostgreSQL và mấy công cụ cần thiết:

# Drizzle ORM và PostgreSQL driver
npm install drizzle-orm @neondatabase/serverless

# Drizzle Kit cho migration (devDependency)
npm install -D drizzle-kit

# Zod cho validation
npm install zod

Drizzle ORM sử dụng @neondatabase/serverless làm driver kết nối — đây là driver chuyên dụng của Neon, tối ưu cho serverless với hỗ trợ cả HTTP và WebSocket. Nó là drop-in replacement cho node-postgres nên bạn không cần lo về compatibility. Thay thế thẳng, không đau đầu gì cả.

Tạo database trên Neon

Truy cập neon.tech, tạo tài khoản miễn phí và tạo một project mới. Quy trình khá nhanh — mình nhớ lần đầu setup chưa tới 2 phút. Neon sẽ cung cấp cho bạn connection string có dạng:

postgresql://username:[email protected]/neondb?sslmode=require

Lưu connection string này vào file .env.local ở root dự án:

# .env.local
DATABASE_URL="postgresql://username:[email protected]/neondb?sslmode=require"

Định nghĩa database schema với Drizzle

Cấu trúc thư mục khuyến nghị

Trước khi viết schema, hãy tổ chức thư mục dự án cho gọn gàng đã. Theo kinh nghiệm của mình thì cấu trúc này hoạt động khá ổn:

src/
├── app/
│   ├── page.tsx
│   ├── posts/
│   │   ├── page.tsx
│   │   ├── new/
│   │   │   └── page.tsx
│   │   └── [id]/
│   │       ├── page.tsx
│   │       └── edit/
│   │           └── page.tsx
├── db/
│   ├── index.ts          # Database connection
│   └── schema.ts         # Schema definitions
├── lib/
│   └── actions.ts        # Server Actions
└── components/
    └── PostForm.tsx       # Form component

Tạo schema cho bảng posts

File schema là nơi bạn định nghĩa cấu trúc database bằng TypeScript. Thú thật, mình khá thích cách Drizzle làm việc này — nó dùng các hàm như pgTable() để mô tả bảng, cú pháp trực quan và gần giống SQL nên ai từng viết SQL đều thấy quen thuộc ngay:

// src/db/schema.ts
import {
  pgTable,
  serial,
  varchar,
  text,
  timestamp,
  boolean,
} from "drizzle-orm/pg-core";

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  slug: varchar("slug", { length: 255 }).notNull().unique(),
  content: text("content").notNull(),
  excerpt: varchar("excerpt", { length: 500 }),
  published: boolean("published").default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Điểm hay của Drizzle là schema TypeScript này chính là single source of truth. Drizzle tự động suy ra TypeScript types từ schema, nên bạn không cần khai báo interface riêng. Ít boilerplate hơn = ít bug hơn.

Thiết lập kết nối database

Tạo file kết nối database sử dụng Neon serverless driver. Ngắn gọn thôi:

// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);

export const db = drizzle(sql, { schema });

À mà có một điểm khác biệt quan trọng cần chú ý: với Neon, bạn dùng drizzle-orm/neon-http thay vì drizzle-orm/node-postgres. Driver HTTP của Neon tối ưu cho serverless vì mỗi query là một HTTP request độc lập — không cần duy trì persistent connection. Nghe đơn giản nhưng đây là khác biệt cốt lõi khi chạy trên serverless.

Cấu hình Drizzle Kit và chạy migration

Tạo file cấu hình Drizzle Kit ở root dự án:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Rồi chạy migration để tạo bảng trong database:

# Tạo file migration từ schema
npx drizzle-kit generate

# Áp dụng migration vào database
npx drizzle-kit migrate

Drizzle Kit sẽ so sánh schema hiện tại trong code với trạng thái database thực tế, rồi tạo file migration SQL tương ứng. Mình hay kiểm tra file migration được tạo trong thư mục ./drizzle trước khi apply — phòng trường hợp có gì không đúng ý thì sửa kịp.

Xây dựng Server Actions cho CRUD operations

Validation schema với Zod

Trước khi viết Server Actions, hãy định nghĩa validation schema đã. Đây là bước bắt buộc — đừng bỏ qua nhé. Nhớ rằng Server Actions thực chất là HTTP endpoints công khai, nên mọi input đều phải được validate phía server (dù bạn đã validate ở client rồi cũng vậy):

// src/lib/validations.ts
import { z } from "zod";

export const postSchema = z.object({
  title: z
    .string()
    .min(5, "Tiêu đề phải có ít nhất 5 ký tự")
    .max(255, "Tiêu đề không được vượt quá 255 ký tự"),
  content: z
    .string()
    .min(20, "Nội dung phải có ít nhất 20 ký tự"),
  excerpt: z
    .string()
    .max(500, "Tóm tắt không được vượt quá 500 ký tự")
    .optional(),
  published: z.boolean().default(false),
});

export type PostInput = z.infer<typeof postSchema>;

Server Actions hoàn chỉnh

OK, bây giờ là phần chính — viết Server Actions cho tất cả CRUD operations. Phần này hơi dài nhưng mỗi action đều có validation, error handling, và revalidation đầy đủ:

// src/lib/actions.ts
"use server";

import { db } from "@/db";
import { posts } from "@/db/schema";
import { postSchema } from "@/lib/validations";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";

// Helper tạo slug từ title
function createSlug(title: string): string {
  return title
    .toLowerCase()
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .replace(/đ/g, "d")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

// === CREATE ===
export async function createPost(formData: FormData) {
  const rawData = {
    title: formData.get("title") as string,
    content: formData.get("content") as string,
    excerpt: formData.get("excerpt") as string || undefined,
    published: formData.get("published") === "on",
  };

  const validated = postSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
    };
  }

  const slug = createSlug(validated.data.title);

  await db.insert(posts).values({
    ...validated.data,
    slug,
  });

  revalidatePath("/posts");
  redirect("/posts");
}

// === READ (danh sách) ===
export async function getPosts() {
  return await db
    .select()
    .from(posts)
    .orderBy(posts.createdAt);
}

// === READ (chi tiết) ===
export async function getPostById(id: number) {
  const result = await db
    .select()
    .from(posts)
    .where(eq(posts.id, id))
    .limit(1);

  return result[0] ?? null;
}

// === UPDATE ===
export async function updatePost(id: number, formData: FormData) {
  const rawData = {
    title: formData.get("title") as string,
    content: formData.get("content") as string,
    excerpt: formData.get("excerpt") as string || undefined,
    published: formData.get("published") === "on",
  };

  const validated = postSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
    };
  }

  const slug = createSlug(validated.data.title);

  await db
    .update(posts)
    .set({
      ...validated.data,
      slug,
      updatedAt: new Date(),
    })
    .where(eq(posts.id, id));

  revalidatePath("/posts");
  revalidatePath(`/posts/${id}`);
  redirect("/posts");
}

// === DELETE ===
export async function deletePost(id: number) {
  await db.delete(posts).where(eq(posts.id, id));

  revalidatePath("/posts");
  redirect("/posts");
}

Vài điểm mình muốn nhấn mạnh trong đoạn code trên:

  • Mọi Server Action đều validate input bằng postSchema.safeParse() trước khi chạm đến database. Đây không chỉ là best practice — mà là yêu cầu bảo mật bắt buộc. Mình đã thấy không ít dự án bỏ qua bước này rồi hối hận.
  • revalidatePath() được gọi sau mỗi mutation để xóa cache của trang liên quan. Nếu thiếu bước này, UI sẽ hiển thị dữ liệu cũ và bạn sẽ ngồi debug mất nửa ngày tự hỏi tại sao data không cập nhật (hỏi thật, mình từng bị).
  • redirect() được gọi sau revalidatePath() — thứ tự này quan trọng. Nếu đảo ngược, cache sẽ không được cập nhật trước khi chuyển trang.

Xây dựng giao diện với React Server Components

Trang danh sách bài viết

Với Next.js App Router, Server Components là mặc định. Điều này có nghĩa là bạn có thể gọi database trực tiếp trong component mà không cần viết API route nào cả. Thực ra thì đây là một trong những điểm mình thích nhất ở App Router:

// src/app/posts/page.tsx
import Link from "next/link";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { desc } from "drizzle-orm";
import { deletePost } from "@/lib/actions";

export default async function PostsPage() {
  const allPosts = await db
    .select()
    .from(posts)
    .orderBy(desc(posts.createdAt));

  return (
    <main className="max-w-4xl mx-auto p-6">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">Bài viết</h1>
        <Link
          href="/posts/new"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg"
        >
          Tạo bài mới
        </Link>
      </div>

      {allPosts.length === 0 ? (
        <p className="text-gray-500">Chưa có bài viết nào.</p>
      ) : (
        <div className="space-y-4">
          {allPosts.map((post) => (
            <article
              key={post.id}
              className="border rounded-lg p-4"
            >
              <div className="flex justify-between items-start">
                <div>
                  <Link href={`/posts/${post.id}`}>
                    <h2 className="text-xl font-semibold hover:text-blue-600">
                      {post.title}
                    </h2>
                  </Link>
                  {post.excerpt && (
                    <p className="text-gray-600 mt-1">{post.excerpt}</p>
                  )}
                  <span className="text-sm text-gray-400">
                    {post.createdAt.toLocaleDateString("vi-VN")}
                  </span>
                </div>
                <div className="flex gap-2">
                  <Link
                    href={`/posts/${post.id}/edit`}
                    className="text-blue-600 hover:underline text-sm"
                  >
                    Sửa
                  </Link>
                  <form action={deletePost.bind(null, post.id)}>
                    <button
                      type="submit"
                      className="text-red-600 hover:underline text-sm"
                    >
                      Xóa
                    </button>
                  </form>
                </div>
              </div>
            </article>
          ))}
        </div>
      )}
    </main>
  );
}

Ở đây, deletePost.bind(null, post.id) là cách truyền argument vào Server Action khi dùng với <form action>. Đây là pattern được Next.js khuyến nghị chính thức — nhìn hơi lạ lần đầu nhưng quen rồi thì thấy rất gọn.

Form component với Client Component và useActionState

Form cần interactivity (hiển thị validation errors, trạng thái loading), nên bắt buộc phải là Client Component. Không có cách nào khác.

// src/components/PostForm.tsx
"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-6 py-2 rounded-lg
                 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {pending ? "Đang lưu..." : "Lưu bài viết"}
    </button>
  );
}

type FormState = {
  errors?: {
    title?: string[];
    content?: string[];
    excerpt?: string[];
  };
} | undefined;

export default function PostForm({
  action,
  initialData,
}: {
  action: (formData: FormData) => Promise<FormState>;
  initialData?: {
    title: string;
    content: string;
    excerpt: string | null;
    published: boolean;
  };
}) {
  const [state, formAction] = useActionState(
    async (_prev: FormState, formData: FormData) => {
      return await action(formData);
    },
    undefined
  );

  return (
    <form action={formAction} className="space-y-6 max-w-2xl">
      <div>
        <label htmlFor="title" className="block font-medium mb-1">
          Tiêu đề
        </label>
        <input
          id="title"
          name="title"
          type="text"
          defaultValue={initialData?.title}
          className="w-full border rounded-lg px-3 py-2"
        />
        {state?.errors?.title && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.title[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="excerpt" className="block font-medium mb-1">
          Tóm tắt
        </label>
        <input
          id="excerpt"
          name="excerpt"
          type="text"
          defaultValue={initialData?.excerpt ?? ""}
          className="w-full border rounded-lg px-3 py-2"
        />
        {state?.errors?.excerpt && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.excerpt[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="content" className="block font-medium mb-1">
          Nội dung
        </label>
        <textarea
          id="content"
          name="content"
          rows={12}
          defaultValue={initialData?.content}
          className="w-full border rounded-lg px-3 py-2"
        />
        {state?.errors?.content && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.content[0]}
          </p>
        )}
      </div>

      <div className="flex items-center gap-2">
        <input
          id="published"
          name="published"
          type="checkbox"
          defaultChecked={initialData?.published}
        />
        <label htmlFor="published">Xuất bản ngay</label>
      </div>

      <SubmitButton />
    </form>
  );
}

Component này dùng hai hook quan trọng của React 19:

  • useActionState: quản lý state trả về từ Server Action, ví dụ validation errors. Nếu bạn từng dùng useFormState thì đây là phiên bản thay thế — tên mới, API gần giống.
  • useFormStatus: cung cấp trạng thái pending khi form đang submit. Mình dùng nó để disable nút submit và hiển thị loading indicator — UX nhỏ nhưng tạo sự khác biệt lớn.

Trang tạo và chỉnh sửa bài viết

Trang tạo bài mới thì đơn giản lắm — chỉ render PostForm với action tương ứng là xong:

// src/app/posts/new/page.tsx
import PostForm from "@/components/PostForm";
import { createPost } from "@/lib/actions";

export default function NewPostPage() {
  return (
    <main className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Tạo bài viết mới</h1>
      <PostForm action={createPost} />
    </main>
  );
}

Trang chỉnh sửa thì phức tạp hơn một chút — cần fetch dữ liệu hiện có rồi truyền vào form:

// src/app/posts/[id]/edit/page.tsx
import { notFound } from "next/navigation";
import PostForm from "@/components/PostForm";
import { getPostById, updatePost } from "@/lib/actions";

export default async function EditPostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await getPostById(Number(id));

  if (!post) notFound();

  const updateWithId = async (formData: FormData) => {
    "use server";
    return updatePost(Number(id), formData);
  };

  return (
    <main className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Chỉnh sửa bài viết</h1>
      <PostForm action={updateWithId} initialData={post} />
    </main>
  );
}

Pattern updateWithId ở trên là cách bind id vào Server Action mà vẫn giữ được sự đơn giản của form component. Cái inline "use server" directive cho phép bạn tạo server action ngay bên trong Server Component — khá tiện, không cần tách file riêng.

Relational Queries — truy vấn dữ liệu quan hệ

Mở rộng schema với bảng comments

Ứng dụng thực tế thì hiếm khi chỉ có một bảng. Hãy thêm bảng comments và thiết lập quan hệ để xem Drizzle xử lý relational data như thế nào:

// src/db/schema.ts (mở rộng)
import {
  pgTable,
  serial,
  varchar,
  text,
  timestamp,
  boolean,
  integer,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  slug: varchar("slug", { length: 255 }).notNull().unique(),
  content: text("content").notNull(),
  excerpt: varchar("excerpt", { length: 500 }),
  published: boolean("published").default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const comments = pgTable("comments", {
  id: serial("id").primaryKey(),
  postId: integer("post_id")
    .references(() => posts.id, { onDelete: "cascade" })
    .notNull(),
  author: varchar("author", { length: 100 }).notNull(),
  body: text("body").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

// Định nghĩa relations
export const postsRelations = relations(posts, ({ many }) => ({
  comments: many(comments),
}));

export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
}));

Sử dụng Relational Queries API

Đây là phần mình thấy Drizzle tỏa sáng. Relational Queries cho phép bạn truy vấn dữ liệu lồng nhau chỉ với một câu SQL duy nhất — không cần viết JOIN thủ công, không cần N+1 query:

// Lấy bài viết kèm comments
const postWithComments = await db.query.posts.findFirst({
  where: eq(posts.id, postId),
  with: {
    comments: {
      orderBy: desc(comments.createdAt),
    },
  },
});

// Kết quả có type-safe structure:
// {
//   id: 1,
//   title: "Bài viết đầu tiên",
//   comments: [
//     { id: 1, author: "Minh", body: "Hay quá!", ... },
//     { id: 2, author: "Lan", body: "Cảm ơn bạn", ... },
//   ]
// }

Drizzle đảm bảo luôn chỉ tạo một câu SQL duy nhất cho mỗi relational query, bất kể bạn lồng bao nhiêu cấp with. Ít roundtrip hơn nghĩa là chi phí và latency thấp hơn — điều cực kỳ quan trọng khi chạy serverless vì bạn trả tiền theo từng request.

Tối ưu cho production

Xử lý error gracefully

Nói thật là khi dev thì để app crash cũng chẳng sao, nhưng production thì khác. Bạn cần bắt lỗi database và trả về thông báo phù hợp cho user:

// Cải tiến createPost với try-catch
export async function createPost(formData: FormData) {
  const rawData = {
    title: formData.get("title") as string,
    content: formData.get("content") as string,
    excerpt: (formData.get("excerpt") as string) || undefined,
    published: formData.get("published") === "on",
  };

  const validated = postSchema.safeParse(rawData);
  if (!validated.success) {
    return { errors: validated.error.flatten().fieldErrors };
  }

  const slug = createSlug(validated.data.title);

  try {
    await db.insert(posts).values({
      ...validated.data,
      slug,
    });
  } catch (error) {
    if (
      error instanceof Error &&
      error.message.includes("unique")
    ) {
      return {
        errors: { title: ["Tiêu đề này đã tồn tại, vui lòng chọn tiêu đề khác"] },
      };
    }
    return {
      errors: { title: ["Đã xảy ra lỗi, vui lòng thử lại sau"] },
    };
  }

  revalidatePath("/posts");
  redirect("/posts");
}

Đoạn check error.message.includes("unique") ở trên hơi "thô" nhưng hoạt động tốt cho hầu hết trường hợp. Trong dự án lớn hơn, bạn có thể muốn check error code cụ thể của PostgreSQL.

Sử dụng Drizzle với Cache Components trong Next.js 16

Nếu bạn đã bật cacheComponents: true trong next.config.ts (xem bài viết về PPR), bạn có thể kết hợp Drizzle queries với directive "use cache" để cache kết quả truy vấn ở cấp component:

// Component được cache — thuộc static shell
async function PostList() {
  "use cache";

  const allPosts = await db
    .select()
    .from(posts)
    .where(eq(posts.published, true))
    .orderBy(desc(posts.createdAt));

  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Khi kết hợp với revalidateTag() hoặc revalidatePath(), bạn kiểm soát chính xác khi nào cache được làm mới. Kết quả là trang vừa nhanh (nhờ cache) vừa luôn fresh khi cần (nhờ revalidation theo yêu cầu). Win-win.

Deploy lên Vercel

Phần deploy thì dễ thở rồi. Neon và Vercel tích hợp rất tốt với nhau nên quy trình khá suôn sẻ:

  1. Push code lên GitHub và kết nối repo với Vercel.
  2. Thêm environment variable DATABASE_URL trong Vercel dashboard (Settings → Environment Variables). Đừng quên bước này — mình từng deploy xong rồi mới nhận ra quên set env variable.
  3. Vercel tự động build và deploy. Một điểm cộng lớn của Drizzle: không cần generate step bổ sung như Prisma — schema TypeScript đã là đủ.

À mà mình muốn chia sẻ thêm một mẹo hay: Neon có tính năng database branching. Bạn có thể tạo branch database cho mỗi preview deployment trên Vercel — dữ liệu hoàn toàn tách biệt, giống như Git branch cho code vậy. Cực kỳ hữu ích khi test tính năng mới mà không sợ ảnh hưởng production data.

So sánh nhanh Drizzle vs Prisma cho Next.js

Nếu bạn đang phân vân giữa Drizzle và Prisma (mình biết nhiều bạn đang ở giai đoạn này), đây là so sánh tóm tắt theo trải nghiệm thực tế:

  • Bundle size: Drizzle nhỏ hơn đáng kể. Prisma cần engine binary riêng — cái này có thể khiến bundle phình to.
  • Edge Runtime: Drizzle chạy native, không cần gì thêm. Prisma thì phải qua Prisma Accelerate.
  • Cú pháp: Drizzle gần SQL hơn, nên nếu bạn quen SQL thì sẽ thấy tự nhiên. Prisma trừu tượng hơn và có thể dễ tiếp cận hơn cho người mới bắt đầu.
  • Migration: Cả hai đều có công cụ migration tốt. Prisma tự động hơn, Drizzle cho bạn nhiều kiểm soát hơn (bạn xem được file SQL trước khi apply).
  • Type safety: Ngang nhau — cả hai đều type-safe xuất sắc. Không phân biệt được.
  • Xu hướng 2026: Drizzle đang tăng trưởng rất nhanh với 25,000+ GitHub stars và được community ưa chuộng cho serverless. Nhưng Prisma cũng không hề đứng yên.

Lời khuyên thành thật: nếu team bạn ưu tiên hiệu năng, bundle size nhỏ, và triển khai serverless/edge — chọn Drizzle. Nếu ưu tiên DX đơn giản, migration tự động, và có sẵn nhiều tài liệu hướng dẫn — Prisma vẫn là lựa chọn rất tốt. Không có câu trả lời "đúng" tuyệt đối ở đây.

FAQ — Câu hỏi thường gặp

Drizzle ORM có ổn định để dùng trong production không?

Có, hoàn toàn được. Drizzle ORM phiên bản stable (0.x) đã được dùng rộng rãi trong production ở nhiều startup và công ty rồi. Mình biết nghe "0.x" thì hơi lo, nhưng thực tế nó ổn định hơn nhiều so với số version gợi ý. Phiên bản v1 đang trong giai đoạn beta với khá nhiều cải tiến như Relational Queries V2, hỗ trợ MSSQL, và tích hợp Zod validator trực tiếp. Bạn cứ dùng bản stable cho production, rồi theo dõi bản beta khi muốn thử tính năng mới.

Neon PostgreSQL có miễn phí không? Giới hạn free tier là gì?

Có free tier và khá rộng rãi luôn: 0.5 GB storage, 191 compute hours/tháng — đủ dùng thoải mái cho side projects và ứng dụng nhỏ. Database branching cũng được hỗ trợ trong free tier. Với cold start được giảm thiểu nhờ PgBouncer tích hợp, free tier của Neon phù hợp cho hầu hết dự án cá nhân và prototype. Mình chạy vài dự án nhỏ trên free tier hơn nửa năm rồi, chưa gặp vấn đề gì.

Tại sao nên dùng @neondatabase/serverless thay vì pg (node-postgres)?

Câu trả lời ngắn gọn: vì serverless. Driver @neondatabase/serverless được tối ưu đặc biệt cho serverless environments — nó kết nối qua HTTP (mỗi query là một request độc lập, không cần persistent connection) và WebSocket. Còn driver truyền thống pg cần TCP connections, mà trên Vercel Edge Runtime hay các serverless platforms khác thì TCP không hoạt động. Nói đơn giản là pg sẽ lỗi trên edge, còn driver của Neon thì chạy mượt.

Làm sao để chạy Drizzle migration trên Neon trong CI/CD?

Cũng không phức tạp lắm. Thêm migration command vào build script trong package.json: "db:migrate": "drizzle-kit migrate". Rồi trong CI/CD pipeline, set biến môi trường DATABASE_URL và chạy npm run db:migrate trước bước build. Trên Vercel cụ thể, bạn có thể set build command thành: npm run db:migrate && next build. Vậy là xong.

Server Actions có thay thế hoàn toàn API Routes không?

Không hẳn, và mình nghĩ cũng không nên cố ép nó làm mọi thứ. Server Actions rất lý tưởng cho mutations (tạo, sửa, xóa dữ liệu) được trigger từ UI — kiểu form submission hay nút bấm. Nhưng bạn vẫn cần API Routes (Route Handlers) cho mấy trường hợp như: webhooks từ dịch vụ bên ngoài, API cung cấp cho third-party, hay khi bạn cần kiểm soát HTTP response chi tiết (status code, headers tùy chỉnh). Hai cái bổ trợ cho nhau chứ không phải thay thế nhau.

Về Tác Giả Editorial Team

Our team of expert writers and editors.