# How to Build a Travel Itinerary App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a collaborative trip planner in Lovable where multiple travelers edit a shared day-by-day itinerary in real time. Features itinerary items, trip member roles, shared expense tracking with an automatic splitting algorithm, and Supabase Realtime so every collaborator sees updates instantly.

## Before you start

- Lovable Pro account for real-time and multi-table generation
- Supabase project with URL and anon key saved to Cloud tab → Secrets
- Supabase Auth configured (email/password is sufficient for inviting members)
- Optional: a real trip in mind to populate with test data

## Step-by-step guide

### 1. Create the trip schema with member roles and RLS

The schema is the most important step. Member-based RLS is more complex than user_id-based RLS — a user can access a trip if they are in the trip_members table for that trip.

```
Build a collaborative travel itinerary app. Create these Supabase tables:

- trips: id, created_by (FK auth.users), title, destination, description, start_date (date), end_date (date), cover_image_url, share_token (uuid, default gen_random_uuid()), is_published (bool default false), created_at
- trip_members: id, trip_id (FK trips), user_id (FK auth.users), email (text, for pending invites), role (owner|editor|viewer), joined_at, UNIQUE(trip_id, user_id)
- itinerary_items: id, trip_id (FK trips), day_number (int, 1-indexed), sort_order (int), item_type (activity|hotel|flight|restaurant|transport|note), title, description, location, start_time (time nullable), end_time (time nullable), booking_reference, created_by (FK auth.users), created_at, updated_at
- trip_expenses: id, trip_id (FK trips), paid_by (FK auth.users), title, amount (numeric), category (food|transport|accommodation|activity|other), expense_date (date), split_config (jsonb, { userId: amountOwed }), receipt_url, created_at

RLS policies:
- trips: SELECT/UPDATE/DELETE where id IN (SELECT trip_id FROM trip_members WHERE user_id = auth.uid())
- trip_members, itinerary_items, trip_expenses: same trip_id IN (...) pattern
- Only owners can delete trips and manage members
- Allow public SELECT on trips WHERE is_published = true (for share link)

Enable Realtime on itinerary_items and trip_expenses tables.
```

> Pro tip: Ask Lovable to create a Supabase function invite_to_trip(trip_id, email, role) that either links an existing user by email lookup in auth.users, or creates a pending invite row in trip_members with just the email. When a new user signs up with that email, a trigger automatically updates the trip_members row with their user_id.

**Expected result:** All four tables are created with member-based RLS. The trips list page shows with a 'Create Trip' button. Creating a trip auto-adds the creator as an owner in trip_members.

### 2. Build the day-by-day itinerary timeline

Create the itinerary view with a day-by-day layout. Each day is a column or section. Items within a day can be reordered by dragging.

```
Build the itinerary page at src/pages/TripItinerary.tsx:

1. Page header: trip title, destination, date range, member Avatars (show up to 5, then '+N more'), 'Invite' Button, 'Share' Button
2. Day tabs: one Tab per day of the trip labeled 'Day 1 — Jun 15', 'Day 2 — Jun 16' etc. (calculate from start_date + day_number - 1)
3. Day content:
   - Timeline list of itinerary_items for this day ordered by sort_order
   - Each item is a Card with: type icon (map pin for activity, bed for hotel, plane for flight, fork for restaurant), title, location, time range, booking reference (grayed out), edit/delete Actions menu
   - Item type Badge with different colors per type
   - Drag handle on left side. On drag end, update sort_order values for all affected items in a batch Supabase update
4. 'Add Item' Button at bottom of each day: opens a Dialog with fields for item_type Select, title, description, location, start_time, end_time, booking_reference
5. Subscribe to itinerary_items changes via Supabase Realtime. Show a 'Member Name edited this itinerary' toast when another user makes a change
```

> Pro tip: Ask Lovable to add an 'Add Day' button for trips that get extended. This inserts a new day_number (max + 1) and also extends the trips.end_date by one day. Members see the new day appear in real time.

**Expected result:** The itinerary page shows day tabs. Adding items to a day creates Cards in the timeline. Opening the same trip in two browser windows shows real-time sync when items are added or reordered.

### 3. Build the expense tracker with split calculation

Add the expense tracking tab. The split algorithm calculates what each member owes the payer based on the split_config JSONB and then runs a settlement algorithm after the trip.

```
// src/utils/expenseSettlement.ts
export interface Balance {
  userId: string
  name: string
  balance: number
}

export interface Settlement {
  from: string
  fromName: string
  to: string
  toName: string
  amount: number
}

export function calculateSettlements(balances: Balance[]): Settlement[] {
  const debtors = balances.filter((b) => b.balance < 0).sort((a, b) => a.balance - b.balance)
  const creditors = balances.filter((b) => b.balance > 0).sort((a, b) => b.balance - a.balance)
  const settlements: Settlement[] = []

  let i = 0
  let j = 0
  while (i < debtors.length && j < creditors.length) {
    const debtor = debtors[i]
    const creditor = creditors[j]
    const amount = Math.min(Math.abs(debtor.balance), creditor.balance)

    if (amount > 0.01) {
      settlements.push({
        from: debtor.userId,
        fromName: debtor.name,
        to: creditor.userId,
        toName: creditor.name,
        amount: Math.round(amount * 100) / 100,
      })
    }

    debtor.balance += amount
    creditor.balance -= amount

    if (Math.abs(debtor.balance) < 0.01) i++
    if (creditor.balance < 0.01) j++
  }

  return settlements
}
```

**Expected result:** The expenses tab shows all trip expenses as Cards. The settlement section shows 'Alice pays Bob $35' style entries that represent the minimum transactions needed to settle all debts.

### 4. Add member management and invitations

Build the member management panel where trip owners can invite new members by email, change roles, and remove members. Invite new users via email using Supabase Auth's invite feature.

```
Build a member management Sheet (slides in from right) for trip owners:

1. Member list: Avatar + display name + email + role Badge (owner=purple, editor=blue, viewer=gray)
2. Role change Select per member (owners only): clicking changes role in trip_members
3. Remove member Button (owners only, cannot remove self if only owner)
4. Invite section at bottom:
   - Email Input + Role Select + 'Send Invite' Button
   - On submit: call invite_to_trip(trip_id, email, role) Supabase RPC function
   - Show pending invites section: members with no user_id yet show as 'Pending — email@example.com' with a 'Revoke' button
5. Trip visibility toggle: 'Private' vs 'Public link' Switch. Setting to public sets is_published = true and shows the share URL: /trips/public/SHARE_TOKEN
6. Create a public route /trips/public/:token that fetches the trip by share_token WHERE is_published = true (no auth required) and shows a read-only itinerary view
```

**Expected result:** The member management Sheet opens from the trip header. Inviting a new member by email creates a pending entry. The public share link works without being logged in.

## Complete code example

File: `src/hooks/useTripRealtime.ts`

```typescript
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { toast } from '@/hooks/use-toast'

interface Options {
  tripId: string
  currentUserId: string
}

export function useTripRealtime({ tripId, currentUserId }: Options) {
  const queryClient = useQueryClient()

  useEffect(() => {
    const channel = supabase
      .channel(`trip-${tripId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'itinerary_items',
          filter: `trip_id=eq.${tripId}`,
        },
        (payload) => {
          queryClient.invalidateQueries({ queryKey: ['itinerary', tripId] })
          if (payload.new && 'created_by' in payload.new && payload.new.created_by !== currentUserId) {
            toast({
              title: 'Itinerary updated',
              description: 'A collaborator just made a change.',
              duration: 3000,
            })
          }
        }
      )
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'trip_expenses',
          filter: `trip_id=eq.${tripId}`,
        },
        () => {
          queryClient.invalidateQueries({ queryKey: ['expenses', tripId] })
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [tripId, currentUserId, queryClient])
}
```

## Common mistakes

- **Using user_id = auth.uid() for trip-related table RLS** — Trip data like itinerary items and expenses don't have a direct user_id column — they're associated with a trip, and access should be based on trip membership. Fix: Use the membership-based RLS pattern: WHERE trip_id IN (SELECT trip_id FROM trip_members WHERE user_id = auth.uid()). Apply this pattern to all trip-related tables. For write operations, also check the user's role (editor or owner) for modifications.
- **Not handling Realtime subscription cleanup** — If the Supabase Realtime subscription is not removed when the component unmounts, each re-render creates a new subscription. After navigating between trips several times, you'll have multiple active subscriptions causing duplicate toast notifications. Fix: Always return a cleanup function from useEffect: return () => { supabase.removeChannel(channel) }. The useTripRealtime hook in the complete code example shows the correct pattern.
- **Storing expense splits as simple equal division rather than JSONB** — Real trips have unequal splits (one person paid but didn't participate in part of the expense). Hardcoding equal division makes it impossible to handle custom splits. Fix: Store the split as a JSONB object in split_config: { 'userId1': 25.00, 'userId2': 25.00, 'userId3': 50.00 }. The Add Expense dialog should show each member with an editable amount that sums to the total. Default to equal split but allow manual adjustment.
- **Calculating settlement on every render** — The settlement algorithm runs in O(n log n) time and re-runs on every re-render if called directly in the component body. With many expenses and members, this can cause visible lag. Fix: Memoize the settlement calculation using useMemo: const settlements = useMemo(() => calculateSettlements(balances), [balances]). The calculation only re-runs when the expense data changes, not on every render.

## Best practices

- Use trip member roles in RLS policies, not just membership. Viewer-role members should not be able to insert or update itinerary items. Check role in the RLS policy or in a before-insert trigger.
- Subscribe to Supabase Realtime at the trip level (all itinerary items for a trip), not at the global level. Filter subscriptions by trip_id to avoid receiving updates from other trips the user is a member of.
- Store the settlement algorithm result as a snapshot in the database after the trip ends, rather than recalculating on every view. Create a trip_settlement table and populate it when the trip status changes to 'completed'.
- Use Supabase Storage for receipt images in trip_expenses. Store the path in receipt_url and use signed URLs for display so storage objects are not publicly accessible. Delete storage objects when expenses are deleted.
- Add an is_archived status to trips instead of deleting them. Archived trips remain accessible for expense reference and memory browsing but don't appear in the main trips list by default.
- Validate that itinerary items have day_number values within the trip's date range. A CHECK constraint or trigger prevents adding Day 10 items to a 7-day trip.

## Frequently asked questions

### How does real-time collaboration work when two people edit the same itinerary item simultaneously?

Supabase Realtime broadcasts database changes, not a CRDT (conflict-free replicated data type) system. If two people edit the same item simultaneously, the last write wins. For a travel app used by small groups, this is acceptable. The toast notification ('A collaborator made a change') alerts users to refresh. For more robust conflict resolution, you'd need to implement optimistic locking with a version column.

### Can people view the itinerary without creating a Lovable account?

Yes, through the public share link. Setting is_published = true on the trip creates a read-only URL at /trips/public/SHARE_TOKEN. This route fetches the trip without any auth headers using the anon key, and the RLS policy allows SELECT on published trips. Viewers can see the itinerary but cannot edit anything.

### How accurate is the expense settlement algorithm?

The greedy settlement algorithm in this guide produces the minimum number of transactions needed to settle all debts. It sorts creditors and debtors by absolute balance and matches the largest debtor to the largest creditor first. For typical trip group sizes (2-10 people), the algorithm is exact. It works best when splits are entered accurately — rounding to 2 decimal places prevents floating-point accumulation errors.

### How do I handle members who joined the trip partway through and shouldn't share earlier expenses?

The split_config JSONB approach handles this. When adding an expense, the dialog shows all current members but you can set the amount to 0 for members who weren't present. The settlement algorithm only settles non-zero amounts. Add a 'joined_date' to trip_members and pre-fill the Add Expense dialog with zero amounts for members whose joined_date is after the expense date.

### Can I add a map showing all the places we're visiting?

Yes, that's the first customization idea in this guide. Add a coordinates column (point type) to itinerary_items, geocode addresses using Mapbox or Google Maps API via an Edge Function, and display all pins on a map. Ask Lovable to add the map view after completing the base build — it's a separate step that requires an API key in Cloud tab → Secrets.

### What is the maximum number of members per trip?

There is no hard limit set in this schema. Supabase can handle hundreds of members per trip from a database perspective. For Realtime, each connected browser tab counts as one Supabase Realtime connection. Supabase Free plan allows 200 simultaneous Realtime connections, which is more than enough for travel groups. The practical limit is what makes sense for a shared trip — most groups are 2-15 people.

### Can I import an existing itinerary from Google Trips or TripIt?

Not directly — there's no import feature in the base build. However, you can ask Lovable to add a CSV import dialog that accepts a specific format (date, time, item_type, title, location, description) and bulk-inserts itinerary items. Google Trips no longer exists, and TripIt has no public export API, but you can manually create the CSV from any source.

### What happens to the itinerary if the trip owner deletes their account?

Add a CASCADE constraint or trigger that reassigns ownership to another editor-role member when an owner leaves. Without this, the trip becomes ownerless and no one can manage membership. Ask Lovable to add an on_member_delete trigger that promotes the oldest editor-role member to owner if the deleted member was the last owner.

---

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