# How to Build Travel itinerary app with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a travel itinerary planner with V0 featuring day-by-day trip planning, activity scheduling with time slots, budget tracking, map integration via react-leaflet, and shareable public trip links. You'll create a visual timeline, drag-to-reorder activities, and collaborative editing — all in about 1-2 hours.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- No map API key needed — react-leaflet uses free OpenStreetMap tiles
- Trip ideas to plan (destinations, activities, accommodations)

## Step-by-step guide

### 1. Set up the trip planning database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the trips, itinerary_days, activities, and trip_collaborators tables with a share_token for public links.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a travel itinerary app:
// 1. trips table: id (uuid PK), owner_id (uuid FK), title (text), destination (text), start_date (date), end_date (date), cover_image_url (text), budget_cents (int nullable), share_token (text UNIQUE DEFAULT gen_random_uuid()), is_public (boolean DEFAULT false), created_at (timestamptz)
// 2. itinerary_days table: id (uuid PK), trip_id (uuid FK), date (date), notes (text)
// 3. activities table: id (uuid PK), day_id (uuid FK), title (text), description (text), start_time (time), end_time (time nullable), location_name (text), latitude (numeric nullable), longitude (numeric nullable), cost_cents (int DEFAULT 0), category (text — 'transport', 'food', 'activity', 'accommodation', 'other'), position (int for ordering)
// 4. trip_collaborators table: trip_id (uuid FK), user_id (uuid FK), can_edit (boolean DEFAULT false), PRIMARY KEY(trip_id, user_id)
// RLS: owners and collaborators can access trips. Public trips accessible via share_token without auth.
// Seed a sample 3-day trip to Tokyo with 4 activities per day.
```

**Expected result:** Four tables created with RLS policies supporting both authenticated access and public share_token access. Sample Tokyo trip seeded.

### 2. Build the day-by-day itinerary view with activity cards

Create the main trip page with Tabs for switching between days. Each day shows activity Cards in chronological order with time, location, cost, and category Badge.

```
import { createClient } from '@/lib/supabase/server'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'

export default async function TripPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const supabase = await createClient()

  const { data: trip } = await supabase.from('trips').select('*').eq('id', id).single()
  const { data: days } = await supabase
    .from('itinerary_days')
    .select('*, activities(*)')
    .eq('trip_id', id)
    .order('date')

  const totalBudget = days?.flatMap(d => d.activities).reduce((sum: number, a: any) => sum + (a.cost_cents ?? 0), 0) ?? 0

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">{trip?.title}</h1>
      <p className="text-muted-foreground">{trip?.destination}</p>
      <p className="text-sm mt-2">Total spent: ${(totalBudget / 100).toFixed(2)}
        {trip?.budget_cents && ` / $${(trip.budget_cents / 100).toFixed(2)} budget`}
      </p>
      <Separator className="my-4" />
      <Tabs defaultValue={days?.[0]?.id}>
        <TabsList>
          {days?.map((day, i) => (
            <TabsTrigger key={day.id} value={day.id}>Day {i + 1}</TabsTrigger>
          ))}
        </TabsList>
        {days?.map((day) => (
          <TabsContent key={day.id} value={day.id} className="space-y-3">
            <p className="text-sm text-muted-foreground">{new Date(day.date).toLocaleDateString()}</p>
            {day.activities
              ?.sort((a: any, b: any) => a.position - b.position)
              .map((activity: any) => (
                <Card key={activity.id}>
                  <CardContent className="p-4 flex justify-between items-start">
                    <div>
                      <p className="font-semibold">{activity.title}</p>
                      <p className="text-sm text-muted-foreground">
                        {activity.start_time} — {activity.location_name}
                      </p>
                    </div>
                    <div className="flex items-center gap-2">
                      <Badge variant="outline">{activity.category}</Badge>
                      {activity.cost_cents > 0 && (
                        <Badge variant="secondary">${(activity.cost_cents / 100).toFixed(2)}</Badge>
                      )}
                    </div>
                  </CardContent>
                </Card>
              ))}
            <Button variant="outline" size="sm">+ Add Activity</Button>
          </TabsContent>
        ))}
      </Tabs>
    </div>
  )
}
```

> Pro tip: Use Design Mode (Option+D) to visually polish the day timeline layout — adjust Card spacing, category Badge colors, and add visual time connectors between activities at zero credit cost.

**Expected result:** A trip page with Tab navigation between days. Each day shows activity Cards chronologically with time, location, category Badge, and cost.

### 3. Integrate the map overview with react-leaflet

Add a map page that shows all activities across all days as pins. Each pin shows the activity name and time when clicked. react-leaflet uses free OpenStreetMap tiles — no API key needed.

```
// Paste this prompt into V0's AI chat:
// Add a map overview page at app/trips/[id]/map/page.tsx.
// Requirements:
// - Install and use react-leaflet with OpenStreetMap tiles (free, no API key)
// - Fetch all activities for this trip that have latitude and longitude
// - Display each activity as a marker on the map
// - Clicking a marker shows a popup with: activity title, time, location name, category Badge, and cost
// - Color-code markers by category (transport=blue, food=orange, activity=green, accommodation=purple)
// - Auto-center and zoom the map to fit all markers
// - Use dynamic(() => import(...), { ssr: false }) since Leaflet doesn't support SSR
// - Add a link back to the itinerary view
// Important: Leaflet CSS must be imported for the map to render correctly.
```

**Expected result:** A map page showing all trip activities as colored markers. Clicking a marker shows activity details in a popup. The map auto-centers to fit all pins.

### 4. Build the shareable public trip link

Create a public share page that fetches a trip using its share_token without requiring authentication. The RLS policy allows SELECT on trips where is_public is true and the token matches.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { notFound } from 'next/navigation'

export default async function SharedTrip({ params }: { params: Promise<{ token: string }> }) {
  const { token } = await params
  const supabase = await createClient()

  const { data: trip } = await supabase
    .from('trips')
    .select('*, itinerary_days(*, activities(*))')
    .eq('share_token', token)
    .eq('is_public', true)
    .single()

  if (!trip) notFound()

  return (
    <div className="max-w-3xl mx-auto p-6">
      <h1 className="text-3xl font-bold">{trip.title}</h1>
      <p className="text-muted-foreground">{trip.destination}</p>
      <Separator className="my-4" />
      {trip.itinerary_days
        ?.sort((a: any, b: any) => a.date.localeCompare(b.date))
        .map((day: any, i: number) => (
          <div key={day.id} className="mb-6">
            <h2 className="text-xl font-semibold mb-3">Day {i + 1} — {new Date(day.date).toLocaleDateString()}</h2>
            {day.activities
              ?.sort((a: any, b: any) => a.position - b.position)
              .map((activity: any) => (
                <Card key={activity.id} className="mb-2">
                  <CardContent className="p-3 flex justify-between">
                    <div>
                      <p className="font-medium">{activity.title}</p>
                      <p className="text-sm text-muted-foreground">{activity.start_time} — {activity.location_name}</p>
                    </div>
                    <Badge variant="outline">{activity.category}</Badge>
                  </CardContent>
                </Card>
              ))}
          </div>
        ))}
    </div>
  )
}
```

**Expected result:** A read-only public trip view accessible without login. Anyone with the share link sees the full itinerary with days and activities.

### 5. Add Server Actions for trip and activity management

Create Server Actions for creating trips, adding days and activities, reordering activities, and toggling the share link. These handle all the CRUD operations the planner needs.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function createTrip(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Not authenticated' }

  const title = formData.get('title') as string
  const destination = formData.get('destination') as string
  const startDate = formData.get('startDate') as string
  const endDate = formData.get('endDate') as string

  const { data: trip } = await supabase.from('trips').insert({
    owner_id: user.id, title, destination,
    start_date: startDate, end_date: endDate,
  }).select('id').single()

  // Auto-create itinerary days
  const start = new Date(startDate)
  const end = new Date(endDate)
  const days = []
  for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
    days.push({ trip_id: trip!.id, date: d.toISOString().split('T')[0] })
  }
  await supabase.from('itinerary_days').insert(days)

  revalidatePath('/trips')
  return { tripId: trip!.id }
}

export async function addActivity(formData: FormData) {
  const supabase = await createClient()
  const dayId = formData.get('dayId') as string
  const { data: maxPos } = await supabase
    .from('activities').select('position')
    .eq('day_id', dayId).order('position', { ascending: false }).limit(1).single()

  await supabase.from('activities').insert({
    day_id: dayId,
    title: formData.get('title') as string,
    start_time: formData.get('startTime') as string,
    location_name: formData.get('location') as string,
    category: formData.get('category') as string,
    cost_cents: parseInt(formData.get('cost') as string || '0') * 100,
    position: (maxPos?.position ?? 0) + 1,
  })
  revalidatePath(`/trips`)
}

export async function toggleShareLink(tripId: string, isPublic: boolean) {
  const supabase = await createClient()
  await supabase.from('trips').update({ is_public: isPublic }).eq('id', tripId)
  revalidatePath(`/trips/${tripId}`)
}
```

**Expected result:** Creating a trip auto-generates itinerary_days for each date in the range. Activities are added with automatic position ordering. Share link toggles public visibility.

## Complete code example

File: `app/trips/[id]/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'

export default async function TripPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const supabase = await createClient()

  const { data: trip } = await supabase
    .from('trips')
    .select('*')
    .eq('id', id)
    .single()

  const { data: days } = await supabase
    .from('itinerary_days')
    .select('*, activities(*)')
    .eq('trip_id', id)
    .order('date')

  const total = days
    ?.flatMap((d) => d.activities)
    .reduce((sum: number, a: any) => sum + (a.cost_cents ?? 0), 0) ?? 0

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">{trip?.title}</h1>
      <p className="text-muted-foreground">{trip?.destination}</p>
      <p className="text-sm mt-1">Budget: ${(total / 100).toFixed(2)}</p>
      <Separator className="my-4" />
      <Tabs defaultValue={days?.[0]?.id}>
        <TabsList>
          {days?.map((day, i) => (
            <TabsTrigger key={day.id} value={day.id}>
              Day {i + 1}
            </TabsTrigger>
          ))}
        </TabsList>
        {days?.map((day) => (
          <TabsContent key={day.id} value={day.id} className="space-y-3">
            {day.activities
              ?.sort((a: any, b: any) => a.position - b.position)
              .map((act: any) => (
                <Card key={act.id}>
                  <CardContent className="p-4 flex justify-between">
                    <div>
                      <p className="font-semibold">{act.title}</p>
                      <p className="text-sm text-muted-foreground">
                        {act.start_time} - {act.location_name}
                      </p>
                    </div>
                    <Badge variant="outline">{act.category}</Badge>
                  </CardContent>
                </Card>
              ))}
            <Button variant="outline" size="sm">+ Add Activity</Button>
          </TabsContent>
        ))}
      </Tabs>
    </div>
  )
}
```

## Common mistakes

- **Importing Leaflet in a Server Component without dynamic import** — Leaflet accesses the window object for map rendering. Server Components don't have window, causing 'window is not defined' errors. Fix: Use dynamic(() => import('../components/map'), { ssr: false }) to load the map component only on the client side.
- **Not auto-generating itinerary_days when creating a trip** — Users expect to see day tabs immediately after creating a trip. Without auto-generated days, the itinerary page is empty and confusing. Fix: In the createTrip Server Action, loop from start_date to end_date and insert one itinerary_days row per date.
- **Making the share_token guessable** — Sequential or short tokens can be guessed, exposing private trip details. A 4-character code has only 1.6M combinations. Fix: Use gen_random_uuid() for share_token, generating 128-bit UUIDs that are practically unguessable.

## Best practices

- Use react-leaflet with OpenStreetMap tiles for free map rendering — no API key or billing required
- Use dynamic import with { ssr: false } for the map component since Leaflet requires browser APIs
- Use Tabs from shadcn/ui for day switching — clean UX for multi-day itineraries
- Auto-generate itinerary_days from start/end dates when creating a trip so the planner is ready immediately
- Use Design Mode (Option+D) to polish activity Card layouts and category Badge colors at zero credit cost
- Use gen_random_uuid() for share_token to generate secure, unguessable public links
- Store costs in cents as integers to avoid floating-point rounding errors in budget calculations

## Frequently asked questions

### Do I need a map API key?

No. react-leaflet uses OpenStreetMap tiles which are completely free and require no API key. Just install react-leaflet and import the tile layer — maps work immediately.

### How do shareable trip links work?

Each trip has a unique share_token (UUID). When is_public is toggled on, the RLS policy allows unauthenticated users to read the trip via the token. The share URL looks like yourdomain.com/share/abc123-def456.

### What V0 plan do I need?

V0 Free tier works. The itinerary app uses standard Server Components, dynamic imports for the map, and shadcn/ui components. No paid integrations required.

### Can multiple people edit a trip?

Yes. The trip_collaborators table stores invited users with can_edit permissions. RLS policies allow collaborators to read and optionally edit the trip alongside the owner.

### How do I deploy this app?

Click Share > Publish in V0. The Supabase connection is auto-configured. The map component works identically in production since it uses public OpenStreetMap tiles.

### Can RapidDev help build a custom travel platform?

Yes. RapidDev has built 600+ apps including travel platforms with itinerary planning, booking integration, and social sharing features. Book a free consultation to discuss your travel app requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/travel-itinerary-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/travel-itinerary-app
