An interactive world map showcasing coffee beans from around the world, their origins, flavor profiles, and recommended brewing methods.
Tech Stack: Next.js 14+ (App Router) | TypeScript | react-map-gl + Mapbox GL JS | Tailwind CSS + shadcn/ui | Zustand | Framer Motion | Recharts + D3 | Fuse.js | MDX
Goal: A working interactive map with clickable bean markers, profile panels, and dark/light mode.
- Initialize Next.js project with
create-next-app(TypeScript, Tailwind, App Router,src/dir) - Install core dependencies:
react-map-gl,mapbox-gl,framer-motion,zustand,lucide-react,zod,next-themes - Initialize shadcn/ui (
npx shadcn-ui@latest init) and install base components: Sheet, Dialog, Button, Toggle, Slider, Command, NavigationMenu - Configure Tailwind theme with coffee color palette:
cream: #FAF6F1,parchment: #F0E8DC,tan: #D4C4A8roast-light: #A67C52,roast-medium: #6F4E37,roast-dark: #3B2314,espresso: #1A0F09cherry-red: #C1440E,leaf-green: #4A7C59,water-blue: #5B8FA8
- Set up fonts via
next/font/google: DM Serif Display (headings), Inter (body), JetBrains Mono (parameters) - Configure ESLint + Prettier with
prettier-plugin-tailwindcss - Create project directory structure:
src/ app/ -- pages and layouts components/ -- map/, bean/, brewing/, filter/, compare/, visualization/, shared/, layout/ lib/ -- schemas, utils, data loading, map helpers, search config store/ -- Zustand store types/ -- TypeScript interfaces content/ -- MDX articles (Phase 3) data/ -- JSON seed data + GeoJSON public/ images/ -- beans/, methods/, icons/
- Create Mapbox account and generate access token
- Store token in
.env.localasNEXT_PUBLIC_MAPBOX_TOKEN - Design custom light map style in Mapbox Studio (warm terrain tones, desaturated non-bean-belt regions)
- Design custom dark map style (espresso-toned background, reduced saturation accents)
- Add
.env.localto.gitignore, create.env.examplewith placeholder
- Create
/data/beans.jsonwith 30 well-researched bean profiles:- Africa: Ethiopia (Yirgacheffe, Sidamo, Guji), Kenya (Nyeri, Kirinyaga), Rwanda (Nyamasheke), Tanzania (Kilimanjaro)
- Central America: Guatemala (Antigua, Huehuetenango), Costa Rica (Tarrazu), Panama (Boquete/Gesha), Honduras (Copan)
- South America: Colombia (Huila, Narino, Tolima), Brazil (Cerrado, Sul de Minas), Peru (Cajamarca)
- Asia-Pacific: Indonesia (Sumatra Mandheling, Java), Yemen (Haraz), India (Malabar)
- Each bean includes: id, slug, name, country, countryCode, region, coordinates [lng, lat], altitude range, varieties, processing method, roast recommendation, 6-axis flavor profile (1-10), flavor notes array, harvest months, description, related bean IDs
- Validate all coordinates are accurate for the specific growing regions
- Cross-reference flavor profiles against SCA cupping standards and specialty roaster notes
- Create
/data/brewing-methods.jsonwith 8 methods:- Pour-Over V60, Chemex, Kalita Wave, Espresso, French Press, AeroPress, Cold Brew, Moka Pot
- Each: id, name, category, description, icon name, equipment list, default parameters
- Add
brewingRecommendationsarray to each bean inbeans.json:- Per method: grind size + microns, water temp (C), coffee:water ratio, bloom time, brew time, pour stages (pour-over), difficulty (1-5), affinity score (1-10), tasting notes
- Tailor recommendations to each bean's characteristics (e.g., light-roast Ethiopian gets higher V60 affinity, lower French Press)
- Create
/data/regions.geojsonwith simplified polygons for the 30 origin regions - Source boundaries from Natural Earth / GADM datasets
- Simplify polygons with mapshaper (tolerance ~0.01) to keep total file under 500KB
- Include properties: regionId, country, name, altitudeRange
- Define interfaces in
/types/index.ts:CoffeeBean,BrewRecommendation,BrewingMethod,CoffeeRegion,FlavorNote,PourStage- Type literals:
ProcessingMethod,RoastLevel,GrindSize
- Create Zod schemas in
/lib/schemas.tsmirroring all interfaces - Write build-time validation script that parses all JSON data through Zod on
next build - Create
/lib/data.tswith typed data loading functions:getBeans(),getBeanBySlug(),getBrewingMethods(),getRegions()
- Create
/store/index.tswith slices:mapState: viewport (lat, lng, zoom, bearing, pitch), selectedBeanIdfilterState: regions, processingMethods, altitudeRange, roastLevels, flavorRanges (acidity, body, sweetness, bitterness min/max)comparisonState: selectedBeanIds (max 3), isComparisonOpenuiState: theme, isBeanPanelOpen, isFilterPanelOpen
- Add actions:
selectBean(),clearSelection(),toggleFilter(),resetFilters(),addToComparison(),removeFromComparison() - Add computed selectors:
filteredBeans()that applies all active filters to the bean dataset
- Build
src/components/map/CoffeeMap.tsx(Client Component) - Render full-bleed map with
react-map-glMap component - Enable globe projection at low zoom levels
- Add GeoJSON Source with bean coordinates, enable clustering (
cluster: true,clusterMaxZoom: 14,clusterRadius: 50) - Render cluster Layer (circle with count label) and individual marker Layer (custom coffee bean icon)
- Handle marker click:
flyTothe bean coordinates (zoom 10), setselectedBeanIdin store, open bean panel - Handle cluster click:
flyTocluster bounds to expand it - Sync map viewport to Zustand store for URL state (Phase 2)
- Add atmosphere/fog for globe view aesthetic
- Handle loading state with skeleton placeholder
- Build
src/components/map/RegionHighlight.tsx - Add regions GeoJSON as a map Source
- On marker hover, set a
hoveredRegionIdfilter on a fill Layer (semi-transparent warm overlay) - Fade in/out with paint-transition properties
- Clear highlight on mouse leave
- Build
src/components/bean/BeanPanel.tsxusing shadcn Sheet component - Layout sections:
- Header: Country flag emoji, bean name, region, altitude range
- Flavor Profile: Display 6-axis values as labeled bars (radar chart comes in Phase 3)
- Tasting Notes: Chip/badge list of flavor notes
- Details: Varieties, processing method, roast recommendation, harvest season (month names)
- Brewing: Preview cards for top 3 recommended methods (by affinity score)
- Similar Beans: 2-3 related bean cards
- Description: Short overview paragraph
- Wire up:
selectedBeanIdfrom store -> fetch bean data -> render panel - Close panel: clear selection, panel slides out
- Add "View Full Profile" link to
/bean/[slug]page
- Build
src/components/layout/TopNav.tsx - Fixed position, 56px height, backdrop blur (
backdrop-blur-md) - Left: BeanMap logo (text + coffee bean icon from Lucide)
- Center: Search button placeholder (Cmd+K hint) -- functional search in Phase 2
- Right: "Explore" link (map), "Learn" link (placeholder), theme toggle button
- Mobile: Hamburger menu for nav links, search icon
- Install and configure
next-themeswith ThemeProvider in root layout - Build
src/components/shared/ThemeToggle.tsx(Sun/Moon icon toggle) - System preference detection with manual override
- Map: switch Mapbox style ID between light and dark variants on theme change
- All components use Tailwind
dark:variants - Persist preference in localStorage
- Mobile (< 640px): Map fills viewport, bean panel opens as bottom sheet (shadcn Sheet
side="bottom") - Tablet (640-1024px): Bean panel as right sheet (50% width)
- Desktop (> 1024px): Bean panel as right sheet (420px fixed width)
- Ensure map controls (zoom +/-, compass) don't overlap with panels
- Test touch interactions on map (pinch zoom, two-finger pan)
- Create
src/app/bean/[slug]/page.tsxas a Server Component - Fetch bean data by slug using
getBeanBySlug() - Full-page bean profile with all details (expanded version of panel)
- Generate
metadatawith Open Graph tags: title, description, image (static placeholder for now) -
generateStaticParams()to pre-render all bean pages at build time - 404 handling for invalid slugs
- "View on Map" button that links back to
/?bean=[slug]
-
npm run buildsucceeds with no errors - Map loads with 30 markers and clustering at low zoom
- Clicking a marker flies to it and opens the bean panel with correct data
- Region highlights on marker hover
- Dark/light mode toggles map style and all UI
- Responsive: mobile bottom sheet, desktop side panel
- Bean detail page renders at
/bean/ethiopian-yirgacheffewith correct OG tags - Deploy to Vercel and verify production build
Goal: Full filtering, search, brewing recommendations with calculator, expanded data, and URL state sync.
- Build
src/components/filter/FilterPanel.tsx - Desktop: Collapsible left sidebar (280px), toggle button on map edge
- Mobile: Full-screen bottom sheet triggered by filter icon button
- Sections:
- Region: Multi-select checkboxes (Africa, Central America, South America, Asia-Pacific) with country sub-groups
- Processing Method: Checkboxes (Washed, Natural, Honey, Anaerobic, Wet-Hulled)
- Roast Level: Toggle chips (Light, Medium-Light, Medium, Medium-Dark, Dark)
- Altitude: Dual-handle range slider (500-2500 masl)
- "Reset Filters" button, active filter count badge on toggle button
- Wire all values to Zustand
filterState
- Build
src/components/filter/FlavorSliders.tsx - Dual-handle range sliders for: Acidity, Body, Sweetness, Bitterness (range 1-10)
- Use shadcn Slider component (customized for dual handles)
- Real-time preview: show count of matching beans as sliders adjust
- Add to FilterPanel as a collapsible "Flavor Profile" section
- When
filterStatechanges, computefilteredBeansvia Zustand selector - Update GeoJSON source data: set
filtered: falseproperty on non-matching beans - Map paint expression: matching markers full opacity, non-matching at 0.15 opacity
- Animate opacity transition (200ms ease)
- Update cluster counts to only include matching beans
- Show "X of Y beans" counter on the filter panel
- Build
src/components/shared/SearchCommand.tsxusing shadcn Command (cmdk) - Configure Fuse.js index in
/lib/search.ts:- Search fields: name, country, region, flavorNotes (weighted: name > region > flavorNotes)
- Threshold: 0.3 for fuzzy matching
- Keyboard shortcut: Cmd+K (Mac) / Ctrl+K (Windows) opens dialog
- Results show: bean name, country flag, region, top 2 flavor notes
- Fixed during Phase 5: searching by flavor note returned nothing.
Fuse matched correctly, but cmdk then re-filtered the results against each
item's
value(name + country + region only) and discarded them — two search algorithms stacked on top of each other. The<CommandDialog>now passesshouldFilter={false}so Fuse alone ranks. Verified: "blackcurrant" returns 3 beans (was 0). - Selecting a result: close dialog, fly to bean on map, open bean panel
- "No results" state with suggestion to adjust search
- Recent searches stored in localStorage (last 5)
- Build
src/components/brewing/BrewCard.tsx - Display: method icon, method name, affinity score (bar or dots), grind size, water temp, ratio, brew time, difficulty stars
- Horizontal scrolling row in the bean panel (snap scrolling on mobile)
- Cards sorted by affinity score (highest first)
- Visual indicator for "Best Match" on highest affinity method
- Click card to open brew detail modal
- Build
src/components/brewing/BrewDetailModal.tsxusing shadcn Dialog - Full parameter display:
- Grind size with visual particle-size reference (illustrated scale from extra-fine to extra-coarse)
- Water temperature (C and F toggle)
- Coffee:water ratio with gram amounts
- Bloom time + total brew time
- Pour stages with water amounts and instructions (pour-over methods)
- Equipment list
- Difficulty rating with description
- "Why this works" explanation paragraph
- Link to full brewing guide (Phase 3)
- Integrated dose calculator (see 2.7)
- Build
src/components/brewing/BrewCalculator.tsx - Input: desired output (ml or cups, with cup size selector: 200ml, 250ml, 300ml, 350ml)
- Auto-calculates: coffee grams, water ml, maintaining the bean-specific ratio
- Persist preferred cup size in localStorage
- Clean number display (round to 0.1g for coffee, whole ml for water)
- Embedded within BrewDetailModal and also usable standalone
- Expand
/data/beans.jsonto 150-200 profiles covering:- Colombia: Cauca, Santander, Sierra Nevada, Antioquia, Quindio
- Brazil: Mogiana, Bahia, Espirito Santo, Chapada Diamantina
- East Africa: Burundi (Kayanza), Tanzania (Mbeya), DRC (Kivu), Uganda (Mt. Elgon), Malawi (Misuku)
- Central America: Honduras (Marcala, Comayagua), El Salvador (Apaneca), Nicaragua (Jinotega, Matagalpa), Mexico (Chiapas, Oaxaca)
- Asia: Myanmar (Shan State), Papua New Guinea (Eastern Highlands), China (Yunnan), Vietnam (Da Lat specialty)
- Islands: Hawaii (Kona), Jamaica (Blue Mountain), Reunion (Bourbon origin)
- Add brewing recommendations for all new beans
- Ensure no duplicate coordinates (offset overlapping markers slightly)
- Create
/data/flavor-notes.jsonfollowing SCA flavor wheel hierarchy:- ~15 top-level categories (Fruity, Floral, Sweet, Nutty/Cocoa, Spices, Roasted, Cereal, etc.)
- ~40 subcategories
- ~100 specific notes with hex color for wheel visualization
- Tag each bean in
beans.jsonwith specific flavor note IDs (replacing plain-text notes) - Update
FlavorNotetype and Zod schema
- Create
src/app/beans/page.tsx - Grid view (default): Cards showing bean name, country, key flavor notes, altitude, processing
- Table view (toggle): Sortable columns for all key attributes
- Reuse filter components from the map view
- Each card/row links to bean detail page AND has "Show on Map" button
- Pagination or infinite scroll for 150+ beans
- Add "Similar Beans" section to BeanPanel and bean detail page
- Algorithm: compute Euclidean distance across the 6-axis flavor profile
- Show 3 closest beans (excluding same country to encourage exploration)
- Display as mini cards with name, country, top 2 flavor notes
- Click navigates to that bean on the map
- Install
nuqsfor type-safe URL search parameter management - Sync to URL: selected bean (
?bean=slug), map viewport (lat,lng,zoom), active filters (region,processing,altitude,roast) - On page load, restore state from URL params
- Shareable URLs:
bean-map.vercel.app/?bean=ethiopian-yirgacheffe®ion=africa&acidity=7-10 - Update URL on state change without full page navigation (shallow routing)
- Filters narrow visible markers in real-time with opacity fade
- All filter types work: region, processing, altitude range, roast, flavor sliders
- Cmd+K search finds beans by name, region, and flavor notes
- Brew cards display correctly sorted by affinity in bean panel
- Brew detail modal shows all parameters with correct C/F toggle
- Dose calculator scales correctly for different cup sizes
- Bean list page renders 150+ beans with grid/table toggle
- URL state persists: copy URL, open in new tab, same view loads
- Mobile: all features accessible via bottom sheets and modals
Goal: Rich data visualizations, bean comparison, polished animations, and educational content.
- Build
src/components/visualization/FlavorRadar.tsx(pure SVG, no Recharts dep) - 6 axes: Acidity, Body, Sweetness, Bitterness, Complexity, Fruitiness
- Filled polygon with semi-transparent brand color fill
- Animated draw-on-enter (CSS keyframes, respects prefers-reduced-motion)
- Replace the text-based flavor display in BeanPanel with this chart
- Support overlay mode: render 2-3 polygons for comparison (different colors)
- Build
src/components/visualization/FlavorWheel.tsx - D3 sunburst layout using
d3-hierarchyandd3-shape - 3 concentric rings: category -> subcategory -> specific note
- Color-coded segments matching the SCA wheel colors
- Hover: highlight segment + show tooltip with note name and bean count
- Click a segment: filter beans on map to only those with that flavor note
- Smooth arc transitions on hover/click
- Accessible: include screen-reader-only table listing all notes
- Lazy-loaded (dynamic import) to avoid D3 in initial bundle
- Place on map view as a toggleable overlay or on a dedicated
/explore/flavorspage
- Build
src/components/visualization/AltitudeChart.tsx(pure SVG/CSS bars) - Shows altitude ranges for currently visible/filtered beans
- Sorted highest to lowest (by midpoint)
- Color gradient from leaf-green (low) to roast-dark (high)
- Click a bar to select that bean on the map
- Lives on
/explore/insightspage
- Build
src/components/visualization/SeasonalChart.tsx - Gantt-style grid: months as columns (Jan-Dec), beans as rows
- Colored cells for harvest months, highlight current month (cherry-red ring)
- Group rows by country for easy scanning
- Click a row to navigate to that bean
- Useful for finding "what's in season now"
- Build
src/components/compare/ComparisonTray.tsx- Sticky bottom bar that collapses to a peek state, slides up from bottom
- Shows 1-3 selected bean mini cards with X to remove
- "Share link" button ->
/compare?beans=... - "Compare" button opens full comparison view in a Dialog
- Build
src/components/compare/ComparisonView.tsx- Side-by-side bean cards (2-3 columns)
- Overlaid radar chart (all beans on one chart, different colors)
- Parameter comparison table: altitude, processing, roast, top flavor notes
- Brewing recommendation comparison with method picker
- "Best for [method]" highlight (Trophy icon on highest affinity)
- Add "Compare" toggle button to BeanCard and BeanPanel
- Store comparison state in Zustand, max 3 beans (already wired pre-Phase-3)
- Create
src/app/compare/page.tsx - Read bean slugs from URL params:
/compare?beans=slug1,slug2,slug3 - Render full ComparisonView as a standalone page
- Shareable URL for bean comparisons (dynamic metadata: "X vs Y · Compare on BeanMap")
- Empty state with CTA to explore beans
- BeanPanel:
AnimatePresenceslide-in from right (desktop) / up (mobile via bottom sheet), staggered children entrance - Bean cards in lists: staggered fade-up on scroll into view (
whileInView, row-capped delay) - Comparison tray:
layout+AnimatePresence— cards fly in/out and reflow as beans are added/removed - Filter chips: entrance/exit animation when toggled (scale+fade
AnimatePresence) - Hero image in bean panel: subtle parallax on scroll (per-bean gradient generated from the bean's flavor profile via
src/lib/flavor-gradient.ts— dominant flavor-note category colors drive the hue, numeric profile drives the geometry; no external imagery) - Page transitions: fade between map and list views (
app/template.tsx) - Loading skeletons: shimmer animation on data loading states (
.skeleton,Skeletoncomponent, map +/beansloading) -
prefers-reduced-motion: disable all animations, instant transitions (global rule in globals.css; chart animations gated onmotion-safe:)
- Set up MDX rendering pipeline with
next-mdx-remote - Create
src/app/learn/page.tsx(Learn hub with article grid) - Create
src/app/learn/processing/[slug]/page.tsx(article renderer) - Write 5 MDX articles in
/content/processing/:-
washed.mdx- Washed/wet processing -
natural.mdx- Natural/dry processing -
honey.mdx- Honey processing (yellow, red, black) -
anaerobic.mdx- Anaerobic fermentation -
wet-hulled.mdx- Wet-hulled (Giling Basah)
-
- Each article: overview, step-by-step process, impact on flavor, origin regions that use it, embedded SVG diagrams
- Link from bean profiles to relevant processing article
- Create
src/app/learn/brewing/[slug]/page.tsx(article renderer) - Write 8 MDX guides in
/content/brewing/:-
v60.mdx -
chemex.mdx,kalita-wave.mdx,french-press.mdx -
aeropress.mdx,espresso.mdx,cold-brew.mdx,moka-pot.mdx
-
- Each guide: equipment list, step-by-step instructions with timing, common mistakes, tips for different beans
- Embed interactive
BrewTimercomponent within guides - Link from brew detail modal to relevant guide
- Build
src/components/brewing/BrewTimer.tsx - Countdown/count-up timer with start/pause/reset
- Pour stage alerts for pour-over methods (visual + optional sound)
- Configurable stages based on the brewing method's pour schedule
- Circular progress indicator with elapsed/remaining time
-
requestAnimationFramefor smooth rendering - Embeddable in MDX articles and brew detail modal
- Add toggleable terrain/altitude layer to the map
- Use Mapbox terrain-rgb raster tiles
- Style with color ramp showing elevation gradient (greens -> browns -> whites)
- Only display within coffee-growing regions (mask with bean-belt bounds)
- Toggle button on map controls
- Opacity slider for blending with base map
- Build
src/components/layout/MobileBottomSheet.tsx - Replace simple mobile Sheet with a draggable bottom sheet
- Three snap points: peek (25% - shows bean name + key stats), half (50%), full (90%)
- Gesture-based: drag handle, flick to expand/collapse
- Use
@use-gesture/reactfor touch handling - Smooth spring animation between snap points
- Backdrop: map dims slightly when sheet is at half or full
- Radar chart renders correctly for each bean with animated entrance
- Flavor wheel displays full SCA hierarchy, clicking filters beans on map
- Altitude chart and harvest calendar show correct data for filtered beans
- Comparison tray: add up to 3 beans, overlaid radar chart renders, parameter table is accurate
- Comparison URL is shareable and loads correctly
- Animations are smooth (60fps), respect reduced-motion preference
- All 13 MDX articles render with embedded components (timer, diagrams)
- Brew timer counts down accurately with pour stage alerts
- Mobile bottom sheet drags smoothly between snap points
Goal: Sharing capabilities, persistent favorites, optional authentication, and community features.
- Build
src/components/shared/ShareButton.tsx - Mobile: Web Share API (
navigator.share()) with title, text, URL - Desktop fallback: Copy-to-clipboard with "Copied!" toast notification
- Add ShareButton to: BeanPanel, bean detail page, ComparisonView, BrewDetailModal
- Share text format: "Check out [Bean Name] from [Region] on BeanMap! [URL]"
- Create
src/app/api/og/route.tsxusing@vercel/og(Satori) - Dynamic OG images for bean pages: bean name, origin, radar chart preview, key flavor notes, warm coffee-themed background
- Dynamic OG images for comparison pages: 2-3 bean names, "Compare on BeanMap"
- Update all
metadataexports to use dynamic OG image URLs - Test with social media debuggers (Twitter Card Validator, Facebook Sharing Debugger)
- Build
src/components/brewing/ShareRecipeCard.tsx - "Share Recipe" button in BrewDetailModal
- Generate a visually appealing card showing: bean name, brewing method, key parameters (grind, temp, ratio, time)
- Option 1: Generate as OG-powered link (dynamic OG image at
/api/og/recipe?bean=X&method=Y) - Option 2: Generate as downloadable PNG using
html-to-imagelibrary - Card design: clean layout with coffee color palette, BeanMap branding
- Build
src/components/shared/FavoriteButton.tsx(heart icon toggle) - Zustand
persistmiddleware to save favorited bean IDs in localStorage - Add FavoriteButton to: BeanPanel, BeanCard, bean detail page
- Create
src/app/favorites/page.tsx:- Grid of favorited bean cards
- Sort by: date added, name, region
- Empty state with CTA to explore
- Export favorites list (optional: JSON download)
- Provision Neon Postgres database (free tier)
- Install Drizzle ORM +
drizzle-kit - Define schemas in
/drizzle/schema.ts:userstable: id, email, name, image, provider, createdAtfavoritestable: id, userId, beanSlug, createdAtbrewNotestable: id, userId, beanSlug, methodId, note, rating, createdAt, updatedAt
- Generate and run initial migration
- Create
/lib/db.tswith Drizzle client configuration - Add database URL to
.env.local
- Install NextAuth.js v5 (
next-auth@beta) - Configure providers: Google OAuth, GitHub OAuth
- Set up JWT session strategy (no database sessions needed)
- Create auth API route at
src/app/api/auth/[...nextauth]/route.ts - Build sign-in/sign-out UI components
- Add user avatar/sign-in button to TopNav
- Create user record in Postgres on first login
- Protect authenticated routes with middleware
- Create API routes:
GET /api/favorites- list user's favoritesPOST /api/favorites- add a favoriteDELETE /api/favorites/[beanSlug]- remove a favorite
- On login: merge localStorage favorites with server favorites (union, no duplicates)
- After merge: clear localStorage favorites, use server as source of truth
- FavoriteButton: check auth state, use local or server accordingly
- Optimistic updates with rollback on error
- Build
src/components/brewing/BrewNoteForm.tsx - Authenticated users can add notes to a bean:
- Brewing method used
- Free-text tasting notes
- Rating (1-5 stars)
- Date brewed
- Create API routes for CRUD operations on brew notes
- Display as a timeline on the bean profile (private, only visible to the author)
- Build
src/app/notes/page.tsx- personal brewing journal listing all notes
- Create
src/app/discover/page.tsx - Sections:
- In Season Now: Beans currently in harvest (based on current month)
- Popular This Week: Most favorited beans (requires tracking, or curate manually)
- Editor's Picks: Curated rotating selection (stored in JSON or CMS)
- New Additions: Recently added beans
- Responsive grid layout with section headers
- Link to bean profiles and map view
- Share button works on mobile (Web Share API) and desktop (clipboard)
- OG images generate correctly for bean pages and comparisons
- Brew recipe card generates and downloads as PNG
- Favorites persist in localStorage for unauthenticated users
- Auth flow works: sign in with Google/GitHub, user created in DB
- Favorites sync: local favorites merge on first login, subsequent favorites save to DB
- Brew notes: create, read, update, delete all work correctly
- Discover page shows correct seasonal beans based on current month
Goal: Performance optimization, SEO hardening, accessibility audit, and production readiness.
- Run Lighthouse on key pages: home/map, bean detail, bean list, learn articles
- Target scores: Performance 90+, Accessibility 95+, Best Practices 95+, SEO 95+
- Measure Core Web Vitals: LCP < 2.5s, FID < 100ms, CLS < 0.1
- Identify and address largest contentful paint bottleneck (likely Mapbox JS)
- Profile React rendering with React DevTools Profiler
- Analyze output — via
npm run analyze(next build --experimental-analyze). NOT@next/bundle-analyzer: that plugin configures webpack, which this project no longer uses (Next 16 builds with Turbopack), so it would be a silent no-op. Next's built-in Turbopack analyzer replaces it. - Dynamic imports for heavy components:
-
CoffeeMap(Mapbox GL JS — measured 466KB gzipped, kept out of initial load) -
FlavorWheel(D3 subset, viaFlavorWheelLazy) -
ComparisonView(lazy viaComparisonTray) -
BrewTimer(lazy viaBeanPanel) -
SearchCommandDialog(cmdk, mounted only after first open)
-
- Removed Framer Motion from the universal route wrapper —
template.tsxusedmotion.divfor the page fade, which pulled the whole animation engine (~43KB gz) into the shared bundle of every page, including fully static Learn articles. Now a CSS keyframe animation (.page-fade). Framer now loads only on pages that actually animate (map, beans list). - Slimmed the root-layout search payload:
<SearchCommand>received fullCoffeeBean[]+FlavorNotesData, which serialized into the RSC payload of every page. Now aSearchableBeanprojection built server-side. Learn-page HTML 216KB -> 77KB raw (29KB -> 17KB gz). - Tree-shake D3: only
d3-hierarchyandd3-shapeare imported - Verified no lodash/moment imports
- Target: initial JS bundle < 200KB gzipped — not yet met.
Measured (gzipped, initial JS per page):
| page | before | after | modern browsers* |
|---|---|---|---|
|
/en(map) | 402KB | 360KB | 321KB | |/en/beans| 370KB | 327KB | 289KB | |/en/bean/[slug]| 310KB | 267KB | 229KB | |/en/learn| 305KB | 262KB | 224KB | * excludes the 38.5KB legacy polyfill chunk, which is servednoModuleand never fetched by browsers that support ES modules. Remaining floor is framework: react-dom (69KB) + Next runtime (47KB) + base-ui/sonner/nuqs/next-intl/next-auth (~65KB combined). Next levers: dropSessionProviderfrom the root layout (only auth-dependent UI needs it), and lazy-loadsonner.
Largely N/A as built — the site ships essentially no raster imagery. Bean
"hero images" are per-bean CSS gradients generated from the flavor profile
(src/lib/flavor-gradient.ts, decided in 3.7), flavor icons are SVG, and the
visualizations are inline SVG. Audited rather than implemented:
- No bean hero images exist to convert — nothing to do
-
next/imageused for the only raster asset in the UI (the 96x96, 12KB TopNav logo). The one raw<img>is the OAuth avatar inUserMenu, which is correct: it's a remote provider URL rendered in a fixed-size box, so it causes no layout shift. - Total image weight in the initial viewport is ~12KB, far under the 500KB budget
-
src/app/icon.pngis 258KB for a 512x512 icon — recompress (not on the critical path, but it's the single largest static asset)
- Measure GeoJSON file sizes —
public/data/regions.geojsonis 363KB, well under the 1MB budget. No further simplification needed. - Vector tilesets not needed at this size (see above); revisit only if the region set grows past ~1MB.
- Implement
useDeferredValuefor filter state to prevent map jank during rapid filter changes - Debounce viewport sync to URL (300ms) to avoid excessive history entries
- Fixed whole-store subscriptions.
CoffeeMapand six other components calleduseBeanMap()with no selector, subscribing to every store change. SinceonMovewritesviewporton each animation frame andonMouseMovewriteshoveredRegionIdon each pointer move, dragging the map forced a full React re-render of the map, its sources and layers — and of the whole filter UI — ~60x/second. All seven now useuseShallowselectors, and the map reads its initial camera once viagetState()instead of subscribing. - Test on low-end devices (throttled CPU/network in DevTools)
- Generate dynamic
sitemap.xmlat build time (src/app/sitemap.ts) — 148 URLs: 6 static + 55 beans + 13 learn articles, x2 locales. Every entry carries the fullhreflangalternate set plusx-default. - Create
robots.txt(src/app/robots.ts) — allows all, disallows/api/, points to the sitemap. Deliberately does notDisallowthe thin/private routes: a disallowed URL is never fetched, so the crawler would never see theirnoindexor canonical tags. Those use per-pagenoindex, follow. - Add JSON-LD structured data (
src/lib/structured-data.ts+<JsonLd>):- Bean pages:
Articleabout aThing(origin attributes asadditionalProperty, plusGeoCoordinates) +BreadcrumbList. Modelled asArticle, notProduct: BeanMap sells nothing, and aProductwith nooffers/review/aggregateRatingearns no rich result and reports missing required fields in the Rich Results Test. - Home:
Organization+WebSitegraph -
/beans:CollectionPage+ItemList+BreadcrumbList - Learn articles:
Article+BreadcrumbList
- Bean pages:
- Unique meta title and description for every page
- Canonical URLs on all pages, plus
hreflangalternates foren/zh-TW/x-default(src/lib/seo.ts— every route is locale-prefixed, so the translations must be declared as alternates rather than duplicates) - Indexation policy for thin / combinatorial routes (
noindex, follow):/favoritesand/notes(per-visitor content),/compare?beans=…(any 2-3 of 55 beans = tens of thousands of near-identical URLs), and the per-method recipe pages (~440 per locale, canonical -> parent bean page). All still render full OG cards, since they exist to be shared. - Submit to Google Search Console, verify indexing (needs production deploy)
- Test with Google Rich Results Test (needs a public URL)
- Full keyboard navigation audit: every interactive element reachable and operable via keyboard
- Screen reader testing with VoiceOver (macOS) and NVDA (Windows)
- Run
axe-coreautomated audit on all pages, fix all violations - Map accessibility: provide a hidden bean list as an equivalent non-visual navigation method
- All images have descriptive
alttext - Radar charts and flavor wheel include screen-reader-only data tables
- Color contrast: verify all text meets WCAG 2.1 AA (4.5:1 body, 3:1 large)
- Focus indicators: visible ring on all interactive elements
-
prefers-reduced-motiondisables all animations - Skip-to-content link for keyboard users
- Aria labels on all icon-only buttons
- Add React Error Boundaries around: Map component, visualization components, auth-dependent sections
- Graceful fallback UI for map loading failures (Mapbox token issues, network errors)
- Custom 404 page for invalid bean slugs and routes
- Custom 500 page for server errors
- API route error responses with proper status codes and messages
- Toast notifications for user-facing errors (favoriting fails, share fails, etc.)
- Add Vercel Analytics for Web Vitals monitoring
- Add Plausible Analytics (or Vercel Web Analytics) for page views (privacy-friendly, no cookies)
- Track key events: bean viewed, brew method selected, filter applied, comparison made, favorite added, share clicked
- Dashboard for monitoring engagement and popular beans
- Create manifest with app name, icons, theme color, display: standalone
(
src/app/manifest.ts->/manifest.webmanifest).start_urlis/enrather than/, so an installed app doesn't round-trip the i18n proxy on every launch. - Add to root layout
<head>— Next links the manifest automatically; added a matchingviewport.themeColor(light/dark) alongside it - Configure service worker with
@ducanh2912/next-pwaornext-pwa - Cache strategies:
- Bean data JSON: cache-first (update in background)
- Images: cache-first with stale-while-revalidate
- Educational content: cache-first
- Map tiles: network-first (Mapbox handles its own caching)
- Offline fallback page for when network is unavailable
- Test add-to-homescreen on iOS and Android
- Set up Playwright with test fixtures
- Critical flow tests:
- Land on map -> markers visible -> click marker -> bean panel opens with correct data
- Apply filters -> markers fade -> clear filters -> all markers return
- Cmd+K search -> type bean name -> select result -> map flies to bean
- Open brew recommendation -> view full details -> use dose calculator
- Add 2 beans to comparison -> open comparison view -> radar chart overlays
- Toggle dark/light mode -> map and UI update
- Navigate to bean detail page -> all sections render -> "View on Map" works
- Mobile: bottom sheet opens and drags between snap points
- Run in CI (GitHub Actions) on push to main
- Visual regression tests for key components (optional: Playwright screenshots)
- Write README.md:
- Project overview and screenshots
- Tech stack summary
- Local development setup instructions
- Environment variables guide
- Project structure overview
- Data contribution guide (how to add new beans to
beans.json) - Deployment instructions (Vercel)
- Add CONTRIBUTING.md with code style, PR process, data quality guidelines
- Add LICENSE (MIT or similar)
- Final deploy to Vercel production environment
- Set up custom domain (e.g., beanmap.coffee or bean-map.vercel.app)
- Verify all pages load correctly in production
- Verify OG images, sitemap, robots.txt in production
- Test on real mobile devices (iOS Safari, Android Chrome)
- Submit sitemap to Google Search Console
- Create launch announcement / social media posts
- Lighthouse scores: Performance 90+, Accessibility 95+, Best Practices 95+, SEO 95+
- Core Web Vitals pass in Vercel Analytics
- All Playwright E2E tests pass in CI
- Full keyboard navigation works across all pages
- VoiceOver reads all content meaningfully
- PWA installs and works offline (cached content)
- No console errors or warnings in production build
- Custom domain resolves and HTTPS works
- OG images render correctly when shared on Twitter/Facebook/LinkedIn