Back to Articles

Building a GPT-Powered Travel Advisor: A Reference Architecture Breakdown

[ View on GitHub ]

Building a GPT-Powered Travel Advisor: A Reference Architecture Breakdown

Hook

Most developers expose their OpenAI API keys on the first try. This reference architecture shows why Next.js API routes aren't just for data fetching—they're your security barrier between expensive AI calls and client-side chaos.

Context

When GPT-3 became publicly available in 2020-2021, developers rushed to integrate it into everything from chatbots to content generators. But the early ecosystem lacked practical examples of production-ready patterns. Most tutorials showed direct client-side API calls (a security nightmare) or oversimplified Flask backends that didn't translate to modern JavaScript stacks.

The travel industry presented a particularly compelling use case: itinerary generation, destination recommendations, and personalized trip planning all require the kind of contextual reasoning that GPT-3 excels at. Yet building such an application required solving several problems: securing API keys, crafting effective prompts for domain-specific tasks, managing response streaming, and handling the unpredictability of LLM outputs. Nader Dabit's gpt-travel-advisor emerged as a reference implementation showing how Next.js—already popular for its server-side rendering and API routes—could elegantly solve the architectural challenges of GPT integration while keeping the codebase TypeScript-strict and maintainable.

Technical Insight

The architectural pattern here is deceptively simple but instructive: the Next.js API route acts as a secure proxy that accepts user preferences from the frontend, constructs a domain-specific prompt, forwards it to OpenAI's API with the server-side key, and returns sanitized results. This three-layer separation—client, API route, external service—is the blueprint for any modern LLM integration.

The typical API route structure looks like this:

import type { NextApiRequest, NextApiResponse } from 'next'
import { Configuration, OpenAIApi } from 'openai'

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
})
const openai = new OpenAIApi(configuration)

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (!configuration.apiKey) {
    res.status(500).json({ error: 'OpenAI API key not configured' })
    return
  }

  const { destination, duration, interests } = req.body

  const prompt = `Create a ${duration}-day travel itinerary for ${destination}. 
The traveler is interested in: ${interests.join(', ')}.
Provide day-by-day recommendations with specific activities, restaurants, and tips.`

  try {
    const completion = await openai.createCompletion({
      model: 'text-davinci-003',
      prompt: prompt,
      max_tokens: 1000,
      temperature: 0.7,
    })

    res.status(200).json({ result: completion.data.choices[0].text })
  } catch (error: any) {
    res.status(500).json({ error: error.message })
  }
}

This pattern demonstrates several critical decisions. First, the API key lives exclusively in environment variables, never touching client code. Second, the prompt construction happens server-side, preventing users from crafting malicious or expensive prompts. Third, the temperature parameter (0.7) balances creativity with consistency—low enough to avoid bizarre suggestions, high enough to generate varied itineraries.

The prompt engineering strategy deserves attention. Travel recommendations require structured output, so effective prompts include formatting instructions. A more sophisticated version might look like:

const prompt = `You are an expert travel advisor. Create a detailed ${duration}-day itinerary for ${destination}.

Traveler interests: ${interests.join(', ')}

Format your response as:
Day 1:
- Morning: [activity]
- Afternoon: [activity]
- Evening: [restaurant/activity]
- Pro tip: [local insight]

Keep recommendations realistic and include specific venue names when possible.`

This structured prompt reduces the randomness of GPT-3's output and makes parsing easier on the frontend. The TypeScript types flowing through the system—from form inputs to API request bodies to response shapes—provide compile-time guarantees that the prompt template receives the expected data structure.

The frontend component handling user input likely uses React state and form validation before calling the API route. A simplified version demonstrates the flow:

const [itinerary, setItinerary] = useState<string>('')
const [loading, setLoading] = useState(false)

const generateItinerary = async (formData: TravelPreferences) => {
  setLoading(true)
  try {
    const response = await fetch('/api/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData),
    })
    const data = await response.json()
    setItinerary(data.result)
  } catch (error) {
    console.error('Failed to generate itinerary:', error)
  } finally {
    setLoading(false)
  }
}

This separation of concerns—UI state management, API communication, and AI processing—makes the codebase modular. You could swap OpenAI for Anthropic's Claude or Google's PaLM without touching the frontend. The API route becomes an adapter pattern, translating your application's domain model (travel preferences) into provider-specific API calls.

One architectural detail worth highlighting: response streaming isn't implemented in this reference. Modern OpenAI endpoints support streaming responses, where tokens arrive incrementally rather than waiting for the complete response. For a production travel app generating long itineraries, streaming dramatically improves perceived performance. The pattern would involve using createCompletion with stream: true and piping chunks to the client via Server-Sent Events or WebSockets, but that complexity exceeds what a reference architecture needs to demonstrate.

Gotcha

The biggest limitation is that this remains a reference implementation, not a production-ready application. There's no rate limiting, which means a malicious user could drain your OpenAI credits by hammering the API endpoint. There's no response caching, so identical requests to "create a 3-day Paris itinerary focused on museums" will cost you API calls every single time. For a real travel application, you'd want to hash prompts and cache responses in Redis or a similar store, potentially saving thousands of dollars monthly.

The model itself (text-davinci-003) is also outdated. Since this repository was created, OpenAI has released GPT-3.5-turbo, GPT-4, and GPT-4o, all offering better reasoning, lower costs, or both. The newer chat-based API format (createChatCompletion instead of createCompletion) provides better instruction-following and supports system messages, which would improve the travel advisor's consistency. Migration isn't trivial—it requires restructuring prompts from single strings to message arrays—but any fork of this project should prioritize the upgrade. Additionally, error handling is minimal. The try-catch block returns generic error messages, but in production you'd want to differentiate between rate limits (retry with backoff), context length errors (truncate input), and content policy violations (notify the user). TypeScript's discriminated unions would help model these different failure states cleanly.

Verdict

Use if: You're learning to integrate LLMs into Next.js applications for the first time and want a working example that shows the complete request flow from form submission to API route to OpenAI and back. This is ideal for understanding the proxy pattern and why server-side API key management matters. It's also useful as a foundation for hackathon projects or MVPs where you need a quick-start template and can tolerate using slightly older models. Skip if: You need production-grade features like streaming responses, rate limiting, caching, or GPT-4 support. Also skip if you're building anything beyond a learning project—the lack of documentation around prompt customization and the outdated OpenAI SDK version mean you'll spend more time updating dependencies than building features. For serious applications, start with Vercel's AI SDK or LangChain.js, which provide these patterns as batteries-included abstractions rather than reference code you'll need to heavily modify.

// ADD TO YOUR README
[![Featured on Starlog](https://starlog.is/api/badge/developer-tools/dabit3-gpt-travel-advisor.svg)](https://starlog.is/api/badge-click/developer-tools/dabit3-gpt-travel-advisor)