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.
What you're building
Planning a multi-day trip involves coordinating activities, tracking budgets, and sharing plans with travel companions. Spreadsheets are cumbersome and maps apps do not track schedules. A dedicated itinerary planner combines scheduling, budgeting, and mapping in one interface.
V0 generates the itinerary layout, activity forms, and map integration from prompts. react-leaflet provides free map rendering without API keys. Supabase stores trips, days, and activities with shareable public links via unique tokens.
The architecture uses Server Components for trip overview pages, a client component for the interactive day planner with drag-to-reorder, Server Actions for CRUD operations, and a public share route that uses RLS policies to allow unauthenticated access via share_token.
Final result
A travel itinerary planner with day-by-day scheduling, activity management, map visualization, budget tracking, and shareable public trip links.
Tech stack
Prerequisites
- 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)
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Create a Supabase schema for a travel itinerary app:3// 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)4// 2. itinerary_days table: id (uuid PK), trip_id (uuid FK), date (date), notes (text)5// 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)6// 4. trip_collaborators table: trip_id (uuid FK), user_id (uuid FK), can_edit (boolean DEFAULT false), PRIMARY KEY(trip_id, user_id)7// RLS: owners and collaborators can access trips. Public trips accessible via share_token without auth.8// 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.
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.
1import { createClient } from '@/lib/supabase/server'2import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'3import { Card, CardContent } from '@/components/ui/card'4import { Badge } from '@/components/ui/badge'5import { Button } from '@/components/ui/button'6import { Separator } from '@/components/ui/separator'78export default async function TripPage({ params }: { params: Promise<{ id: string }> }) {9 const { id } = await params10 const supabase = await createClient()1112 const { data: trip } = await supabase.from('trips').select('*').eq('id', id).single()13 const { data: days } = await supabase14 .from('itinerary_days')15 .select('*, activities(*)')16 .eq('trip_id', id)17 .order('date')1819 const totalBudget = days?.flatMap(d => d.activities).reduce((sum: number, a: any) => sum + (a.cost_cents ?? 0), 0) ?? 02021 return (22 <div className="max-w-4xl mx-auto p-6">23 <h1 className="text-3xl font-bold">{trip?.title}</h1>24 <p className="text-muted-foreground">{trip?.destination}</p>25 <p className="text-sm mt-2">Total spent: ${(totalBudget / 100).toFixed(2)}26 {trip?.budget_cents && ` / $${(trip.budget_cents / 100).toFixed(2)} budget`}27 </p>28 <Separator className="my-4" />29 <Tabs defaultValue={days?.[0]?.id}>30 <TabsList>31 {days?.map((day, i) => (32 <TabsTrigger key={day.id} value={day.id}>Day {i + 1}</TabsTrigger>33 ))}34 </TabsList>35 {days?.map((day) => (36 <TabsContent key={day.id} value={day.id} className="space-y-3">37 <p className="text-sm text-muted-foreground">{new Date(day.date).toLocaleDateString()}</p>38 {day.activities39 ?.sort((a: any, b: any) => a.position - b.position)40 .map((activity: any) => (41 <Card key={activity.id}>42 <CardContent className="p-4 flex justify-between items-start">43 <div>44 <p className="font-semibold">{activity.title}</p>45 <p className="text-sm text-muted-foreground">46 {activity.start_time} — {activity.location_name}47 </p>48 </div>49 <div className="flex items-center gap-2">50 <Badge variant="outline">{activity.category}</Badge>51 {activity.cost_cents > 0 && (52 <Badge variant="secondary">${(activity.cost_cents / 100).toFixed(2)}</Badge>53 )}54 </div>55 </CardContent>56 </Card>57 ))}58 <Button variant="outline" size="sm">+ Add Activity</Button>59 </TabsContent>60 ))}61 </Tabs>62 </div>63 )64}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.
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.
1// Paste this prompt into V0's AI chat:2// Add a map overview page at app/trips/[id]/map/page.tsx.3// Requirements:4// - Install and use react-leaflet with OpenStreetMap tiles (free, no API key)5// - Fetch all activities for this trip that have latitude and longitude6// - Display each activity as a marker on the map7// - Clicking a marker shows a popup with: activity title, time, location name, category Badge, and cost8// - Color-code markers by category (transport=blue, food=orange, activity=green, accommodation=purple)9// - Auto-center and zoom the map to fit all markers10// - Use dynamic(() => import(...), { ssr: false }) since Leaflet doesn't support SSR11// - Add a link back to the itinerary view12// 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.
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.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Separator } from '@/components/ui/separator'5import { notFound } from 'next/navigation'67export default async function SharedTrip({ params }: { params: Promise<{ token: string }> }) {8 const { token } = await params9 const supabase = await createClient()1011 const { data: trip } = await supabase12 .from('trips')13 .select('*, itinerary_days(*, activities(*))')14 .eq('share_token', token)15 .eq('is_public', true)16 .single()1718 if (!trip) notFound()1920 return (21 <div className="max-w-3xl mx-auto p-6">22 <h1 className="text-3xl font-bold">{trip.title}</h1>23 <p className="text-muted-foreground">{trip.destination}</p>24 <Separator className="my-4" />25 {trip.itinerary_days26 ?.sort((a: any, b: any) => a.date.localeCompare(b.date))27 .map((day: any, i: number) => (28 <div key={day.id} className="mb-6">29 <h2 className="text-xl font-semibold mb-3">Day {i + 1} — {new Date(day.date).toLocaleDateString()}</h2>30 {day.activities31 ?.sort((a: any, b: any) => a.position - b.position)32 .map((activity: any) => (33 <Card key={activity.id} className="mb-2">34 <CardContent className="p-3 flex justify-between">35 <div>36 <p className="font-medium">{activity.title}</p>37 <p className="text-sm text-muted-foreground">{activity.start_time} — {activity.location_name}</p>38 </div>39 <Badge variant="outline">{activity.category}</Badge>40 </CardContent>41 </Card>42 ))}43 </div>44 ))}45 </div>46 )47}Expected result: A read-only public trip view accessible without login. Anyone with the share link sees the full itinerary with days and activities.
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.
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'56export async function createTrip(formData: FormData) {7 const supabase = await createClient()8 const { data: { user } } = await supabase.auth.getUser()9 if (!user) return { error: 'Not authenticated' }1011 const title = formData.get('title') as string12 const destination = formData.get('destination') as string13 const startDate = formData.get('startDate') as string14 const endDate = formData.get('endDate') as string1516 const { data: trip } = await supabase.from('trips').insert({17 owner_id: user.id, title, destination,18 start_date: startDate, end_date: endDate,19 }).select('id').single()2021 // Auto-create itinerary days22 const start = new Date(startDate)23 const end = new Date(endDate)24 const days = []25 for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {26 days.push({ trip_id: trip!.id, date: d.toISOString().split('T')[0] })27 }28 await supabase.from('itinerary_days').insert(days)2930 revalidatePath('/trips')31 return { tripId: trip!.id }32}3334export async function addActivity(formData: FormData) {35 const supabase = await createClient()36 const dayId = formData.get('dayId') as string37 const { data: maxPos } = await supabase38 .from('activities').select('position')39 .eq('day_id', dayId).order('position', { ascending: false }).limit(1).single()4041 await supabase.from('activities').insert({42 day_id: dayId,43 title: formData.get('title') as string,44 start_time: formData.get('startTime') as string,45 location_name: formData.get('location') as string,46 category: formData.get('category') as string,47 cost_cents: parseInt(formData.get('cost') as string || '0') * 100,48 position: (maxPos?.position ?? 0) + 1,49 })50 revalidatePath(`/trips`)51}5253export async function toggleShareLink(tripId: string, isPublic: boolean) {54 const supabase = await createClient()55 await supabase.from('trips').update({ is_public: isPublic }).eq('id', tripId)56 revalidatePath(`/trips/${tripId}`)57}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
1import { createClient } from '@/lib/supabase/server'2import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'3import { Card, CardContent } from '@/components/ui/card'4import { Badge } from '@/components/ui/badge'5import { Separator } from '@/components/ui/separator'6import { Button } from '@/components/ui/button'78export default async function TripPage({9 params,10}: {11 params: Promise<{ id: string }>12}) {13 const { id } = await params14 const supabase = await createClient()1516 const { data: trip } = await supabase17 .from('trips')18 .select('*')19 .eq('id', id)20 .single()2122 const { data: days } = await supabase23 .from('itinerary_days')24 .select('*, activities(*)')25 .eq('trip_id', id)26 .order('date')2728 const total = days29 ?.flatMap((d) => d.activities)30 .reduce((sum: number, a: any) => sum + (a.cost_cents ?? 0), 0) ?? 03132 return (33 <div className="max-w-4xl mx-auto p-6">34 <h1 className="text-3xl font-bold">{trip?.title}</h1>35 <p className="text-muted-foreground">{trip?.destination}</p>36 <p className="text-sm mt-1">Budget: ${(total / 100).toFixed(2)}</p>37 <Separator className="my-4" />38 <Tabs defaultValue={days?.[0]?.id}>39 <TabsList>40 {days?.map((day, i) => (41 <TabsTrigger key={day.id} value={day.id}>42 Day {i + 1}43 </TabsTrigger>44 ))}45 </TabsList>46 {days?.map((day) => (47 <TabsContent key={day.id} value={day.id} className="space-y-3">48 {day.activities49 ?.sort((a: any, b: any) => a.position - b.position)50 .map((act: any) => (51 <Card key={act.id}>52 <CardContent className="p-4 flex justify-between">53 <div>54 <p className="font-semibold">{act.title}</p>55 <p className="text-sm text-muted-foreground">56 {act.start_time} - {act.location_name}57 </p>58 </div>59 <Badge variant="outline">{act.category}</Badge>60 </CardContent>61 </Card>62 ))}63 <Button variant="outline" size="sm">+ Add Activity</Button>64 </TabsContent>65 ))}66 </Tabs>67 </div>68 )69}Customization ideas
Add packing list
Create a checklist component for packing items linked to trips. Users can check off items as they pack with a progress indicator.
Add weather forecast integration
Fetch weather forecasts for the trip destination and dates using Open-Meteo API. Display daily weather icons on each day tab.
Add flight and hotel booking links
Integrate affiliate links to booking sites for flights and hotels at the destination. Display them as suggestions based on trip dates.
Add photo journal
Let users upload photos to each day using Supabase Storage. Create a trip photo gallery that maps images to itinerary days.
Common pitfalls
Pitfall: Importing Leaflet in a Server Component without dynamic import
How to avoid: Use dynamic(() => import('../components/map'), { ssr: false }) to load the map component only on the client side.
Pitfall: Not auto-generating itinerary_days when creating a trip
How to avoid: In the createTrip Server Action, loop from start_date to end_date and insert one itinerary_days row per date.
Pitfall: Making the share_token guessable
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a travel itinerary planner with Next.js App Router and Supabase. I need: 1) Day-by-day trip planning with activity scheduling, 2) Map integration showing all activities as pins, 3) Budget tracking per activity and total, 4) Shareable public trip links via unique token. Help me design the schema and the public share RLS policy.
Create a shareable public trip page at app/share/[token]/page.tsx that: 1) Fetches a trip by share_token from Supabase without authentication, 2) Uses an RLS policy that allows SELECT on trips WHERE is_public = true AND share_token matches, 3) Displays the full itinerary in read-only mode with day-by-day activities, 4) Shows a clean layout without edit buttons. Handle the case where the token is invalid with notFound().
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation