Itinerary Generation — Engineering Internals
A full walkthrough of how a user trip goes from form submission to a saved itinerary record, including the AI model, tool calling, place resolution, hallucination detection, and geocoding.
Overview
User fills form
→ TripGeneratorForm (client component)
→ generateItinerary() server action
→ Load context (curated trips + hip places)
→ Extract uploaded file text
→ Phase 1: generateText() — scaffold (no tools, no place names)
→ title, overview, day titles/descriptions/locations
→ packing list, budget, tips
→ Phase 2: Promise.all() — enrich each day in parallel
→ generateText() per day with lookup_place tool
→ AI calls lookup_place for each activity
→ GooglePlacesCache lookup
→ Google Places API fallback
→ Cache write (awaited)
→ AI writes activity detail informed by place data
→ Merge scaffold + enriched days
→ geocodeItinerary()
→ prisma.itinerary.create()
→ router.push() to itinerary detail page
1. User Input (form)
File: components/dashboard/trip-generator-form.tsx
The user fills in:
| Field | Type | Notes |
|---|---|---|
destination | string | Free text, e.g. "Patagonia" |
tripType | string | Comma-joined from sliders: "hiking,cultural" |
tripFocus | string | Verbose version: "Hiking: A lot, Cultural: Some" |
duration | number | Days 1–30. Auto-set from date range if provided. |
difficulty | easy | moderate | challenging | |
interests | string | Optional free-text |
startDate / endDate | yyyy-MM-dd strings | Optional |
files | { url, filename, type }[] | Uploaded via Vercel Blob |
Trip balance sliders control both tripType (which types are active at all) and tripFocus (how much of each). A slider at 0 excludes that type entirely.
On submit the form calls generateItinerary(data) — a Next.js server action — and shows a GeneratingInterstitial loading screen while it runs.
2. Server Action Entry Point
File: app/actions/generate-itinerary.ts
Auth-gated: requires a logged-in session.
2a. Context loading
Two queries run in parallel to give the AI editorial context:
Curated trips (up to 5 published) — injected as examples of good trip content. Gives the AI a sense of HipTrip's editorial voice, destination coverage, and highlight style.
Hip places — editor-curated places matching the destination (by city or country), rated ≥ 3, up to 15. These are injected with explicit instructions to strongly prefer them when planning activities. This is how editors influence AI recommendations at scale.
2b. File text extraction
File: lib/extract-file-text.ts
If the user uploaded files they're fetched and converted to text:
| File type | Handling |
|---|---|
.txt, .md | Fetched and read directly, truncated to 2,000 chars |
.pdf | Fetched as buffer, parsed with pdf-parse, truncated to 2,000 chars |
image/* | Placeholder note: "travel inspiration image" |
.doc, .docx | Placeholder note: "travel notes reference" |
The combined text is appended at the end of the generation prompt so the AI can incorporate the user's own research.
3. AI Generation — Two phases
Model: gpt-5.4 via Vercel AI SDK (generateText + Output.object)
Files: lib/itinerary-tools.ts, app/actions/generate-itinerary.ts
Generation is deliberately split into two phases so that place lookups never happen in the same call that is writing creative content. This eliminates the hallucinated placeId problem entirely.
Phase 1 — Scaffold (no tools)
A single generateText call with no tools produces the trip skeleton:
{
title: string
overview: string
days: Array<{
day: number
title: string
description: string
location: string // geocoded after generation, must be a specific real place name
}>
packingList: string[]
bestTimeToVisit: string
budgetEstimate: string
importantTips: string[]
}
No placeName, no placeId, no activities. The model concentrates entirely on trip arc, day pacing, and narrative quality. Because there are no tool calls, there is no step budget pressure and nothing to hallucinate a place ID from.
Phase 2 — Day enrichment (parallel, with tools)
Each day from the scaffold is enriched in a separate generateText call, all running in parallel via Promise.all. Each call:
- Receives the day's scaffold context (title, description, location) and the destination
- Has access to the
lookup_placetool - Is instructed to call
lookup_placefor every activity with a real-world place before writing that activity's detail - Is bounded to
5 × 2 + 5 = 15steps — comfortably enough for 5 activities with tool calls to spare - Produces:
{ activities[], accommodation, meals }for that day
Because each call only handles one day's worth of activities (~3–5), the step budget is never exhausted. Every place the model wants to include gets a lookup_place call, and the returned google_place_id is used directly.
What lookup_place does:
- Checks
HipPlacefor an exact name match — if found, marksis_editor_pick: trueand returns editor notes and tags - Calls
lookupPlace(placeName + ", " + destination, { mode: "full" })which:- Checks
GooglePlacesCachefor a fresh entry (< 24h old) - On miss: calls Google Places Text Search API with full field mask (rating, photos, reviews, opening hours, editorial summary, etc.)
- Awaits the cache write before returning
- Checks
- Returns to the model:
google_place_id, name, address, rating, editorial summary, editor context
The model uses the returned editorial data to write informed activity descriptions, then sets placeId from the returned google_place_id.
5. Geocoding
File: lib/geocode.ts
After validation/repair, geocodeItinerary() runs on the content object (mutating in-place):
-
Destination geocode — calls
lookupPlace(destination, { mode: "geocode" }). Geocode mode hits a cheaper Google API field mask (id, name, location only — no photos/reviews). StoresdestinationCoordsanddestinationPlaceIdon the root content object. -
Day-level geocode — for each
day.locationstring, callslookupPlace(location + ", " + destination, { mode: "geocode", bias: destCoords }). The location bias nudges the Places API toward the destination region. Storesday.coordsandday.placeId.
All day geocodes run in parallel via Promise.all.
These coordinates are used by the map view in the itinerary detail page.
6. Database Write
prisma.itinerary.create({
data: {
userId,
title: generatedContent.title,
destination,
tripType,
generatedContent: JSON.stringify(generatedContent),
startDate,
endDate,
}
})
The entire itinerary object (including all day/activity data with verified place IDs and geocoords) is serialised as a JSON string into the generated_content text column. There is no separate activities table — it's a single document.
7. Paywall & Display
File: app/dashboard/itinerary/[id]/page.tsx
After generation the user is redirected to /dashboard/itinerary/:id. The page checks itinerary.isUnlocked:
- Unlocked → renders full
<ItineraryContent>component with all days, map, place details - Locked → renders a teaser (overview + day titles) +
<UnlockPaywall>which gates the full content behind a Stripe purchase or credit spend
Key files reference
| File | Role |
|---|---|
components/dashboard/trip-generator-form.tsx | User-facing form, calls server action |
app/actions/generate-itinerary.ts | Main server action orchestrating the entire pipeline |
lib/itinerary-tools.ts | lookup_place tool factory with verified-ID tracking |
lib/google-places.ts | Cache read/write + Google Places API calls |
lib/geocode.ts | Post-generation coordinate resolution |
lib/extract-file-text.ts | Converts uploaded files to prompt text |
prisma/schema.prisma → Itinerary | Storage: generated_content text column |
prisma/schema.prisma → GooglePlacesCache | Place data cache keyed by google_place_id |
prisma/schema.prisma → HipPlace | Editor-curated places, influence AI recommendations |
How editors influence generation
Editors have two levers that shape every AI-generated trip:
-
Hip Places — added via
/editor. Places with rating ≥ 3 matching the destination are injected into the prompt with a "STRONGLY PREFER" instruction. When the AI callslookup_placefor one of these, the tool returnsis_editor_pick: truealong with the editor's notes and tags, which the AI uses to enrich the activity. -
Curated Trips — published trips from
/editor/tripsare injected as editorial examples. They shape the AI's tone, structure, and understanding of what makes a good HipTrip itinerary.
The /editor/recommended-places page shows which places are actually appearing in user trips, making it easy to identify high-frequency unvetted spots worth adding as Hip Places.