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.