Build an AI Travel Recommender
Call the Gemini API from JavaScript to build a deployed travel recommender.
Introduction
30 Second Summary
You're about to visit a new city. You open Google Maps, scroll through hundreds of pins, read a dozen blog posts, and still end up at tourist traps. What if you could just type your destination and travel style into an app and get a curated shortlist in seconds?
In this project, you will build an AI-powered travel recommender that calls the Gemini API to return personalized place recommendations based on a city and your travel preferences. You'll deploy it to a live URL you can actually use on your next trip.
What You'll Build
You type "Tokyo" and select "foodie", hit the button, and five specific restaurant and food district recommendations appear on screen, each with a description and an explanation of why it matches your style.
By the end of this project, you'll have:
- A live travel recommender at a shareable URL where you enter any city, pick your travel styles, and get 5 AI-generated recommendations you can browse on your phone.
- Hands-on experience calling an LLM API directly from JavaScript using fetch(), with no frameworks or libraries required.
- Practical prompt engineering skills that shape raw AI output into structured, reliable JSON your UI can display every time.
- Secret Mission: Add a trip duration selector that fundamentally changes what the AI recommends based on whether you have 1 day, 3 days, or a full week.
Are there any prerequisites?
You need basic familiarity with HTML (knowing what tags are). Some coding exposure is helpful but not required. GitHub Copilot will assist you along the way.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Development Environment
To build your travel recommender, you need a way to preview your web app locally as you build it. This lets you see changes instantly without deploying every time you tweak something.
In this step, you will install a VS Code extension called Live Server that launches a local web server and auto-refreshes your browser whenever you save a file. Then you will create your project folder and confirm everything works.
In this step, get ready to:
- Install the Live Server extension in VS Code.
- Create your project folder and a starter HTML file.
- Launch Live Server and confirm your page appears in the browser.
Install the Live Server extension
Extensions add extra capabilities to VS Code. Live Server gives you a one-click local web server that refreshes your browser every time you save.
- Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Type Live Server in the search bar at the top.
- Find the result by Ritwick Dey and click Install.
- Wait a few seconds until the button changes from Install to a gear icon, confirming installation is complete.
- Look at the very bottom of the VS Code window (the status bar). You should now see a Go Live button on the right side.
Don't see Go Live in the status bar?
Make sure you searched for the extension by Ritwick Dey specifically. There are several extensions with similar names.
Try closing and reopening VS Code if the button still does not appear.
Help me find the Live Server extension in VS Code.
Create your project folder and HTML file
You need a dedicated folder for your project files. You will create it on your Desktop so it is easy to find.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, located above Tab on most keyboards).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running this command:
mkdir travel-recommender
- Confirm the folder was created by running:
ls
You should see travel-recommender in the output.
- Open the new folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the travel-recommender folder, and click Open.
VS Code reloads with travel-recommender as your workspace. The Explorer sidebar on the left shows the empty folder.
- In the Explorer sidebar, hover over the folder name and click the New File... icon (a page with a plus sign).
- Type index.html and press Enter.
The file opens in the editor, ready for you to add code.
- Paste the following HTML into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Travel Recommender</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
What does this code do?
- The <!DOCTYPE html> declaration tells the browser this is a modern HTML5 page.
- The <meta name="viewport"> tag makes the page scale correctly on mobile devices.
- The <h1>Hello World</h1> is a visible heading that proves the page is loading.
- Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html and that it appears in the Explorer sidebar on the left.
ⓧ I'd like to double check the full code
Your index.html should look exactly like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Travel Recommender</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
Don't see the file in the sidebar?
Make sure you opened the travel-recommender folder (not your entire Desktop) with File > Open Folder.
If the Explorer sidebar is hidden, press Cmd+B (macOS) or Ctrl+B (Windows) to toggle it.
Help me create index.html in my project folder.
Launch your app with Live Server
Everything is in place. Before you click the button, take a guess: what do you think will appear in your browser?
- Click the Go Live button in the bottom-right of the VS Code status bar.
Your default browser opens to a page at localhost:5500 (or a similar port) showing a large Hello World heading. That confirms Live Server is working and serving your HTML file.
What is localhost?
When you see localhost:5500 in your browser address bar, it means your computer is acting as its own web server. The page is not on the internet yet. Only you can see it.
Browser did not open?
Check that you saved index.html before clicking Go Live. Live Server needs at least one saved HTML file to start.
If the button says Port: 5500 instead of Go Live, the server is already running. Open your browser manually and go to http://127.0.0.1:5500.
Help me get Live Server running in my browser.
Your development environment is ready. Next up, you will connect to the Gemini AI and get your first travel recommendations flowing.
Get Your AI Recommendations Working
Your development environment is set up and Live Server is running. Now it is time to prove the core concept: can you ask an AI for travel recommendations and get a real answer back?
Before adding any styling or features, you want to see Gemini actually return travel recommendations for a city you type in. This proves the concept works and gives you something tangible to build on.
In this step, get ready to:
- Get a free Gemini API key from Google AI Studio.
- Add a form with a city input and submit button to your page.
- Write JavaScript that calls the Gemini API and displays raw recommendations.
Get your free Gemini API key
To call the Gemini API, you need an API key that authenticates your requests. Google provides this for free through Google AI Studio.
- Open your browser and go to aistudio.google.com.
- Sign in with your Google account.
- Click Get API Key in the left sidebar.
- Click Create API key.
- If prompted to select a project, choose Create API key in new project.
- Copy the API key that appears. Keep this tab open. You will paste the key into your code shortly.
What is an API key?
An API key is a unique string that identifies your app to a service. It lets Google know who is making the request so they can track usage and enforce limits.
The Gemini free tier gives you free input and output tokens with no billing required. You do not need to enter a credit card.
Keep your key private
For this learning project, you will paste the key directly into your code. This is fine for personal projects on the free tier. For a production app, you would hide the key behind a backend server.
Add a form to your page
Right now your page just says "Hello World". You need a text input where the user types a city name and a button to submit it.
- In VS Code, open index.html and replace its entire contents with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Page title shown in the browser tab -->
<title>AI Travel Recommender</title>
</head>
<body>
<h1>AI Travel Recommender</h1>
<!-- Form captures city input and triggers JavaScript on submit -->
<form id="travel-form">
<input type="text" id="city-input" placeholder="e.g., Tokyo, Paris, New York" required>
<button type="submit">Get Recommendations</button>
</form>
<!-- Empty div where AI results will appear -->
<div id="results"></div>
<!-- Load JavaScript file -->
<script src="app.js"></script>
</body>
</html>
What does this code do?
- The <form> wraps the input and button together so pressing Enter or clicking the button triggers a submit event.
- The required attribute on the input prevents submission if the field is empty.
- The <div id="results"> is an empty container. Your JavaScript will fill it with the AI response.
- The <script src="app.js"> tag loads the JavaScript file you will create next.
- Save index.html.
- Check your browser. Live Server auto-refreshes the page. You should see the heading "AI Travel Recommender", a text input, and a "Get Recommendations" button.
Don't see the form?
- Make sure you saved the file (Cmd+S on macOS, Ctrl+S on Windows).
- Confirm Live Server is still running. Look for "Port: 5500" in the VS Code status bar. If it is not there, click Go Live to restart it.
- Check that you replaced all the old HTML, not just part of it.
Why isn't my form appearing in the browser?
Connect the form to the Gemini API
The form is ready. Now you need JavaScript that listens for the submit event, sends the city name to the Gemini API, and shows the response on the page.
- Create a new file in your travel-recommender folder called app.js. In VS Code, click the new file icon in the Explorer sidebar and type app.js.
- Paste the following code into app.js:
// Your Gemini API key and endpoint
const API_KEY = "your-gemini-api-key-here";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";
// Grab references to the form and results container
const form = document.getElementById("travel-form");
const resultsEl = document.getElementById("results");
// Listen for form submission
form.addEventListener("submit", async (e) => {
e.preventDefault();
const city = document.getElementById("city-input").value.trim();
if (!city) return;
const text = await getRecommendations(city);
resultsEl.textContent = text;
});
// Call the Gemini API with a basic prompt
async function getRecommendations(city) {
const prompt = `Recommend 5 places to visit in ${city}`;
const response = await fetch(`${API_URL}?key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
});
const data = await response.json();
return data.candidates[0].content.parts[0].text;
}
- Replace your-gemini-api-key-here with the API key you copied from Google AI Studio. Keep the quotes around it.
What does this code do?
- API_KEY stores your personal key. API_URL points to the Gemini REST API endpoint for the gemini-2.0-flash model.
- The event listener intercepts the form submit, grabs the city text, and passes it to getRecommendations().
- getRecommendations() uses fetch() to send a POST request with your prompt wrapped in the format Gemini expects: {contents: [{parts: [{text: ...}]}]}.
- The response text is extracted from data.candidates[0].content.parts[0].text and displayed inside the results div.
Before you test, what do you think will happen when you type "Paris" and click the button?
- Save app.js.
- Switch to your browser (Live Server should auto-refresh the page).
- Type Paris in the input field and click Get Recommendations.
After a few seconds, you should see a block of raw unformatted text appear below the button with 5 place recommendations for Paris. The text will be unstructured and plain. That is expected at this stage.
Not seeing any recommendations?
- Open your browser's Developer Tools (Cmd+Option+I on macOS, F12 on Windows) and check the Console tab for errors.
- If you see a 400 or 401 error, double-check that you pasted your API key correctly between the quotes in app.js. Make sure there are no extra spaces.
- If you see a CORS error or a network error, confirm you are using the ?key= query parameter format in the URL (not a header).
- If the page is blank and nothing happens on click, make sure app.js is in the same folder as index.html (not inside a subfolder).
Why am I getting an error when I click Get Recommendations?
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Travel Recommender</title>
</head>
<body>
<h1>AI Travel Recommender</h1>
<form id="travel-form">
<input type="text" id="city-input" placeholder="e.g., Tokyo, Paris, New York" required>
<button type="submit">Get Recommendations</button>
</form>
<div id="results"></div>
<script src="app.js"></script>
</body>
</html>
const API_KEY = "your-gemini-api-key-here";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";
const form = document.getElementById("travel-form");
const resultsEl = document.getElementById("results");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const city = document.getElementById("city-input").value.trim();
if (!city) return;
const text = await getRecommendations(city);
resultsEl.textContent = text;
});
async function getRecommendations(city) {
const prompt = `Recommend 5 places to visit in ${city}`;
const response = await fetch(`${API_URL}?key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
});
const data = await response.json();
return data.candidates[0].content.parts[0].text;
}
You just made your first AI-powered feature: type a city, get recommendations. The output is raw and unformatted, but the concept works. Next up, you will craft a smarter prompt that returns structured, styled recommendations.
Craft a Prompt That Returns Structured Recommendations
Your app can talk to Gemini and get a wall of text back. That proves the API works. But raw, unformatted text is impossible to style or structure in a consistent UI.
In this step, you will rewrite your prompt so Gemini returns structured JSON data. You will also add travel style preferences and render each recommendation as a clean card. This is prompt engineering in action: shaping AI output into something your code can reliably use.
In this step, get ready to:
- Add travel style checkboxes so users can customize their recommendations.
- Design a structured prompt that returns predictable JSON from Gemini.
- Parse and display recommendations as styled cards.
Rebuild the HTML with travel style checkboxes
Your current HTML is a bare-bones form. You need to add travel style options, proper structure, and placeholder elements for loading and error states that you will wire up later.
- Open index.html in VS Code.
- Select all the existing content and replace it with this page structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Travel Recommender</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>AI Travel Recommender</h1>
<p class="subtitle">Get personalized travel recommendations powered by AI</p>
<form id="travel-form">
<div class="form-group">
<label for="city-input">Where are you going?</label>
<input type="text" id="city-input" placeholder="e.g., Tokyo, Paris, New York" required>
</div>
What does this code do?
The <link rel="stylesheet" href="style.css"> tag connects a stylesheet you will create shortly.
The .container div wraps everything for centered layout. The .form-group div groups the label and input together.
- Continue directly below by adding the travel style checkboxes and the rest of the page:
<div class="form-group">
<label>What is your travel style?</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="style" value="foodie"> Foodie
</label>
<label class="checkbox-label">
<input type="checkbox" name="style" value="culture"> Culture
</label>
<label class="checkbox-label">
<input type="checkbox" name="style" value="adventure"> Adventure
</label>
<label class="checkbox-label">
<input type="checkbox" name="style" value="nightlife"> Nightlife
</label>
<label class="checkbox-label">
<input type="checkbox" name="style" value="relaxation"> Relaxation
</label>
</div>
</div>
<button type="submit" id="submit-btn">Get Recommendations</button>
</form>
<div id="loading" class="loading hidden">
<div class="spinner"></div>
<p>Finding recommendations...</p>
</div>
<div id="error" class="error hidden"></div>
<div id="results" class="results"></div>
</div>
<script src="app.js"></script>
</body>
</html>
What does this code do?
- Each checkbox has name="style" so your JavaScript can query all checked styles at once.
- The loading and error divs are hidden by default (class="hidden"). You will show them with JavaScript in the next step.
- The results div is where your recommendation cards will appear.
- Save index.html.
- Refresh your browser. You should see the city input, five travel style labels (Foodie, Culture, Adventure, Nightlife, Relaxation), and a submit button. They will look unstyled for now.
Don't see the checkboxes?
- Make sure you replaced the entire file content. The old <h1> tag outside a .container div should be gone.
- Check that the two code blocks above are pasted together as one continuous file with no gap or duplication.
- help me fix my index.html checkboxes
Rewrite the prompt for structured JSON output
The key insight of prompt engineering is this: if you tell the AI exactly what format you want, it will comply. Your new prompt will ask Gemini to return a JSON array with specific fields, so your code can parse and display each recommendation predictably.
- Open app.js and replace its entire contents with this updated code:
const API_KEY = "your-gemini-api-key-here";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";
// Cache references to DOM elements
const form = document.getElementById("travel-form");
const submitBtn = document.getElementById("submit-btn");
const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error");
const resultsEl = document.getElementById("results");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const city = document.getElementById("city-input").value.trim();
if (!city) return;
// Read which travel styles the user checked
const styles = Array.from(document.querySelectorAll('input[name="style"]:checked'))
.map((cb) => cb.value);
const styleText = styles.length > 0
? styles.join(", ")
: "general sightseeing";
resultsEl.innerHTML = "";
const recommendations = await getRecommendations(city, styleText);
displayResults(recommendations);
});
What does this code do?
- The new DOM references (submitBtn, loadingEl, errorEl) prepare for loading and error states you will add in the next step.
- The querySelectorAll line finds every checked checkbox named style and collects their values into an array.
- If no styles are checked, it defaults to "general sightseeing" so the prompt always makes sense.
- Continue directly below the form handler by adding the getRecommendations and displayResults functions:
async function getRecommendations(city, styleText) {
// Build a structured prompt that requests JSON output
const prompt = `You are a knowledgeable travel guide. Recommend exactly 5 places to visit in ${city} for a traveler whose style is: ${styleText}.
Return your response as a JSON array with exactly 5 objects. Each object must have these fields:
- "name": the name of the place
- "description": a 1-2 sentence description of what makes this place special
- "matchReason": a 1 sentence explanation of why this matches the traveler's style
Return ONLY the JSON array, no other text or markdown formatting.`;
const response = await fetch(`${API_URL}?key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.8 }
})
});
const data = await response.json();
const text = data.candidates[0].content.parts[0].text;
// Extract the JSON array from the response text
const jsonMatch = text.match(/\[[\s\S]*\]/);
return JSON.parse(jsonMatch[0]);
}
Why does the prompt ask for JSON?
Without format instructions, Gemini returns freeform paragraphs. By specifying exact field names (name, description, matchReason), you get predictable data your code can loop through and display.
The temperature: 0.8 setting adds creative variety. Lower values make output more predictable. Higher values make it more surprising.
The regex /\[[\s\S]*\]/ extracts just the JSON array from the response, in case Gemini adds any extra text around it.
- Add the displayResults function directly below getRecommendations:
function displayResults(recommendations) {
// Build HTML cards from the JSON data
resultsEl.innerHTML = recommendations
.map((rec) => `
<div class="card">
<h3>${rec.name}</h3>
<p>${rec.description}</p>
<p class="match-reason">${rec.matchReason}</p>
</div>
`)
.join("");
}
What does this code do?
This function takes the parsed JSON array and maps each recommendation into an HTML card using template literals. Each card displays the place name, description, and a reason it matches the selected travel style.
- Make sure your API_KEY value on line 1 still contains your actual Gemini API key (not the placeholder text).
- Save app.js.
- Refresh your browser, type Tokyo in the city field, check Foodie, and click Get Recommendations. You should see 5 recommendations appear as plain text blocks (no styling yet).
Seeing a blank page or console error?
- Open DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows) and check the Console tab for errors.
- If you see a TypeError about null, confirm that id="submit-btn" exists on your button in index.html.
- If recommendations do not appear, check that your API key is pasted correctly and has no extra spaces.
- help me debug my Gemini API call for structured JSON
Style the recommendations as cards
Your recommendations work but look plain. A stylesheet will transform the page into a polished, mobile-friendly interface with pill-shaped checkboxes and card-based results.
- Create a new file called style.css in the same folder as index.html.
- Add the base page styles by pasting this into style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 24px 16px;
}
h1 {
font-size: 1.8rem;
margin-bottom: 4px;
}
.subtitle {
color: #666;
margin-bottom: 24px;
}
What does this code do?
The reset (* selector) removes default browser spacing so everything starts from zero. The .container centers the page at a phone-friendly 600px max width.
- Continue below by adding the form and checkbox styles:
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 6px;
}
input[type="text"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
input[type="text"]:focus {
outline: none;
border-color: #4a90d9;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
- Continue with the checkbox pill and button styles:
.checkbox-label {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: #fff;
border: 1px solid #ddd;
border-radius: 20px;
cursor: pointer;
font-weight: normal;
}
.checkbox-label:has(input:checked) {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}
.checkbox-label input {
display: none;
}
button[type="submit"] {
width: 100%;
padding: 14px;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
How do the checkbox pills work?
The actual checkbox input is hidden with display: none. The visible pill is the .checkbox-label wrapper. When a hidden checkbox inside it is checked, the :has(input:checked) selector turns the pill blue.
- Save style.css and refresh your browser.
- You should see a clean form with rounded input, blue pill-shaped checkboxes that highlight when clicked, and a blue submit button.
- Continue in style.css by adding the button hover state, loading spinner, and utility classes:
button[type="submit"]:hover {
background: #357abd;
}
button[type="submit"]:disabled {
background: #999;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 32px 0;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #ddd;
border-top-color: #4a90d9;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 12px;
border-radius: 8px;
margin-top: 16px;
}
- Finally, add the result card styles at the bottom of style.css:
.hidden {
display: none;
}
.results {
margin-top: 24px;
}
.card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.card h3 {
font-size: 1.1rem;
margin-bottom: 6px;
color: #222;
}
.card p {
color: #555;
margin-bottom: 8px;
}
.card .match-reason {
font-size: 0.9rem;
color: #4a90d9;
font-style: italic;
}
What do the card styles do?
Each .card gets a white background with rounded corners and a subtle shadow, making it float above the gray page. The .match-reason text is styled in blue italic to visually separate the "why this fits you" explanation from the description.
- Save style.css.
Before you test, what do you think will happen when you submit "Tokyo" with "foodie" checked? Will the recommendations look different from the raw text you saw in the previous step?
- Refresh your browser, type Tokyo, check Foodie, and click Get Recommendations.
You should see 5 recommendations appear as distinct white cards, each with a bold place name, a description, and a blue italic "why this matches you" line.
Cards not styled or still seeing raw text?
- Check that <link rel="stylesheet" href="style.css"> appears in your index.html head section. Without it, the browser cannot find your styles.
- Confirm your style.css file is in the same folder as index.html (not in a subfolder).
- If you see raw text instead of cards, open the Console in DevTools and check for a JSON parsing error. This means Gemini's response was not valid JSON. Try submitting again.
- help me debug my travel recommender card styling
✔️ Awesome, I've got everything!
Great. Make sure all three files (index.html, app.js, style.css) are saved.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain these sections in order:
- index.html: page head with stylesheet link, container with form (city input group + checkbox group + submit button), then loading, error, and results divs.
- app.js: constants and DOM references, form submit handler that reads styles, getRecommendations function with the structured JSON prompt, and displayResults function that builds card HTML.
- style.css: reset and body styles, container and typography, form-group and input styles, checkbox-label pills, button styles, loading spinner and animation, error and hidden utilities, card and match-reason styles.
Your app now returns structured, styled recommendations based on user preferences. Next up, you will handle what happens when things go wrong: loading states, error messages, and input validation.
Handle Edge Cases and Polish the Experience
Your app successfully calls the Gemini API and renders travel recommendations. But what happens when the network drops or the API returns garbage?
Right now, the user sees nothing. A real product handles errors gracefully. In this step, you will add loading feedback, error messages, and defensive checks so the app behaves like something you would actually ship.
In this step, get ready to:
- Add a loading spinner and disable the button while the API responds.
- Add try/catch error handling with a friendly error message.
- Verify the app recovers gracefully from a network failure.
Add loading state and error handling to the submit handler
The form handler currently calls the API and renders results with no feedback in between. If the request takes a few seconds, the user has no idea anything is happening.
- In app.js, find this code inside your form.addEventListener block (the last four lines before the closing });):
resultsEl.innerHTML = "";
const recommendations = await getRecommendations(city, styleText);
displayResults(recommendations);
- Replace those four lines with the following code, which adds loading feedback and wraps the API call in error handling:
// Disable button and show spinner while waiting
submitBtn.disabled = true;
loadingEl.classList.remove("hidden");
errorEl.classList.add("hidden");
resultsEl.innerHTML = "";
try {
const recommendations = await getRecommendations(city, styleText);
displayResults(recommendations);
} catch (err) {
// Show a friendly message instead of a cryptic error
errorEl.textContent = "Something went wrong. Please check your connection and try again.";
errorEl.classList.remove("hidden");
} finally {
// Always re-enable the button and hide the spinner
submitBtn.disabled = false;
loadingEl.classList.add("hidden");
}
What does this code do?
- submitBtn.disabled = true grays out the button so the user cannot double-click while a request is in flight.
- loadingEl.classList.remove("hidden") reveals the spinner you already have in your HTML.
- try/catch catches any error thrown during the API call and shows a human-readable message instead of crashing silently.
- finally runs whether the call succeeds or fails. It always re-enables the button and hides the spinner.
- Save app.js.
- Switch back to your browser (Live Server auto-refreshes).
- Type a city name and click Get Recommendations.
You should see the button turn gray and a spinner appear below the form. After a few seconds, the recommendations load and the spinner disappears.
Spinner not showing?
- Make sure you removed the old resultsEl.innerHTML = ""; line and the two lines below it. The new code replaces all four lines, not just one.
- Check for a red error in the browser console (right-click the page, click Inspect, then the Console tab). A syntax error means a missing brace or parenthesis.
- Help me debug my loading spinner code.
Add defensive checks to the API function
The try/catch in your submit handler catches errors, but getRecommendations never actually throws one. If the API returns a bad status code or malformed JSON, the function silently fails.
- In app.js, scroll down to your getRecommendations function.
- Find this line (right after the closing }); of the fetch call):
const data = await response.json();
- Add this check directly above that line (between the fetch closing }); and const data):
// Throw an error if the API returned a failure status
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
- Now find these two lines near the bottom of the same function:
const jsonMatch = text.match(/\[[\s\S]*\]/);
return JSON.parse(jsonMatch[0]);
- Replace those two lines with this version that checks for missing JSON before parsing:
// Check that the response actually contains a JSON array
const jsonMatch = text.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
throw new Error("Could not parse recommendations");
}
return JSON.parse(jsonMatch[0]);
What do these checks do?
- if (!response.ok) catches HTTP errors (like a 401 for a bad API key, or a 500 from the server). Without it, the code would try to parse an error page as JSON.
- if (!jsonMatch) catches cases where Gemini returns text that does not contain a JSON array. Instead of crashing on null[0], it throws a clear error that your catch block displays as a friendly message.
- Save app.js.
- Switch to your browser, type a city, and click Get Recommendations to confirm the app still works normally.
You should see the spinner, then recommendations appear as cards. The new checks only fire when something goes wrong.
Getting an error on every request now?
- Make sure the if (!response.ok) block sits between the }); closing the fetch and the const data line. Placing it inside the fetch options object causes a syntax error.
- Check the browser console for a specific error message. If it says the API request failed with a status code, your API key may have expired or been revoked.
- Help me fix my getRecommendations error checks.
Test error recovery
The real test of error handling is seeing it work. You are going to simulate a network failure and confirm the app tells the user what happened instead of dying silently.
Before you run this test, what do you think will happen when the fetch request cannot reach the Gemini server?
- Disconnect your computer from wifi (turn wifi off in your system settings).
- In your browser, type a city name and click Get Recommendations.
You should see the spinner appear briefly, then a red error box with the message "Something went wrong. Please check your connection and try again." The button re-enables so the user can retry.
- Reconnect to wifi.
- Click Get Recommendations again with the same city.
The error message disappears and recommendations load as normal. The app recovers without a page refresh.
Why does this matter for AI products?
AI APIs are inherently unreliable. They time out, return unexpected formats, and hit rate limits. The difference between a prototype and a product is how gracefully it handles the unhappy path.
What about the input validation and mobile CSS?
Your HTML already has required on the city input, and your JavaScript already has the if (!city) return check. Try clicking the button with an empty field to confirm the browser blocks submission.
Your CSS is already mobile-responsive thanks to the max-width: 600px container and percentage-based widths. Open your browser DevTools, click the device toggle icon, and preview the page at a phone width to confirm it looks good.
Error message not appearing when offline?
- Make sure you fully disconnected wifi. Some operating systems keep a cached connection for a few seconds.
- Check that your catch block sets errorEl.classList.remove("hidden"). If the error div stays hidden, your message won't appear even though it was set.
- My error handling is not showing the error message when I disconnect wifi.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js and that both the normal flow (recommendations load) and the error flow (friendly message appears) work.
ⓧ I'd like to double check the full code
Scroll up and confirm your app.js contains these sections in order:
- The API_KEY and API_URL constants at the top.
- The five DOM element constants (form, submitBtn, loadingEl, errorEl, resultsEl).
- The form submit listener with submitBtn.disabled = true, loading toggle, and try/catch/finally wrapping getRecommendations.
- The getRecommendations function with if (!response.ok) and if (!jsonMatch) checks.
- The displayResults function rendering cards.
Your app now handles errors like a real product. Next up, you will push this to a live URL so you can actually use it on your next trip.
Deploy to Vercel
Your travel recommender handles errors, shows loading states, and looks great on mobile. Everything works locally on your machine.
A project only counts if someone else can see it. In this step, you will push your code to GitHub and deploy it to Vercel, giving you a shareable URL you can put on your resume, show in interviews, or actually use on your next trip.
In this step, get ready to:
- Push your code to GitHub.
- Deploy to Vercel with the free Hobby plan.
- Test the live URL on your phone.
Push your project to GitHub
To deploy on Vercel, your code needs to live in a Git repository on GitHub. You will initialize a local repo, create a remote on GitHub, and push your code up.
- Open the terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Initialize your git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"
- Confirm your commit exists by running:
git log --oneline
You should see one line showing your commit message.
Seeing an error about user identity?
If Git asks you to set your name and email, run these two commands (replacing with your details), then try the commit again:
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
help me fix a Git identity error when committing
Now you need a place to push this code. You will create a new empty repository on GitHub.
- Open your browser and go to github.com.
- In the upper-right corner, click your profile icon, then click New repository.
- In the Repository name field, type travel-recommender.
- Select Public for the visibility.
- Leave all other options unchecked (do not add a README, .gitignore, or license).
- Click Create repository.
Why leave everything unchecked?
You already have code locally that you want to push up. If GitHub adds a README or other files to the repo, it creates a conflict with your local commit. An empty repo lets you push cleanly.
After creating the repo, GitHub shows a "Quick setup" page with commands you can copy.
- Copy the HTTPS URL shown on that page (it looks like https://github.com/your-username/travel-recommender.git).
- Back in your VS Code terminal, connect your local repo to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/travel-recommender.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- `git remote add origin` tells your local repo where to push code to on GitHub.
- `git branch -M main` renames your branch to main (the default branch GitHub expects).
- `git push -u origin main` uploads all your code to GitHub.
Seeing a browser popup asking you to sign in?
Git may open a browser window to authenticate with GitHub. Sign in and authorize access when prompted. This is normal and only happens the first time.
- Refresh the GitHub page in your browser.
You should see your three files (index.html, style.css, app.js) listed in the repository.
Files not showing on GitHub?
Make sure you copied the correct HTTPS URL from the Quick setup page. If you see an error about the remote already existing, run git remote -v to check what URL is set.
help me push my code to GitHub
Deploy to Vercel
With your code on GitHub, you can now connect it to Vercel for instant deployment. Vercel will host your static site for free on its Hobby plan.
- Open vercel.com in your browser.
- Click Sign Up.
- Select Continue with GitHub to create your Vercel account using your GitHub login.
- Choose the free Hobby plan when prompted.
Once you land on the Vercel dashboard, you will import your GitHub repo.
- Click Add New… then select Project.
- Find travel-recommender in the list of repositories and click Import.
Not seeing your repository?
If your repo is not listed, click the link to adjust GitHub app permissions and grant Vercel access to the travel-recommender repository.
You are now on the project configuration page. You need to tell Vercel this is a plain static site with no build step.
- Set the Framework Preset dropdown to Other.
- Open the Build and Output Settings section.
- Toggle the Build Command override on, and leave the field empty.
- Click Deploy.
Why these settings?
Your project is plain HTML, CSS, and JavaScript with no framework or build process. Setting the preset to Other and leaving the build command empty tells Vercel to serve your files exactly as they are, without trying to compile anything.
Vercel will take about 30 seconds to deploy. When it finishes, you will see a congratulations page with a preview of your site and your live URL.
Test your live URL on your phone
The real proof is using your app on a device you would actually travel with.
- Copy the URL shown on the Vercel success page (it looks like travel-recommender-xxxx.vercel.app).
- Open that URL on your phone's browser.
Before you submit a query, do you think the app will behave the same way it does locally? Consider what is different now that the code is served from Vercel instead of your machine.
- Type a city name (try Tokyo) and select a travel style.
- Tap Get Recommendations.
You should see the loading spinner appear, followed by 5 recommendation cards within a few seconds. Your app is now live on the internet.
Not seeing recommendations on the live site?
Open your browser's developer console on your phone (or on desktop using the Vercel URL) and check for errors. The most common issue is the API key not being in your deployed app.js file. Confirm your key is in the code you pushed to GitHub.
help me debug why my Vercel deployment is not returning AI recommendations
Secret mission
Add Trip Duration Intelligence
Your recommender treats every trip the same, whether someone has an afternoon or a full week. Add a duration selector that fundamentally changes what Gemini recommends. A one-day trip should suggest a tight walkable cluster. A week-long trip should include day trips and hidden gems.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local travel-recommender folder on your Desktop.
- Gemini API key (free tier, no billing attached).
- GitHub repository.
- Vercel project (free Hobby plan).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep using your travel recommender on trips.
- Your Vercel site stays live indefinitely on the free Hobby plan.
- The Gemini API key remains free as long as you stay within the free tier limits.
- Your GitHub repository stays available for future edits.
- The local travel-recommender folder stays on your Desktop.
Pause - I'll come back to this later
Everything in this project is free and serverless, so there is nothing to pause. Your Vercel site, GitHub repo, and API key all remain dormant at no cost until you use them again.
- No action needed. Come back whenever you want to make changes.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean everything up.
- Delete the local project folder. On macOS, open Terminal and run the command below. On Windows, open PowerShell and run the Windows command below.
macOS
rm -rf ~/Desktop/travel-recommender
Windows
Remove-Item -Recurse -Force "$HOME\Desktop\travel-recommender"
- Delete your Gemini API key. Open the Google Cloud API Credentials page at console.cloud.google.com/apis/credentials.
- Find the API key you created for this project and click the Actions icon (three dots).
- Select Delete API key.
- In the Delete credential modal, click Delete to confirm.
- Delete your GitHub repository. Go to your repository page on github.com and click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository, then click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
- Delete your Vercel project. Go to vercel.com and select your project from the dashboard.
- Click Settings in the sidebar.
- At the bottom of the General page, find the Delete Project section and click Delete.
- Enter your project name and the confirmation phrase, then click Continue.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a complete AI-powered travel recommender from scratch.
You've learned how to:
- Called the Gemini API directly using fetch() with no frameworks or libraries, proving you can integrate any LLM into a web app.
- Designed structured prompts that return reliable JSON output, turning unpredictable AI text into data your UI can render consistently.
- Deployed a live web app to Vercel using GitHub integration, giving you a shareable URL you can open on your phone or put on your resume.
- Secret Mission: Added context-aware recommendations that change based on trip duration, transforming a simple list tool into a smarter AI product.
Ready to quiz yourself?