Travel explorer & offline trip planner — a Progressive Web App built with Next.js 16
Voyago lets you explore destinations worldwide — with live weather and exchange rates — and build a day‑by‑day trip itinerary that keeps working with no internet connection at all. It's built as a frontend portfolio piece, so the emphasis throughout is on architecture and engineering decisions, not just shipping features.
Most portfolio apps stop at "fetch some data and render a list." Voyago was built to answer a more interesting question: what does it take for a data-heavy app to keep working when the network doesn't? That constraint touches almost every layer of the stack — state management, caching, service workers, and API design all have to cooperate.
A few decisions worth a reviewer's attention:
| Concern | Approach |
|---|---|
| One data layer, two protocols | A custom hybridBaseQuery lets a single RTK Query createApi instance serve both REST and GraphQL endpoints, with unified error handling and cache tags — see lib/redux/api/hybridBaseQuery.ts |
| Real, working offline support | A Serwist service worker with per-source caching strategies (see below), not a generic "cache everything" fallback |
| Offline-first persistence | Trips are written straight to IndexedDB via redux-persist + localforage, so they survive reloads and work with zero backend |
| Zero backend | Every data source is a free, keyless public API — the entire app runs from static hosting |
| SEO & metadata | generateMetadata, JSON-LD, dynamic OG images, ISR, sitemap.ts, robots.ts |
| Accessibility | Skip-to-content link, aria-* on interactive elements, keyboard-operable drag-and-drop, visible focus rings |
- Explore destinations — filterable grid of every country, backed by a GraphQL API, with flags/coordinates/population from a REST source and current weather layered on top
- Interactive maps — Leaflet, lazy-loaded so it never blocks the initial page load
- Live currency conversion — daily exchange rates, cached to stay usable offline
- Drag-and-drop itinerary board — plan a trip day by day, reorder or move activities between days with
@dnd-kit(mouse, touch, and keyboard) - True offline mode — install the app, go offline, and previously visited destinations, weather, exchange rates, map tiles, and your saved trips are all still there
- Custom design system — a "map & boarding pass" visual identity, CSS custom properties mirrored into Ant Design's theme tokens
- Light/dark theme with no flash-of-wrong-theme on load
This is the part most "PWA" side projects fake with a copy-pasted service worker, so here's the real breakdown. app/sw.ts defines per-source strategies instead of one blanket rule:
| Source | Strategy | Reasoning |
|---|---|---|
| REST Countries (flags, coordinates) | CacheFirst, 7-day expiry |
Effectively static data |
| Open-Meteo (weather) | StaleWhileRevalidate, 30-min expiry |
Show a cached value instantly, refresh quietly in the background |
| Frankfurter (exchange rates) | StaleWhileRevalidate, 6-hour expiry |
Changes a few times a day at most |
| OpenStreetMap tiles | CacheFirst, 14-day expiry |
Keeps recently viewed map areas usable offline |
| Everything else (app shell, pages) | Serwist's Next.js defaults | Battle-tested precaching for the app shell |
Trip data never touches the network layer at all — trips, days, and activities are Redux state persisted directly to IndexedDB, so creating or editing an itinerary works identically online or offline.
Note on
next build: Serwist doesn't yet have stable Turbopack support (serwist/serwist#54), so the production build runs on Webpack (next build --webpack) whilenext devstill uses Turbopack for fast local iteration. This is a deliberate, documented trade-off — seepackage.json.
- Wire the existing
offlineQueueRedux slice (already IndexedDB-persisted, currently unused) up to a real mutation-sync flow once/if a write-capable backend is introduced - Playwright coverage for the offline install → go offline → reload flow
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Server Components, ISR) |
| Language | TypeScript 5 (strict mode) |
| State & data | Redux Toolkit 2 · RTK Query (hybrid REST/GraphQL base query) · redux-persist → IndexedDB |
| UI | Tailwind CSS v4 · Ant Design 6 |
| Maps | Leaflet / react-leaflet (lazy-loaded) |
| Drag & drop | @dnd-kit |
| PWA | Serwist (Workbox's actively maintained successor) |
| Fonts | Fraunces · Manrope · JetBrains Mono |
Every source below is public and requires no API key — the app has no backend of its own.
| Source | Used for |
|---|---|
| countries.trevorblades.com | GraphQL — countries, continents, languages |
| restcountries.com | REST — flags, coordinates, population |
| open-meteo.com | REST — current weather |
| frankfurter.dev | REST — daily exchange rates |
| OpenStreetMap | Map tiles (cached by the service worker) |
git clone https://github.com/ali-afarinifard/voyago-travel-plan.git
cd voyago-travel-plan
npm install
cp .env.local.example .env.local
npm run devProduction build (service worker only runs in production):
npm run build && npm start| Command | What it does |
|---|---|
npm run dev |
Start the dev server (Turbopack) |
npm run build |
Type-check the service worker, then build for production (Webpack) |
npm start |
Serve the production build |
npm run lint |
Run ESLint |
node scripts/generate-icons.js |
Regenerate PWA icons and maskable variants |
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_SITE_URL |
Yes | Canonical deployment URL, used by sitemap.ts and OG metadata |
voyago/
├── app/ # App Router: routes, layouts, sw.ts, metadata files
│ ├── destinations/ # Explore grid + destination detail pages
│ ├── trips/ # Trip list, detail, and creation flow
│ ├── api/ # Route handlers (exchange-rate proxy)
│ └── sw.ts # Serwist service worker source
├── components/
│ ├── destinations/ # Explorer, filters, map, weather, currency widgets
│ ├── trips/ # Itinerary board, activity modal/list, budget bar
│ ├── pwa/ # Install prompt, offline indicator
│ ├── layout/ · ui/ # Header/footer/theme toggle, shared primitives
├── lib/
│ ├── redux/ # Store, slices, RTK Query APIs (incl. hybridBaseQuery)
│ ├── graphql/ # GraphQL documents
│ ├── theme/ # Theme provider + design tokens
│ └── types/ # Shared TypeScript types
├── public/icons/ # PWA icons (maskable + standard)
└── scripts/ # Icon generation