Giới thiệu
8 phút đọc15 tháng 6, 2026300

Deploy Next.js lên Production: Từ git push đến monitoring thực tế

Bài cuối series React → Next.js: tất cả những gì cần biết để đưa app lên production an toàn, từ Vercel đến self-hosted Docker.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Deploy Next.js lên Production: Từ git push đến monitoring thực tế

Đây là bài thứ 12 cũng là bài cuối trong series từ React đến Next.js. Sau 11 bài đào sâu vào routing, data fetching, Server Components, authentication... giờ là lúc nói về thứ quan trọng nhất: đưa cái app đó lên production và đảm bảo nó chạy ổn định.

Mình sẽ không nói kiểu "deploy là bước cuối cùng" vì thực ra deploy chỉ là bắt đầu. Phần khó hơn là monitor, debug production issues, và đảm bảo performance không tụt dốc sau khi có traffic thật.

Vercel Zero Config nhưng không có nghĩa là Zero Knowledge

Nếu bạn chưa từng dùng Vercel, trải nghiệm lần đầu sẽ khá ấn tượng. Connect GitHub repo, push code, xong app live. Không cần config gì cả.

Nhưng để dùng Vercel đúng cách trong môi trường production thật sự, có vài thứ bạn cần nắm:

Preview Deployments là tính năng mình thấy cực kỳ hữu ích khi làm việc nhóm. Mỗi PR tự động có một preview URL riêng kiểu your-app-git-feature-xyz-yourteam.vercel.app. QA có thể test trực tiếp trên đó mà không cần pull code về local. Reviewer cũng có thể click vào xem UI thay vì chỉ đọc code. Workflow này tiết kiệm khá nhiều thời gian.

Environment Variables trên Vercel có 3 scope: Production, Preview, và Development. Đừng nhét tất cả vào Production rồi thôi hãy tách biệt rõ ràng. Database staging không nên dùng chung với production, đây là điều hiển nhiên nhưng mình vẫn thấy nhiều team làm sai.

Edge Functions middleware của Next.js chạy trên Vercel Edge Network, tức là chạy gần người dùng nhất có thể (CDN edge nodes). Cold start gần như bằng 0. Đây là lý do middleware thường được dùng cho auth checks, redirects, A/B testing những thứ cần response nhanh nhưng không cần full Node.js environment.

represented as connected circles on a world map. Clean flat design, dark background, blue and white accent colors.)

Self-hosted với Docker Khi bạn cần kiểm soát nhiều hơn

Vercel tuyệt vời, nhưng không phải lúc nào cũng phù hợp. Một số công ty có yêu cầu data residency (dữ liệu phải ở trong nước), hoặc đơn giản là budget không cho phép scale trên Vercel khi traffic lớn. Lúc đó self-hosted là lựa chọn.

Bước đầu tiên là config next.config.ts:

TypeScript
1// next.config.ts
2const nextConfig = {
3 output: 'standalone',
4 // standalone mode tạo ra một folder chứa tất cả
5 // dependencies cần thiết, không cần node_modules đầy đủ
6};
7
8export default nextConfig;

Với output: 'standalone', Next.js sẽ bundle chỉ những gì cần thiết để chạy app rất tiện cho Docker image nhỏ gọn.

Dockerfile mình hay dùng theo pattern multi-stage build:

Dockerfile
1# Stage 1: Dependencies
2FROM node:20-alpine AS deps
3WORKDIR /app
4COPY package.json package-lock.json ./
5RUN npm ci --only=production
6
7# Stage 2: Builder
8FROM node:20-alpine AS builder
9WORKDIR /app
10COPY --from=deps /app/node_modules ./node_modules
11COPY . .
12RUN npm run build
13
14# Stage 3: Runner
15FROM node:20-alpine AS runner
16WORKDIR /app
17ENV NODE_ENV=production
18
19COPY --from=builder /app/public ./public
20COPY --from=builder /app/.next/standalone ./
21COPY --from=builder /app/.next/static ./.next/static
22
23EXPOSE 3000
24CMD ["node", "server.js"]

Multi-stage build giúp image cuối cùng không chứa dev dependencies, source code gốc, hay các build artifacts không cần thiết. Image size giảm đáng kể.

Rồi docker-compose.yml để orchestrate toàn bộ stack:

YAML
1services:
2 app:
3 build: .
4 ports:
5 - "3000:3000"
6 environment:
7 - DATABASE_URL=postgresql://user:pass@db:5432/myapp
8 - REDIS_URL=redis://cache:6379
9 depends_on:
10 - db
11 - cache
12
13 db:
14 image: postgres:16-alpine
15 volumes:
16 - postgres_data:/var/lib/postgresql/data
17 environment:
18 POSTGRES_DB: myapp
19 POSTGRES_USER: user
20 POSTGRES_PASSWORD: pass
21
22 cache:
23 image: redis:7-alpine
24 volumes:
25 - redis_data:/data
26
27 nginx:
28 image: nginx:alpine
29 ports:
30 - "80:80"
31 - "443:443"
32 volumes:
33 - ./nginx.conf:/etc/nginx/nginx.conf
34 - ./certs:/etc/nginx/certs
35 depends_on:
36 - app
37
38volumes:
39 postgres_data:
40 redis_data:

Về reverse proxy, mình thấy Caddy ngày càng được ưa dùng hơn Nginx vì tự động handle SSL với Let's Encrypt mà không cần config thêm gì. Nhưng nếu team đã quen Nginx thì cứ dùng Nginx không có gì sai cả.

Environment Variables Đừng để secret lọt ra client

Đây là thứ mình thấy nhiều người mới bị nhầm. Next.js có quy ước rõ ràng:

Ví dụ:

PrefixAccessible ởUse case
`NEXT_PUBLIC_*`Client + ServerAPI public URL, analytics ID, feature flags
Không có prefixServer onlyDatabase URL, secret keys, API private keys
Terminal
1# .env.local (gitignored KHÔNG commit file này)
2DATABASE_URL=postgresql://localhost:5432/myapp_dev
3JWT_SECRET=your-super-secret-key
4STRIPE_SECRET_KEY=sk_test_...
5
6# Cái này expose ra client bundle ai cũng đọc được
7NEXT_PUBLIC_API_URL=https://api.yourapp.com
8NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

Một lỗi mình hay thấy: đặt tên NEXT_PUBLIC_DATABASE_URL rồi wonder tại sao database bị attack. Bất cứ thứ gì có prefix NEXT_PUBLIC_ đều nằm trong JavaScript bundle gửi về browser bất kỳ ai cũng có thể đọc được bằng DevTools.

Edge vs Node.js Runtime Chọn đúng cho đúng việc

Next.js cho phép bạn chọn runtime cho từng route. Đây là trade-off cần hiểu rõ:

Thực tế mình hay dùng Edge cho middleware (auth check, redirect) và Node.js cho API routes có database query. Đừng cố nhét database query vào Edge Runtime hầu hết database clients không support môi trường đó.

Edge RuntimeNode.js Runtime
Cold start~0ms100-500ms
MemoryGiới hạn (128MB)Đủ dùng
Node.js APIsKhông hỗ trợFull support
Database clientsHầu hết không dùng đượcBình thường
Phù hợp choAuth, redirects, geo-routingDB queries, file I/O, heavy processing
TypeScript
1// middleware.ts chạy trên Edge, perfect cho auth check
2export const config = {
3 runtime: 'edge',
4 matcher: ['/dashboard/:path*', '/api/protected/:path*'],
5};
6
7// app/api/users/route.ts cần Node.js vì dùng Prisma
8export const runtime = 'nodejs'; // hoặc không khai báo, mặc định là nodejs

Core Web Vitals và tại sao bạn nên quan tâm từ sớm

Google dùng Core Web Vitals để rank trang web. Quan trọng hơn, nó phản ánh trải nghiệm thật của người dùng. Ba metric chính:

  • LCP (Largest Contentful Paint): Thời gian render element lớn nhất trong viewport. Target < 2.5s. Thường bị ảnh hưởng bởi hero image hoặc main content block.
  • INP (Interaction to Next Paint): Thay thế FID từ 2024, đo responsiveness khi user interact. Target < 200ms.
  • CLS (Cumulative Layout Shift): Layout bị nhảy lung tung khi load. Target < 0.1. Thủ phạm phổ biến: image không có width/height, font swap, dynamic content inject.

Next.js giúp bạn khá nhiều ở đây. next/image tự động:

  • Lazy load images ngoài viewport
  • Convert sang WebP/AVIF
  • Yêu cầu khai báo widthheight (fix CLS)
  • Serve đúng size theo device
TSX
1import Image from 'next/image';
2
3// Đúng next/image handle hết
4<Image
5 src="/hero.jpg"
6 alt="Hero image"
7 width={1200}
8 height={600}
9 priority // dùng cho above-the-fold images, tắt lazy load
10/>
11
12// Sai dùng <img> thường, mất hết optimization
13<img src="/hero.jpg" alt="Hero image" />

Về bundle size, mình hay chạy @next/bundle-analyzer để xem cái gì đang phình to:

Terminal
1npm install @next/bundle-analyzer
TypeScript
1// next.config.ts
2import bundleAnalyzer from '@next/bundle-analyzer';
3
4const withBundleAnalyzer = bundleAnalyzer({
5 enabled: process.env.ANALYZE === 'true',
6});
7
8export default withBundleAnalyzer(nextConfig);
Terminal
1ANALYZE=true npm run build

Nó sẽ mở browser với treemap visualization cho thấy package nào chiếm bao nhiêu KB. Theo kinh nghiệm của mình, thủ phạm thường gặp nhất là moment.js (thay bằng date-fns), lodash import nguyên cả library thay vì tree-shake, và các icon library import cả bộ thay vì từng icon.

Sentry + Vercel Analytics Monitoring thực tế

Error tracking và performance monitoring là hai thứ khác nhau, cần cả hai.

Sentry cho error tracking:

Terminal
1npm install @sentry/nextjs
2npx @sentry/wizard@latest -i nextjs

Sentry tự động capture unhandled exceptions, nhưng bạn cũng nên manually capture các business logic errors:

TypeScript
1import * as Sentry from '@sentry/nextjs';
2
3try {
4 await processPayment(orderId);
5} catch (error) {
6 Sentry.captureException(error, {
7 tags: { orderId, userId },
8 level: 'error',
9 });
10 throw error;
11}

Vercel Analytics cho real user metrics nó tự động collect Core Web Vitals từ actual users, không phải synthetic tests. Bạn sẽ thấy sự khác biệt khá rõ giữa Lighthouse score local và real user data, đặc biệt trên mobile và connection chậm.

Production Checklist Cái này in ra dán lên màn hình

Mình tổng hợp lại checklist trước khi go-live. Không phải để tick cho xong, mà mỗi item đều có lý do cụ thể:

TEXT
1□ TypeScript strict mode bật, không có `any` tùy tiện
2□ Environment variables đã verify đúng scope (không để secret ra NEXT_PUBLIC_)
3□ Error boundaries bao quanh các page/section quan trọng
4□ Metadata và OG tags đầy đủ trên mọi page
5□ Tất cả ảnh dùng next/image với width/height khai báo đúng
6□ Loading states (loading.tsx) cho các route có data fetching
7□ Custom 404 và error page
8□ Core Web Vitals đạt target (LCP < 2.5s, INP < 200ms, CLS < 0.1)
9□ Security headers đã config (X-Frame-Options, CSP, etc.)
10□ Database connection pooling (Prisma, pg-pool, hoặc PgBouncer)
11□ Logging structured và có thể query được
12□ Sentry (hoặc tương đương) đã setup và test

Về security headers, Next.js cho phép config trong next.config.ts:

TypeScript
1const securityHeaders = [
2 { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
3 { key: 'X-Content-Type-Options', value: 'nosniff' },
4 { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
5 {
6 key: 'Content-Security-Policy',
7 value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'",
8 },
9];
10
11export default {
12 async headers() {
13 return [
14 {
15 source: '/(.*)',
16 headers: securityHeaders,
17 },
18 ];
19 },
20};

Database connection pooling hay bị bỏ qua cho đến khi app bị crash vì quá nhiều connections. Với Prisma trên serverless/edge, bạn cần Prisma Accelerate hoặc PgBouncer. Với self-hosted Node.js thì Prisma tự handle pool khá ổn, nhưng vẫn nên explicit config pool size.


Series 12 bài đến đây là xong. Từ React cơ bản, qua Next.js App Router, Server Components, data fetching patterns, authentication, đến deployment và monitoring. Mình cố gắng viết theo hướng thực chiến những thứ mình thật sự dùng trong dự án, không phải chỉ copy docs lại.

Nếu bạn có câu hỏi về bất kỳ phần nào, hoặc muốn mình đào sâu hơn vào một topic cụ thể (ví dụ deployment với Kubernetes, hay monitoring nâng cao với OpenTelemetry), cứ drop comment. Mình đọc hết.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!

Deploy Next.js lên Production: Từ git push đến monitoring thực tế — Stacklog