Why I built this

WanderPlan started as a practical itch. I was planning an extended trip through Portugal and Spain and found myself juggling spreadsheets, notes apps, and browser tabs to organize a 35-day itinerary. No single tool felt right — they were either too rigid, too opinionated, or just plain ugly.

I decided to build something myself. But beyond solving the travel planning problem, I had a secondary goal: get hands-on with the Next.js App Router and the patterns that come with it. I’d been following the ecosystem closely and wanted to form my own opinion about server components, client components, and where the boundary between them should actually live.

Choosing the stack

The stack wasn’t a difficult decision. I’d been working with TypeScript for a while and there was no question there. For styling, Tailwind CSS was the obvious choice — I wanted to move fast and keep styles co-located with components.

The more interesting decision was state management. Next.js 15 pushes you toward server-side data fetching, and for a lot of use cases that’s the right call. But a travel itinerary app has a fundamentally interactive data model — users are constantly adding, reordering, and editing days and destinations. That kind of UI-local mutable state needs to live on the client.

Why Zustand over Context or Redux

I considered React Context first. It’s built-in, no dependency, works fine for small state trees. But the itinerary state had some complexity: a list of days, each with a list of destinations, each with metadata. As soon as you have nested mutable state in Context, you start dealing with unnecessary re-renders and awkward update patterns.

Redux would have handled it cleanly but the boilerplate cost isn’t worth it for a personal project. Zustand hits the sweet spot — minimal API, no providers, plays well with TypeScript, and gives you fine-grained subscriptions out of the box. The store definition is clean and readable:

// store/itinerary.ts
import { create } from 'zustand'

interface Day {
  id: string
  title: string
  destinations: Destination[]
}

interface ItineraryStore {
  days: Day[]
  addDay: (day: Day) => void
  updateDay: (id: string, updates: Partial<Day>) => void
  removeDay: (id: string) => void
  reorderDays: (from: number, to: number) => void
}

export const useItineraryStore = create<ItineraryStore>((set) => ({
  days: [],
  addDay: (day) => set((state) => ({ days: [...state.days, day] })),
  updateDay: (id, updates) => set((state) => ({
    days: state.days.map((d) => d.id === id ? { ...d, ...updates } : d)
  })),
  removeDay: (id) => set((state) => ({
    days: state.days.filter((d) => d.id !== id)
  })),
  reorderDays: (from, to) => set((state) => {
    const days = [...state.days]
    const [moved] = days.splice(from, 1)
    days.splice(to, 0, moved)
    return { days }
  }),
}))

Clean, typed, and every component that needs the store just calls useItineraryStore(). No prop drilling, no wrapper hell.

Server vs. Client components

This is where Next.js 15 forces you to think carefully. The default in the App Router is server components — they render on the server, have no client-side JS, and can’t use hooks or browser APIs. Client components opt in with the 'use client' directive.

My approach was to keep the component tree as server-first as possible and push 'use client' down to leaf components that actually need interactivity. A page that renders the itinerary header, metadata, and layout can be a server component. The individual day cards that respond to drag-and-drop and edits are client components.

Rule of thumb

If a component reads from Zustand, responds to events, or uses any React hook — it’s a client component. Everything else defaults to server.

In practice this means I have a clear mental model for each component before I write it. I ask: does this component need to know about user interaction? If not, server. If yes, client. The boundary is a bit leaky in a few places (mostly around loading states and optimistic updates), but it holds for 90% of the codebase.

Component architecture

I organized the component structure around two layers:

This isn’t a new idea — it’s close to how most well-structured component libraries are organized. But having a clear rule up front prevents the drift that happens in side projects where everything ends up in a flat components/ folder.

Folder structure

app/
  (marketing)/
    page.tsx          ← landing / home
  itinerary/
    [id]/
      page.tsx        ← itinerary detail (server component)
      DayList.tsx     ← 'use client', reads from Zustand
      DayCard.tsx     ← 'use client', drag handles, inline edit
components/
  ui/
    Button.tsx
    Card.tsx
    Modal.tsx
    Input.tsx
store/
  itinerary.ts        ← Zustand store
lib/
  utils.ts

What I learned

A few things stood out after spending real time with the App Router:

What’s next

WanderPlan is still evolving. The next big piece is persistence — right now the store is ephemeral and resets on page reload. I’m evaluating Supabase for the backend, which would give me a hosted Postgres instance, auth, and real-time subscriptions without having to spin up and maintain infrastructure.

I’m also thinking about a map integration to visualize the route between destinations in a day — probably Mapbox or the Google Maps JavaScript API. The data model already supports coordinates, so it’s mostly a rendering problem.

The code is on GitHub. If you’re building something with the Next.js App Router and have questions about the architecture decisions, feel free to reach out.