Back to blog

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

reactvitetypescriptvercelhonofrontend
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.

FrontendBackend
Repomy-app-uimy-api
URLmy-app-ui.vercel.appmy-api.vercel.app
RuntimeStatic (CDN)Edge Functions
FrameworkReact + ViteHono

Đ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 install

Template 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-react

Bướ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.svg

Phầ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:3000

Trong 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*.local

Bướ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:5173

Mở 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

  1. Truy cập github.com/new
  2. Đặt tên my-app-ui, chọn Public
  3. 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 main

Bước 16: Deploy lên Vercel

  1. Truy cập vercel.com/new

  2. Import repo my-app-ui

  3. 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
  4. Trước khi nhấp Deploy, thêm biến môi trường:

TênGiá trị
VITE_API_URLhttps://my-api-yourusername.vercel.app
  1. 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 stateisLoading từ useQuery
Error stateisError + error.message từ useQuery
Refetch sau mutationqueryClient.invalidateQueries
Optimistic updatesonMutate + rollback trong onError
Auto-refreshrefetchInterval: 30_000
DeduplicationReact 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.ts

Tó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ầngDịch vụFree Tier
FrontendVercel (static)Unlimited deployments, 100GB bandwidth
BackendVercel (edge)500K invocations/tháng
DatabaseNeon PostgreSQL0.5 GB storage
Cache + Rate limitUpstash Redis10K commands/ngày
Lưu trữ codeGitHubUnlimited 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 order trong 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.