Back to blog

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

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

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

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 install

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

Step 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.svg

Part 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:3000

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

Step 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:5173

Open 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

  1. Go to github.com/new
  2. Name it my-app-ui, set to Public
  3. 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 main

Step 16: Deploy to Vercel

  1. Go to vercel.com/new

  2. Import the my-app-ui repo

  3. Vercel auto-detects Vite and sets:

    • Framework Preset: Vite
    • Build Command: vite build
    • Output Directory: dist
    • Install Command: bun install
  4. Before clicking Deploy, add the environment variable:

NameValue
VITE_API_URLhttps://my-api-yourusername.vercel.app
  1. 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:

ConcernHow it's handled
Loading stateisLoading from useQuery
Error stateisError + error.message from useQuery
Refetch after mutationqueryClient.invalidateQueries
Optimistic updatesonMutate + rollback in onError
Auto-refreshrefetchInterval: 30_000
DeduplicationReact 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.ts

Summary 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:

LayerServiceFree Tier
FrontendVercel (static)Unlimited deployments, 100GB bandwidth
BackendVercel (edge)500K invocations/month
DatabaseNeon PostgreSQL0.5 GB storage
Cache + Rate limitUpstash Redis10K commands/day
Code hostingGitHubUnlimited 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 order field 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.