Overview
Travel Genie is a Next.js app for discovering travel destinations and activities — you pick a place, and an API route backed by an LLM suggests activities with a name, description, and emoji for each. It's a small personal project, but the API route behind the suggestions is the part worth talking about: it's written like something meant to survive real traffic, not a demo.
What I built
A defensive LLM-backed API route
src/app/api/places/route.ts calls out to an LLM (via OpenRouter) to generate activity suggestions, and treats the model's response the way you'd treat any untrusted external input rather than assuming it comes back clean. The response is stripped of markdown code fences the model sometimes wraps its output in, parsed as JSON, and validated field by field — each item has to be a proper object with name, description, and emoji all present and typed as strings, or the request fails loudly with a specific error rather than passing malformed data through to the UI.
In-memory rate limiting
A simple per-IP rate limiter caps requests to 10 per minute, resetting on cold start. It's deliberately lightweight — no Redis, no external store — which is the right call for a personal project's actual traffic profile, but it's there, which is the point: an API route that calls a paid LLM endpoint without any request limiting is one bad actor away from a surprising bill.
Data fetching and rendering
TanStack Query handles the client-side data fetching and caching around the places API, axios wraps the HTTP calls, and react-markdown/marked render any markdown-formatted content the model returns. Tailwind CSS handles styling, and the app runs on Next.js 15 with the App Router.
A proactive CVE patch
When CVE-2025-66478 was disclosed against Next.js, I upgraded the app to 15.5.12 to patch it before it became an active problem, rather than waiting for a dependency bot or an incident to force the issue. It's a one-line commit in the history, but it's the kind of housekeeping that's easy to skip on a personal project nobody's watching, and the habit is worth keeping regardless of who's watching.
Engineering decisions
Validating LLM output like any other external API response
It would have been simpler to JSON.parse the model's response and trust the shape. I didn't, because an LLM's output is not a typed API contract — it's text that's usually structured correctly, which is a different guarantee. Field-by-field validation with a clear error on failure means a malformed response fails the request cleanly instead of shipping broken data to a component that assumes well-formed activities.
Lightweight rate limiting over no rate limiting
The in-memory approach doesn't survive a server restart or scale across multiple instances, and I knew that going in. For a personal project fronting a paid API, "good enough and shipped" beat "correct and unbuilt" — the failure mode of the simple version (a reset counter on redeploy) is harmless, whereas the failure mode of skipping rate limiting entirely (an open door to a metered API) isn't.
Stack
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router) · TypeScript |
| Styling | Tailwind CSS |
| Data | TanStack Query · axios |
| AI | OpenRouter (LLM API) |
| Markdown | react-markdown / marked |
Reflections
The interesting engineering in a small AI-backed side project is rarely the AI call itself — that's a fetch with a prompt. It's everything around it: not trusting the response shape, not leaving an API route open to unbounded cost, and keeping dependencies patched without anyone forcing you to. None of that is exciting to write about, but it's the difference between a demo and something you'd be comfortable leaving running unattended.