Xây dựng React + Vite UI cho Hono API trên Vercel

Trong hai bài trước chúng ta đã xây dựng Bun + Hono REST API với Neon PostgreSQL và Upstash Redis và deploy lên Vercel. API hoạt động hoàn hảo — nhưng một API không có UI chỉ là một nửa câu chuyện.
Trong bài này chúng ta xây dựng frontend: một ứng dụng React + Vite kết nối với API. Chúng ta sẽ giữ chúng trong hai repo GitHub riêng biệt và deploy mỗi cái lên Vercel độc lập. Đây là pattern production chuẩn — UI và API của bạn có thể scale, được version hóa và deploy độc lập.
Bạn sẽ học được gì
✅ Tạo dự án React + Vite + TypeScript từ đầu
✅ Viết API client type-safe phản ánh types từ Hono backend
✅ Cấu hình CORS trên API để chấp nhận requests từ domain của frontend
✅ Quản lý API URLs với biến môi trường của Vite
✅ Xây dựng UI task manager với create, toggle và delete
✅ Deploy frontend lên Vercel từ repo GitHub riêng biệt
✅ Xử lý loading states, errors và optimistic updates
Tổng quan kiến trúc
Hai Vercel deployments hoàn toàn độc lập, hai GitHub repos, một sự phân tách trách nhiệm rõ ràng.
| Frontend | Backend | |
|---|---|---|
| Repo | my-app-ui | my-api |
| URL | my-app-ui.vercel.app | my-api.vercel.app |
| Runtime | Static (CDN) | Edge Functions |
| Framework | React + Vite | Hono |
Điều kiện tiên quyết
- Đã hoàn thành Phần 1: Deploy Bun + Hono lên Vercel
- API đã được deploy tại ví dụ
https://my-api-yourusername.vercel.app - Node.js 20+ hoặc Bun đã cài đặt
- Git + GitHub account + Vercel account (từ các bài trước)
Phần 1: Tạo dự án React + Vite
Bước 1: Tạo dự án
Vite là công cụ chuẩn cho React development năm 2026 — HMR nhanh, build tức thì và zero config.
bun create vite my-app-ui --template react-ts
cd my-app-ui
bun installTemplate react-ts cho bạn React 18 + TypeScript được cấu hình sẵn.
Bước 2: Cài đặt Dependencies
Chúng ta cần thêm một vài packages:
# Tailwind CSS cho styling
bun add -d tailwindcss @tailwindcss/vite
# React Query cho server state (fetching, caching, loading states)
bun add @tanstack/react-query
# Lucide cho icons
bun add lucide-reactBước 3: Cấu hình Tailwind CSS
Cập nhật vite.config.ts để thêm Tailwind plugin:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})Thay thế nội dung của src/index.css bằng:
/* src/index.css */
@import "tailwindcss";Bước 4: Dọn dẹp Scaffold
Xóa các file boilerplate không cần thiết:
rm src/App.css src/assets/react.svg public/vite.svgPhần 2: Xây dựng API Client
Chìa khóa cho frontend dễ bảo trì là một API client chuyên dụng — một file biết cách nói chuyện với backend. Tất cả lệnh gọi fetch nằm ở đây; các component không bao giờ gọi fetch trực tiếp.
Bước 5: Định nghĩa Shared Types
// src/types/api.ts
export type Task = {
id: number
title: string
done: boolean
createdAt: string
updatedAt: string
}
export type TasksResponse = {
tasks: Task[]
total: number
source: 'cache' | 'db'
}
export type TaskResponse = {
task: Task
source?: 'cache' | 'db'
}
export type ApiError = {
error: string
}Bước 6: Tạo API Client
// src/lib/api.ts
import type { Task, TasksResponse, TaskResponse } from '../types/api'
// Vite nhúng VITE_* env vars vào bundle lúc build
const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(body.error ?? `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
export const api = {
// GET /api/tasks
getTasks: () => request<TasksResponse>('/tasks'),
// GET /api/tasks/:id
getTask: (id: number) => request<TaskResponse>(`/tasks/${id}`),
// POST /api/tasks
createTask: (title: string) =>
request<TaskResponse>('/tasks', {
method: 'POST',
body: JSON.stringify({ title }),
}),
// PATCH /api/tasks/:id (toggle done)
toggleTask: (id: number) =>
request<TaskResponse>(`/tasks/${id}`, { method: 'PATCH' }),
// DELETE /api/tasks/:id
deleteTask: (id: number) =>
request<TaskResponse>(`/tasks/${id}`, { method: 'DELETE' }),
}Bước 7: Cài đặt React Query
Bọc app bằng QueryClientProvider trong src/main.tsx:
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // Dữ liệu giữ nguyên trong 30s
retry: 1, // Thử lại request thất bại một lần
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)Phần 3: Xây dựng các UI Component
Bước 8: Task Form Component
// src/components/TaskForm.tsx
import { useState, type FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../lib/api'
import { Plus, Loader2 } from 'lucide-react'
export function TaskForm() {
const [title, setTitle] = useState('')
const queryClient = useQueryClient()
const { mutate, isPending } = useMutation({
mutationFn: (title: string) => api.createTask(title),
onSuccess: () => {
// Xóa cache danh sách tasks để refetch
queryClient.invalidateQueries({ queryKey: ['tasks'] })
setTitle('')
},
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (!title.trim()) return
mutate(title.trim())
}
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-6">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Thêm task mới..."
disabled={isPending}
className="flex-1 px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-700
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50"
/>
<button
type="submit"
disabled={isPending || !title.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Plus size={16} />
)}
Thêm
</button>
</form>
)
}Bước 9: Task Item Component
// src/components/TaskItem.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../lib/api'
import type { Task } from '../types/api'
import { Trash2, Loader2 } from 'lucide-react'
type Props = { task: Task }
export function TaskItem({ task }: Props) {
const queryClient = useQueryClient()
const toggleMutation = useMutation({
mutationFn: () => api.toggleTask(task.id),
// Optimistic update — lật UI ngay lập tức, hoàn tác nếu lỗi
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['tasks'] })
const previous = queryClient.getQueryData(['tasks'])
queryClient.setQueryData(['tasks'], (old: any) => ({
...old,
tasks: old.tasks.map((t: Task) =>
t.id === task.id ? { ...t, done: !t.done } : t
),
}))
return { previous }
},
onError: (_err, _vars, context) => {
// Hoàn tác khi có lỗi
queryClient.setQueryData(['tasks'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
const deleteMutation = useMutation({
mutationFn: () => api.deleteTask(task.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
const isBusy = toggleMutation.isPending || deleteMutation.isPending
return (
<div className={`flex items-center gap-3 p-4 rounded-lg border transition-all
${task.done
? 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
}`}>
{/* Checkbox */}
<button
onClick={() => toggleMutation.mutate()}
disabled={isBusy}
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0
transition-colors disabled:opacity-50
${task.done
? 'bg-green-500 border-green-500'
: 'border-gray-300 dark:border-gray-600 hover:border-green-400'
}`}
>
{toggleMutation.isPending ? (
<Loader2 size={10} className="animate-spin text-white" />
) : task.done ? (
<span className="text-white text-xs">✓</span>
) : null}
</button>
{/* Tiêu đề */}
<span className={`flex-1 text-sm ${
task.done
? 'line-through text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100'
}`}>
{task.title}
</span>
{/* Xóa */}
<button
onClick={() => deleteMutation.mutate()}
disabled={isBusy}
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50
dark:hover:bg-red-900/20 disabled:opacity-50 transition-colors"
>
{deleteMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
)
}Bước 10: Task List Component
// src/components/TaskList.tsx
import { useQuery } from '@tanstack/react-query'
import { api } from '../lib/api'
import { TaskItem } from './TaskItem'
import { Loader2 } from 'lucide-react'
export function TaskList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['tasks'],
queryFn: api.getTasks,
refetchInterval: 30_000, // Tự động refresh mỗi 30s
})
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 size={32} className="animate-spin text-blue-500" />
</div>
)
}
if (isError) {
return (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">
Không thể tải tasks: {error.message}
</p>
</div>
)
}
const tasks = data?.tasks ?? []
const done = tasks.filter((t) => t.done).length
return (
<div>
{/* Thống kê */}
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{done} / {tasks.length} hoàn thành
</p>
<span className={`text-xs px-2 py-0.5 rounded-full font-mono
${data?.source === 'cache'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}`}>
{data?.source ?? 'db'}
</span>
</div>
{/* Trạng thái rỗng */}
{tasks.length === 0 && (
<p className="text-center py-12 text-gray-400 dark:text-gray-500">
Chưa có task nào. Thêm task đầu tiên ở trên!
</p>
)}
{/* Danh sách tasks */}
<div className="flex flex-col gap-2">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
</div>
)
}Bước 11: Kết nối tất cả trong App.tsx
// src/App.tsx
import { TaskForm } from './components/TaskForm'
import { TaskList } from './components/TaskList'
export default function App() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
<div className="max-w-xl mx-auto px-4 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Task Manager
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Powered by Hono + Neon + Upstash
</p>
</div>
{/* Thêm task */}
<TaskForm />
{/* Danh sách task */}
<TaskList />
</div>
</div>
)
}Phần 4: Biến môi trường
Bước 12: File .env local
Vite đọc các file .env lúc build time. Các biến có prefix VITE_ được nhúng vào JavaScript bundle.
# .env.local
VITE_API_URL=http://localhost:3000Trong production, đây sẽ là URL Vercel API live — được quản lý trong Vercel dashboard, không phải trong repo.
Thêm .env.local vào .gitignore:
# .gitignore
node_modules/
dist/
.env.local
.env*.localBước 13: TypeScript Types cho env vars
Vite không tự type import.meta.env. Thêm declaration file:
// src/vite-env.d.ts (thêm vào file có sẵn)
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}Bước 14: Test trên local
Chạy cả hai server trong hai terminal:
# Terminal 1 — API (từ project my-api)
bun run dev
# → http://localhost:3000
# Terminal 2 — UI (từ project my-app-ui)
bun run dev
# → http://localhost:5173Mở http://localhost:5173. Bạn sẽ thấy task manager. Thử thêm, toggle và xóa tasks.
Phần 5: Cấu hình CORS trên API
Khi UI được deploy trên domain khác với API (ví dụ: my-app-ui.vercel.app gọi my-api.vercel.app), trình duyệt thực thi CORS — API phải rõ ràng cho phép requests từ origin của UI.
Mở project API (my-api) và cập nhật src/index.ts:
// src/index.ts trong project my-api
import { cors } from 'hono/cors'
// Thay thế wildcard cors() bằng allowlist rõ ràng
const ALLOWED_ORIGINS = [
'http://localhost:5173', // Local Vite dev server
'https://my-app-ui.vercel.app', // UI đã deploy (cập nhật lại)
/https:\/\/my-app-ui-.*\.vercel\.app/, // Vercel preview deployments
]
app.use('*', cors({
origin: (origin) => {
if (!origin) return origin // Cho phép server-to-server (curl, Postman)
const allowed = ALLOWED_ORIGINS.some((allowed) =>
typeof allowed === 'string'
? allowed === origin
: allowed.test(origin)
)
return allowed ? origin : null
},
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
}))Regex https:\/\/my-app-ui-.*\.vercel\.app tự động cho phép tất cả các URL preview deployment của Vercel, vì vậy mỗi PR đều có preview hoạt động được.
Commit và push thay đổi API — Vercel redeploy trong ~30 giây.
Phần 6: Push lên GitHub và Deploy
Bước 15: Tạo UI GitHub Repo
- Truy cập github.com/new
- Đặt tên
my-app-ui, chọn Public - KHÔNG khởi tạo với README
# Trong project my-app-ui
git init
git add .
git commit -m "feat: init React + Vite task manager UI"
git remote add origin https://github.com/YOUR_USERNAME/my-app-ui.git
git branch -M main
git push -u origin mainBước 16: Deploy lên Vercel
-
Truy cập vercel.com/new
-
Import repo
my-app-ui -
Vercel tự động phát hiện Vite và cài đặt:
- Framework Preset: Vite
- Build Command:
vite build - Output Directory:
dist - Install Command:
bun install
-
Trước khi nhấp Deploy, thêm biến môi trường:
| Tên | Giá trị |
|---|---|
VITE_API_URL | https://my-api-yourusername.vercel.app |
- Nhấp Deploy
Trong ~30 giây UI của bạn live tại https://my-app-ui-yourusername.vercel.app.
Bước 17: Cập nhật CORS Allowlist
Bây giờ bạn đã biết URL UI thực, quay lại project API và cập nhật cấu hình CORS với URL thực:
// Trong my-api/src/index.ts
const ALLOWED_ORIGINS = [
'http://localhost:5173',
'https://my-app-ui-yourusername.vercel.app', // ← URL thực của bạn
/https:\/\/my-app-ui-.*\.vercel\.app/,
]Push, để redeploy, và UI của bạn sẽ giao tiếp thành công với API trên production.
Phần 7: Cách hoạt động của Full Deployment
Mỗi git push vào bất kỳ repo nào đều kích hoạt Vercel deployment độc lập:
Hai deployments hoàn toàn độc lập. Bạn có thể cập nhật UI mà không cần đụng đến API, và ngược lại.
Hiểu kiến trúc Frontend
React Query xử lý tất cả sự phức tạp bất đồng bộ:
| Vấn đề | Cách xử lý |
|---|---|
| Loading state | isLoading từ useQuery |
| Error state | isError + error.message từ useQuery |
| Refetch sau mutation | queryClient.invalidateQueries |
| Optimistic updates | onMutate + rollback trong onError |
| Auto-refresh | refetchInterval: 30_000 |
| Deduplication | React Query loại bỏ các query giống nhau đồng thời |
Cấu trúc dự án
my-app-ui/
├── public/
│ └── favicon.svg
├── src/
│ ├── components/
│ │ ├── TaskForm.tsx # Form thêm task
│ │ ├── TaskItem.tsx # Một task đơn lẻ
│ │ └── TaskList.tsx # Fetch + render danh sách
│ ├── lib/
│ │ └── api.ts # Tất cả lệnh fetch nằm ở đây
│ ├── types/
│ │ └── api.ts # TypeScript types chung
│ ├── App.tsx # Layout gốc
│ ├── main.tsx # Cài đặt React Query
│ ├── index.css # Import Tailwind
│ └── vite-env.d.ts # Types cho env vars
├── .env.local # URL API local (git-ignored)
├── .gitignore
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.tsTóm tắt và Bài học chính
Bạn đã có một ứng dụng full-stack hoàn chỉnh trên hai Vercel deployments độc lập:
✅ React Query quản lý toàn bộ server state — fetching, caching, mutations, loading states
✅ API client tập trung giữ tất cả lệnh fetch ở một nơi, dễ thay thế hoặc mock
✅ CORS allowlist với regex tự động bao phủ tất cả URL Vercel preview deployments
✅ Env var VITE_API_URL chuyển đổi giữa API local và production không cần thay đổi code
✅ Optimistic updates làm UI cảm giác tức thì — toggle lật ngay, hoàn tác khi lỗi
✅ Badge cache hiển thị response API đến từ Redis hay Neon
✅ Hai repo riêng biệt để UI và API deploy, scale và được version hóa độc lập
Stack Free hoàn chỉnh
Bạn giờ đã có stack production-grade đầy đủ với chi phí bằng không:
| Tầng | Dịch vụ | Free Tier |
|---|---|---|
| Frontend | Vercel (static) | Unlimited deployments, 100GB bandwidth |
| Backend | Vercel (edge) | 500K invocations/tháng |
| Database | Neon PostgreSQL | 0.5 GB storage |
| Cache + Rate limit | Upstash Redis | 10K commands/ngày |
| Lưu trữ code | GitHub | Unlimited public repos |
Bước tiếp theo
- Thêm authentication — dùng Clerk hoặc Supabase Auth để bảo vệ API và hiển thị tasks theo user
- Thêm optimistic delete — xóa task khỏi danh sách ngay lập tức trước khi server xác nhận
- Thêm dark mode toggle — các class
dark:của Tailwind đã sẵn có, chỉ cần nút toggle - Thêm drag-and-drop sắp xếp — lưu field
ordertrong Neon, dùng@dnd-kit/core
📬 Đă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.