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.
What you're building
A travel itinerary app has three core features: planning, collaboration, and expenses. Planning uses a trips table with date ranges and an itinerary_items table with a day_number and sort_order for drag-and-drop reordering. Each item has a type (activity, hotel, flight, restaurant, transport) that controls its icon.
Collaboration is handled by a trip_members table with roles. RLS policies check trip membership before allowing any read or write operation. Supabase Realtime broadcasts changes to the itinerary_items table so all members see additions, edits, and deletions without refreshing.
Expenses use a trip_expenses table where each expense has a payer (one member) and a split configuration. The simplest split is equally among all members. The split is stored as a JSONB column: { memberId: amountOwed }. After the trip, the app runs a settlement algorithm that calculates the minimum number of transactions needed for everyone to settle up — for example, if Alice owes Bob $30 and Bob owes Charlie $20, the result is Alice pays Charlie $20 and Alice pays Bob $10.
Final result
A collaborative travel planner where multiple people can co-edit an itinerary in real time, track shared expenses, and see a settlement breakdown after the trip.
Tech stack
Prerequisites
- 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
Build steps
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.
1Build a collaborative travel itinerary app. Create these Supabase tables:23- 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_at4- 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)5- 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_at6- 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_at78RLS policies:9- trips: SELECT/UPDATE/DELETE where id IN (SELECT trip_id FROM trip_members WHERE user_id = auth.uid())10- trip_members, itinerary_items, trip_expenses: same trip_id IN (...) pattern11- Only owners can delete trips and manage members12- Allow public SELECT on trips WHERE is_published = true (for share link)1314Enable 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.
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.
1Build the itinerary page at src/pages/TripItinerary.tsx:231. Page header: trip title, destination, date range, member Avatars (show up to 5, then '+N more'), 'Invite' Button, 'Share' Button42. 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)53. Day content:6 - Timeline list of itinerary_items for this day ordered by sort_order7 - 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 menu8 - Item type Badge with different colors per type9 - Drag handle on left side. On drag end, update sort_order values for all affected items in a batch Supabase update104. '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_reference115. Subscribe to itinerary_items changes via Supabase Realtime. Show a 'Member Name edited this itinerary' toast when another user makes a changePro 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.
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.
1// src/utils/expenseSettlement.ts2export interface Balance {3 userId: string4 name: string5 balance: number6}78export interface Settlement {9 from: string10 fromName: string11 to: string12 toName: string13 amount: number14}1516export function calculateSettlements(balances: Balance[]): Settlement[] {17 const debtors = balances.filter((b) => b.balance < 0).sort((a, b) => a.balance - b.balance)18 const creditors = balances.filter((b) => b.balance > 0).sort((a, b) => b.balance - a.balance)19 const settlements: Settlement[] = []2021 let i = 022 let j = 023 while (i < debtors.length && j < creditors.length) {24 const debtor = debtors[i]25 const creditor = creditors[j]26 const amount = Math.min(Math.abs(debtor.balance), creditor.balance)2728 if (amount > 0.01) {29 settlements.push({30 from: debtor.userId,31 fromName: debtor.name,32 to: creditor.userId,33 toName: creditor.name,34 amount: Math.round(amount * 100) / 100,35 })36 }3738 debtor.balance += amount39 creditor.balance -= amount4041 if (Math.abs(debtor.balance) < 0.01) i++42 if (creditor.balance < 0.01) j++43 }4445 return settlements46}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.
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.
1Build a member management Sheet (slides in from right) for trip owners:231. Member list: Avatar + display name + email + role Badge (owner=purple, editor=blue, viewer=gray)42. Role change Select per member (owners only): clicking changes role in trip_members53. Remove member Button (owners only, cannot remove self if only owner)64. Invite section at bottom:7 - Email Input + Role Select + 'Send Invite' Button8 - On submit: call invite_to_trip(trip_id, email, role) Supabase RPC function9 - Show pending invites section: members with no user_id yet show as 'Pending — email@example.com' with a 'Revoke' button105. Trip visibility toggle: 'Private' vs 'Public link' Switch. Setting to public sets is_published = true and shows the share URL: /trips/public/SHARE_TOKEN116. 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 viewExpected 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
1import { useEffect } from 'react'2import { useQueryClient } from '@tanstack/react-query'3import { supabase } from '@/integrations/supabase/client'4import { toast } from '@/hooks/use-toast'56interface Options {7 tripId: string8 currentUserId: string9}1011export function useTripRealtime({ tripId, currentUserId }: Options) {12 const queryClient = useQueryClient()1314 useEffect(() => {15 const channel = supabase16 .channel(`trip-${tripId}`)17 .on(18 'postgres_changes',19 {20 event: '*',21 schema: 'public',22 table: 'itinerary_items',23 filter: `trip_id=eq.${tripId}`,24 },25 (payload) => {26 queryClient.invalidateQueries({ queryKey: ['itinerary', tripId] })27 if (payload.new && 'created_by' in payload.new && payload.new.created_by !== currentUserId) {28 toast({29 title: 'Itinerary updated',30 description: 'A collaborator just made a change.',31 duration: 3000,32 })33 }34 }35 )36 .on(37 'postgres_changes',38 {39 event: '*',40 schema: 'public',41 table: 'trip_expenses',42 filter: `trip_id=eq.${tripId}`,43 },44 () => {45 queryClient.invalidateQueries({ queryKey: ['expenses', tripId] })46 }47 )48 .subscribe()4950 return () => {51 supabase.removeChannel(channel)52 }53 }, [tripId, currentUserId, queryClient])54}Customization ideas
Map view with pinned locations
Integrate Mapbox GL JS (or Google Maps via an Edge Function proxy) to show all itinerary items with coordinates on a map. When users add a location to an itinerary item, use the Mapbox geocoding API to convert the address to coordinates and store them in itinerary_items.coordinates (point type in PostgreSQL).
Flight and hotel search integration
Add an 'Add Flight' dialog that calls the Amadeus or Skyscanner API via an Edge Function to search for flights. Show results in a list and let users click to add the flight details (departure/arrival times, flight number) directly to the itinerary. Store the API key in Edge Function secrets.
Packing list with member assignment
Add a packing_items table linked to a trip. Each item has a name, category, quantity, and assigned_to member. Show a checklist UI where members can check off items as they pack. The Realtime subscription updates the checklist for all members live.
Budget tracker with currency conversion
Add a trip budget (total_budget column on trips) and convert all expenses to a home currency using a daily exchange rate stored in a currencies table updated by a scheduled Edge Function. Show a budget vs actual Recharts BarChart that converts expenses to the trip's home currency.
PDF itinerary export
Create an Edge Function that generates a PDF of the full itinerary using a template. The PDF includes trip header, day-by-day timeline, and expense summary. Use a Deno-compatible PDF library or an HTML-to-PDF service like Puppeteer via an external API. Add a 'Download PDF' button to the trip page.
Common pitfalls
Pitfall: Using user_id = auth.uid() for trip-related table RLS
How to avoid: 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.
Pitfall: Not handling Realtime subscription cleanup
How to avoid: Always return a cleanup function from useEffect: return () => { supabase.removeChannel(channel) }. The useTripRealtime hook in the complete code example shows the correct pattern.
Pitfall: Storing expense splits as simple equal division rather than JSONB
How to avoid: 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.
Pitfall: Calculating settlement on every render
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a collaborative travel app where multiple users share a trip and track expenses together. I have a trip_expenses table with a split_config JSONB column that stores { userId: amountOwed } for each expense. Help me write a JavaScript function that takes an array of expenses and trip members and returns: 1) each member's net balance (positive = owed money, negative = owes money), 2) the minimum set of transactions to settle all debts. Show the full algorithm with TypeScript types.
Add a 'Day Notes' feature to the travel itinerary. On each day tab, show a TextArea at the top for free-form daily notes (restaurant reservations, reminders, packing notes). Store in a day_notes table: id, trip_id, day_number, content (text), updated_at, updated_by. Auto-save on keystroke debounce. Show who last edited the note and when. Broadcast changes via Supabase Realtime so all members see the note update in real time.
In Supabase, create a trigger function that fires when is_published changes to true on the trips table. The trigger should: 1) verify the trip has at least one itinerary item, 2) create a row in trip_activity_log with action='published', trip_id, and user_id from auth.uid(). Also create a separate scheduled Edge Function that runs weekly and sets is_published = false for trips whose end_date was more than 90 days ago to clean up old public links.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation