Build a React + Vite UI for Your Hono API on Vercel

In the previous two posts we built a Bun + Hono REST API with Neon PostgreSQL and Upstash Redis and deployed it to Vercel. The API works perfectly — but an API without a UI is only half the story.
In this post we build the frontend: a React + Vite app that talks to the API. We'll keep them in separate GitHub repos and deploy each to Vercel independently. This is the standard production pattern — your UI and API can scale, be versioned, and be deployed independently.
What You'll Learn
✅ Scaffold a React + Vite + TypeScript project from scratch
✅ Write a type-safe API client that mirrors your Hono backend types
✅ Configure CORS on the API to accept requests from your frontend domain
✅ Manage API URLs with Vite environment variables
✅ Build a functional task manager UI with create, toggle, and delete
✅ Deploy the frontend to Vercel from a separate GitHub repo
✅ Handle loading states, errors, and optimistic updates
Architecture Overview
Two completely independent Vercel deployments, two GitHub repos, one clean separation of concerns.
| 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 |
Prerequisites
- Completed Part 1: Deploy Bun + Hono to Vercel
- Your API deployed at e.g.
https://my-api-yourusername.vercel.app - Node.js 20+ or Bun installed
- Git + GitHub account + Vercel account (from previous posts)
Part 1: Scaffold the React + Vite Project
Step 1: Create the Project
Vite is the standard tool for React development in 2026 — fast HMR, instant builds, and zero config.
bun create vite my-app-ui --template react-ts
cd my-app-ui
bun installThe react-ts template gives you React 18 + TypeScript preconfigured.
Step 2: Install Dependencies
We need a few extra packages:
# Tailwind CSS for styling
bun add -d tailwindcss @tailwindcss/vite
# React Query for server state (fetching, caching, loading states)
bun add @tanstack/react-query
# Lucide for icons
bun add lucide-reactStep 3: Configure Tailwind CSS
Update vite.config.ts to add the 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(),
],
})Replace the contents of src/index.css with:
/* src/index.css */
@import "tailwindcss";Step 4: Clean Up the Scaffold
Remove the boilerplate files you don't need:
rm src/App.css src/assets/react.svg public/vite.svgPart 2: Build the API Client
The key to a maintainable frontend is a dedicated API client — one file that knows how to talk to your backend. All fetch calls live here; your components never call fetch directly.
Step 5: Define 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
}Step 6: Create the API Client
// src/lib/api.ts
import type { Task, TasksResponse, TaskResponse } from '../types/api'
// Vite injects VITE_* env vars at build time
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' }),
}Step 7: Set Up React Query
Wrap the app with QueryClientProvider in 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, // Data stays fresh for 30s
retry: 1, // Retry failed requests once
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)Part 3: Build the UI Components
Step 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: () => {
// Invalidate the tasks list so it refetches
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="Add a new task..."
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} />
)}
Add
</button>
</form>
)
}Step 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 — flip the UI immediately, revert on error
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) => {
// Revert on error
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>
{/* Title */}
<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>
{/* Delete */}
<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>
)
}Step 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, // Auto-refresh every 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">
Failed to load tasks: {error.message}
</p>
</div>
)
}
const tasks = data?.tasks ?? []
const done = tasks.filter((t) => t.done).length
return (
<div>
{/* Stats */}
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{done} / {tasks.length} completed
</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>
{/* Empty state */}
{tasks.length === 0 && (
<p className="text-center py-12 text-gray-400 dark:text-gray-500">
No tasks yet. Add one above!
</p>
)}
{/* Task list */}
<div className="flex flex-col gap-2">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
</div>
)
}Step 11: Wire It All Together in 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>
{/* Add task */}
<TaskForm />
{/* Task list */}
<TaskList />
</div>
</div>
)
}Part 4: Environment Variables
Step 12: Local .env File
Vite reads .env files at build time. Variables prefixed with VITE_ are embedded into the JavaScript bundle.
# .env.local
VITE_API_URL=http://localhost:3000For production, this will be the live Vercel API URL — managed in the Vercel dashboard, not in the repo.
Add .env.local to .gitignore:
# .gitignore
node_modules/
dist/
.env.local
.env*.localStep 13: TypeScript Types for env vars
Vite doesn't type import.meta.env by default. Add a declaration file:
// src/vite-env.d.ts (add to existing file)
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}Step 14: Test Locally
Start both servers in two terminals:
# Terminal 1 — API (from the my-api project)
bun run dev
# → http://localhost:3000
# Terminal 2 — UI (from the my-app-ui project)
bun run dev
# → http://localhost:5173Open http://localhost:5173. You should see the task manager. Try adding, toggling, and deleting tasks.
Part 5: Configure CORS on the API
When the UI is deployed on a different domain from the API (e.g. my-app-ui.vercel.app calling my-api.vercel.app), the browser enforces CORS — the API must explicitly allow requests from the UI origin.
Open the API project (my-api) and update src/index.ts:
// src/index.ts in the my-api project
import { cors } from 'hono/cors'
// Replace the wildcard cors() with an explicit allowlist
const ALLOWED_ORIGINS = [
'http://localhost:5173', // Local Vite dev server
'https://my-app-ui.vercel.app', // Your deployed UI (update this)
/https:\/\/my-app-ui-.*\.vercel\.app/, // Vercel preview deployments
]
app.use('*', cors({
origin: (origin) => {
if (!origin) return origin // Allow 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'],
}))The regex https:\/\/my-app-ui-.*\.vercel\.app automatically allows all Vercel preview deployment URLs, so every PR gets a working preview.
Commit and push the API change — Vercel redeploys it in ~30 seconds.
Part 6: Push to GitHub and Deploy
Step 15: Create the UI GitHub Repo
- Go to github.com/new
- Name it
my-app-ui, set to Public - Do NOT initialize with README
# In the my-app-ui project
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 mainStep 16: Deploy to Vercel
-
Go to vercel.com/new
-
Import the
my-app-uirepo -
Vercel auto-detects Vite and sets:
- Framework Preset: Vite
- Build Command:
vite build - Output Directory:
dist - Install Command:
bun install
-
Before clicking Deploy, add the environment variable:
| Name | Value |
|---|---|
VITE_API_URL | https://my-api-yourusername.vercel.app |
- Click Deploy
In ~30 seconds your UI is live at https://my-app-ui-yourusername.vercel.app.
Step 17: Update the CORS Allowlist
Now that you know the final UI URL, go back to the API project and update the CORS config with the real URL:
// In my-api/src/index.ts
const ALLOWED_ORIGINS = [
'http://localhost:5173',
'https://my-app-ui-yourusername.vercel.app', // ← your real URL
/https:\/\/my-app-ui-.*\.vercel\.app/,
]Push, let it redeploy, and your UI will now successfully talk to the API in production.
Part 7: How the Full Deployment Works
Every git push to either repo triggers an independent Vercel deployment:
The two deployments are completely independent. You can update the UI without touching the API, and vice versa.
Understanding the Frontend Architecture
React Query handles all the async complexity:
| Concern | How it's handled |
|---|---|
| Loading state | isLoading from useQuery |
| Error state | isError + error.message from useQuery |
| Refetch after mutation | queryClient.invalidateQueries |
| Optimistic updates | onMutate + rollback in onError |
| Auto-refresh | refetchInterval: 30_000 |
| Deduplication | React Query deduplicates concurrent identical queries |
Project Structure
my-app-ui/
├── public/
│ └── favicon.svg
├── src/
│ ├── components/
│ │ ├── TaskForm.tsx # Add task form
│ │ ├── TaskItem.tsx # Single task row
│ │ └── TaskList.tsx # Fetches + renders list
│ ├── lib/
│ │ └── api.ts # All fetch calls live here
│ ├── types/
│ │ └── api.ts # Shared TypeScript types
│ ├── App.tsx # Root layout
│ ├── main.tsx # React Query setup
│ ├── index.css # Tailwind import
│ └── vite-env.d.ts # Env var types
├── .env.local # Local API URL (git-ignored)
├── .gitignore
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.tsSummary and Key Takeaways
You now have a complete full-stack app across two independent Vercel deployments:
✅ React Query manages all server state — fetching, caching, mutations, loading states
✅ Centralised API client keeps all fetch calls in one place, easy to swap or mock
✅ CORS allowlist with regex covers all Vercel preview deployment URLs automatically
✅ VITE_API_URL env var switches between local and production API with zero code changes
✅ Optimistic updates make the UI feel instant — toggle flips immediately, reverts on error
✅ Cache badge shows whether the API response came from Redis or Neon
✅ Separate repos let the UI and API deploy, scale, and be versioned independently
The Complete Free Stack
You now have a full production-grade stack at zero cost:
| Layer | Service | Free Tier |
|---|---|---|
| Frontend | Vercel (static) | Unlimited deployments, 100GB bandwidth |
| Backend | Vercel (edge) | 500K invocations/month |
| Database | Neon PostgreSQL | 0.5 GB storage |
| Cache + Rate limit | Upstash Redis | 10K commands/day |
| Code hosting | GitHub | Unlimited public repos |
What to Build Next
- Add authentication — use Clerk or Supabase Auth to protect the API and show user-specific tasks
- Add optimistic delete — remove the task from the list immediately before the server confirms
- Add dark mode toggle — Tailwind's
dark:classes are already in place, just need a toggle - Add drag-and-drop reordering — store an
orderfield in Neon, use@dnd-kit/core
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.