← Back to projects

Web App · NUS Orbital

Waypoint

Next.jsReactTypeScriptTailwind CSSshadcn/uiNode.jsPostgreSQLPrismaSupabaseLiveBlocksGoogle MapsGoogle PlacesGoogle Routesdnd-kitVercel
Waypoint screenshot

Why This Problem Matters

Planning a trip with friends usually means three tools fighting each other: a WhatsApp thread for deciding on attractions, a Google Doc nobody keeps updated, and a spreadsheet for who paid for what. None of them update in real time, and none of them talk to each other.

Waypoint is built for the group that doesn't want to become project managers just to plan a five-day trip: one shared itinerary, editable by everyone at once, with the routing and the money math handled underneath it. It's our NUS Orbital 2026 project, built by two of us (Waypoint Wizards), aiming for the Apollo level of achievement.

Creating a Trip and Finding Attractions

New trip form asking for a destination, start date, and end date

A trip starts with a destination and a date range. Behind that one form, the destination is geocoded first, then used to query the Google Places Text Search API for nearby attractions, restaurants, and points of interest, each with a rating, address, and review count.

Decoupling the free-text destination a user types from the structured location data Places needs was deliberate: it lets the same search box work whether someone types a city, a neighbourhood, or a specific landmark, without the user having to think about the difference.

The Interactive Map

Interactive Google Map showing attraction markers and a drawn route in Da Nang

Attractions show up as markers on a Google Map (@vis.gl/react-google-maps) next to the results list. Clicking a marker scrolls to its card; selecting a card pans the map to its coordinates. It sounds like a small detail, but keeping the list and the map in sync is what makes browsing attractions feel like one interface instead of two.

Drag-and-Drop Itinerary Builder

Attraction card being dragged into a day-by-day itinerary sidebar

Attractions get dragged straight from the results panel into a day-by-day sidebar built with @dnd-kit, and reordered within a day the same way. The sidebar grows to however many days the trip spans, so a two-day weekend and a two-week trip use the exact same builder.

Real-Time Collaboration: LiveBlocks Over Rolling Our Own

Diagram of the LiveBlocks real-time collaboration architecture, from browser tab to database

The core requirement was that multiple people editing the same itinerary see each other's changes immediately, not on refresh. Building that on raw WebSockets means owning conflict resolution, reconnection, and presence tracking yourself. A simple realtime database like Firebase gives you sync, but it's last-write-wins and has no concept of live cursors.

We went with LiveBlocks and modeled each trip as a LiveMap keyed by day, each holding a LiveList of LiveObjects for that day's stops, rather than one serialized blob for the whole itinerary. That structure matters: it lets LiveBlocks merge concurrent edits at the level of a single day's list instead of requiring a lock on the entire itinerary. Presence (who's viewing, where their cursor is) comes for free, and edits land across every connected client in about 100ms.

The editor itself is split into a TripClient shell, which calls the liveblocks-auth endpoint (checking the Collaborator table so only authorized users can join a room) and seeds initialStorage from the database on first visit, and a TripInner component holding all the live hooks (useStorage, useMutation, useMyPresence, useOthers). That split keeps the one-time "load from database" logic separate from the continuously-updating "live editing" logic.

ApproachConflict HandlingPresence/CursorsEffort
Raw WebSocketsBuild it yourselfBuild it yourselfHigh
Firebase Realtime DBLast-write-winsNot built inMedium
LiveBlocksCRDT-backed, built inBuilt inLow

Save-on-Demand, Not Autosave

Itinerary state lives in LiveBlocks storage first; an explicit Save button is what syncs it down to Postgres. With several people editing the same trip at once, a clear save boundary was easier to reason about than trying to merge a stream of continuous writes into a relational schema.

The honest tradeoff: it's save-on-demand, not autosave, so a change can be lost if nobody hits save before closing the tab. That's a known limitation, not an oversight. The alternative was a much harder consistency problem for a feature that isn't the core value of the app, and it's the first thing on the list if this ever needs to handle less careful users.

Where Every Trip Lives

User dashboard listing several saved trips as cards

Every saved trip shows up on a dashboard scoped to the signed-in user, backed by a single GET /api/get-user-trips/[userId] route. It's a small feature next to the collaborative editor, but it's the page most users actually land on first, so it stayed on the punch list for every milestone.

Authentication: Migrating Off a Custom JWT System Mid-Project

Login screen with email and password fields and a Sign in with Google button

Waypoint didn't start on Supabase Auth. The first version signed and verified its own JWTs with jose and bcrypt, stored in an HTTP-only cookie, all hand-rolled in src/lib/auth/tokens.ts. It worked, but every protected route had to call getCurrUserId() or verifyJWT() itself, and Google sign-in was still on the to-do list.

Partway through the project we migrated to Supabase Auth: email and password with sessions in Supabase's own HTTP-only cookies, plus Google OAuth, with session refresh handled centrally in src/proxy.ts (Next.js middleware), which also gates protected routes server-side. That's a smaller surface for us to get wrong, since token signing, hashing, and session refresh are no longer our code to maintain.

The migration itself was a substantial mid-project refactor: the custom tokens.ts module and its unit tests were deleted entirely, and every API route that previously checked a custom JWT had to be rewritten against Supabase's session retrieval instead. Google OAuth added its own edge case, since the first sign-in via OAuth has no matching User row yet, so we auto-create one from the Google profile (name and avatar), de-duplicating the name if it's already taken by another user.

Custom JWT (jose + bcrypt)Supabase Auth
Session handlingHand-rolled, our code to maintainManaged, HTTP-only cookies
Google OAuthNot implementedBuilt in
Failure surfaceEvery bug is ours to findDelegated to Supabase

Photo Uploads Stay Server-Side

Profile pictures upload through a server-side route, /api/upload-avatar, which writes to Supabase Storage using the service role key rather than uploading directly from the client. That pattern predates the auth migration (it originally worked around the custom JWT system being incompatible with Supabase's row-level security), but it stayed even after switching to Supabase Auth natively, simply because it keeps the service role key off the client entirely and it already worked.

Social System: Following, Mutual Friends, and Who Gets Invited

Socials page listing followers and following with a search box

Following is modeled as a directed, status-bearing relationship (PENDING or ACCEPTED) rather than a simple symmetric link. One Follow table ends up representing follow requests, one-directional follows, and mutual friendships all at once: a mutual friendship is just two ACCEPTED rows between the same pair of users, pointing in opposite directions. That's the gate we check before letting anyone invite a collaborator onto a trip.

Querying for mutual friends efficiently took more care than it first looked: naively checking both directions of a relationship for every candidate user doesn't scale as a friend list grows, so the query had to be written to do that check in one pass rather than one lookup per candidate.

The Follow Request Flow

Follow request modal with accept and reject buttons

Requests sit in a modal until they're accepted or rejected. Because inviting a collaborator requires mutual acceptance, the invite flow could never be designed independently of the social system: the two had to be built together from the start.

Collaborator Management: Optimistic UI, With a Rollback

The itinerary owner invites mutual friends as collaborators from a panel that updates the collaborator list immediately on invite or remove, before the server has confirmed anything. That optimistic update is what makes the panel feel instant, but it introduces the usual risk of the UI drifting from server state if a request fails after the UI has already changed. We handle that by rolling back the optimistic change on any error response from the invite or remove endpoints.

Routes, Transit Colors, and API Budget

Trip details panel showing a driving route between two stops, with a collaborators list

Once a day has attractions in it, Waypoint fetches walking, driving, and transit directions between every consecutive pair via the Google Routes API, and renders them as coloured polylines on the map. Transit segments render in the transit line's actual colour rather than one generic colour for all of them, so a multi-leg journey shows an MRT segment and a bus segment as visibly different at a glance.

Three transport modes times every consecutive pair of attractions, across every day of a trip, multiplies the number of Routes API calls quickly as a trip grows. Fetching every day in parallel, rather than serially across the whole trip, is what keeps that within reasonable latency and quota.

Profiles and a Feed That Doesn't Require Mutual Friends

Public user profile page showing follower and following counts and a list of posts

Public profile pages at /users/[id] show a name, avatar, and follow status, with a feed of that user's published trip posts. Feed visibility deliberately mirrors the app's one-directional follow model instead of the stricter mutual-friend gate used for collaborators: seeing someone's public posts is lower stakes than editing their itinerary, so a single accepted follow is enough.

Each post renders as an Instagram-style photo carousel that merges that post's individual photos with the trip's shared group photos into one ordered set. Combining two differently-scoped photo sources (post-scoped and trip-scoped) into a single carousel meant the feed query had to join across both tables rather than returning one relation, while staying capped and ordered by recency for performance.

The AI Planner: One Prompt, One Request

AI-generated multi-day itinerary for Seoul loaded into the itinerary sidebar with many attraction markers

The AI itinerary generator runs on DeepSeek and produces a full multi-day plan from a single request, rather than an iterative agent loop that calls out repeatedly while refining. DeepSeek was chosen mainly for cost: itinerary generation is a per-request LLM call, not a one-off feature, and keeping the marginal cost of every generated trip low mattered for a student project on a limited budget.

Generating the whole route in one request, instead of per day or per attraction, keeps the trip coherent, since the model reasons about the entire trip at once rather than losing context between separate calls. The result loads into the exact same drag-and-drop sidebar used for manual planning, so it's a starting point users can immediately adjust, not a fixed output.

The tradeoff is grounding: the model's output isn't checked against the Places API as it generates, so a generated attraction name isn't guaranteed to geocode. Mapping free-form model output back onto real, geocoded places was the main source of edge cases while building this feature.

Budget Tracking: A Hyphen-Shaped Bug

The budget tracker splits shared expenses equally and nets everything down to a "who owes who" summary between every pair of trip members. The balance-netting math lives in its own module, src/lib/balances.ts, pulled out of the BudgetPanel component specifically so it could be unit tested in isolation rather than only exercised indirectly through the UI, which is exactly what caught the bug below.

That netting logic looks up each pair's running balance by a key built from their two user IDs. The first version joined the two UUIDs with a hyphen. It passed every test written with short placeholder IDs, and broke silently against real UUIDs, which themselves contain hyphens, because the separator was no longer unambiguous. Two different pairs of users could produce a key that collided, quietly merging two unrelated balances into one.

SeparatorSafe with real UUIDs?Why
"-" (hyphen)NoUUIDs already contain hyphens, so the key boundary is ambiguous
"|" (pipe)YesNot a legal UUID character, so the split is always unambiguous

Three Decisions I'd Make Again

What held up under real use

  • LiveBlocks as the live source of truth, Postgres only on an explicit Save: a clear boundary beat trying to merge continuous writes.
  • Mutual friends as the gate for collaboration: it costs new users an extra step, but a trip carries real dates, budgets, and plans that shouldn't default to public.
  • AI-generated itineraries land in the same editable board as manual ones: a wrong suggestion is exactly as easy to fix as a wrong manual entry.

System Architecture

Layered system architecture diagram: frontend, backend API routes, real-time and storage layer, database, external APIs

The application is a layered Next.js app: pages and API routes both live in the same Next.js project, Prisma provides an ORM layer over a managed PostgreSQL database (hosted on Neon), and LiveBlocks sits on top as a real-time, in-memory collaboration layer over that persisted data. Supabase Storage handles user-uploaded photos through the same trusted server-side route pattern described above.

Tech Stack

LayerTechnology
FrontendNext.js 16, React 19, Tailwind CSS, shadcn/ui
BackendNext.js API Routes (Node.js)
DatabasePostgreSQL via Supabase (Prisma ORM)
AuthSupabase Auth (email/password + Google OAuth)
Real-TimeLiveBlocks (collaborative storage + presence/cursors)
File StorageSupabase Storage
MapsGoogle Maps API (@vis.gl/react-google-maps)
DirectionsGoogle Routes API (walking, driving, transit)
PlacesGoogle Places Text Search API
Drag & Drop@dnd-kit/core, @dnd-kit/sortable

Testing at This Scale

Terminal output showing all 22 test suites and 125 tests passing

125 automated tests across 22 suites (Jest + ts-jest): unit tests for JWT utilities and the trip, social, and user services, including the balance-key logic that caught the bug above, plus integration tests for the signup, login, collaborator, and mutual-friends API routes, and 8 manual end-to-end flows covering the rest of the app by hand against the live deployment.

We also ran user acceptance testing with two external testers across 7 real scenarios, from sign-up through collaborative editing and budget tracking. Every scenario scored 8 to 10 out of 10, and the same two pieces of feedback came up more than once: a desire for more onboarding guidance for new users, and a tooltip explaining the mutual-friends requirement before someone tries to invite a collaborator and gets stuck. That's the kind of gap you only find by watching someone use it for the first time.

What I'd Do Differently

LimitationWhy it's there nowWhat I'd reconsider
AI itinerary qualityBounded by the DeepSeek model's output, not grounded against Places as it generatesValidate or geocode generated names before they land in the sidebar
Collaboration needs mutual friendsDeliberate trust boundary so trip data isn't visible by defaultA tooltip or explainer at the point someone tries to invite, per user testing
Save-on-demand persistenceA clear save boundary was simpler than merging continuous writesAn autosave fallback if this ever needs to tolerate less careful users

Conclusion

Waypoint is live at waypoint-wizards.vercel.app, aiming for the Apollo level of achievement for NUS Orbital 2026. Weather-based itinerary adjustments are the one feature we scoped and didn't get to, carried forward past this milestone; everything else on the original feature list, including the extension features we weren't sure we'd reach, shipped.

Building this taught me more about real-time systems and mid-project migrations than any lecture could: LiveBlocks for the collaboration layer, and a full auth system swapped out from under a working app without anyone's session breaking.

Li Junyu

Waypoint Wizards, with Alpha Hong.