Skip to content

sam-maass/bargen

Repository files navigation

Bargen

A small e-commerce demo for a fictional grocer, built to show how Next.js 16 on Vercel turns rendering, caching, and edge primitives into measurable wins for a real webshop. Every page is a teaching artifact — the choices below are the ones I would defend in a customer review.

Stack: Next.js 16.2 (App Router) · React 19 · Tailwind v4 · Drizzle ORM · Neon Postgres · Vercel.


Why these choices

The brief is "small and deep". I picked the surfaces a grocer actually loses money on — slow product pages, generic landing pages, broken search — and built each one with the rendering strategy that fits.

Surface Strategy Why
Home, product, cat. PPR — static shell, streamed body Instant LCP, fresh price/stock without rebuilding the world
Local offer banner Streamed RSC from geo headers Personalization without a client round-trip or layout shift
Header (cart, user) Streamed RSC inside Suspense Dynamic per-user data without blocking the static shell
Locale routing Edge proxy + cookie Country-aware URLs (/DE, /GB, /US) decided before render
Search Postgres FTS + cached route Real ranking; the API caches at the edge, the query at the ORM
Catalog data Neon + Drizzle, tag-invalidated One source of truth; merchandiser changes go live in seconds

Rendering map

┌─ static shell (prerendered, immutable until deploy) ─────────────────┐
│  header chrome · hero · footer · category nav links                  │
│                                                                      │
│  ┌─ streamed RSC (cached at request time) ──────────────────────┐    │
│  │  product body  ·  category grid  ·  search results           │    │
│  │  cacheLife("minutes") + cacheTag(product:slug, …)            │    │
│  └──────────────────────────────────────────────────────────────┘    │
│                                                                      │
│  ┌─ streamed RSC (per-request, dynamic) ────────────────────────┐    │
│  │  local offer (geo headers)  ·  cart badge  ·  user greeting  │    │
│  └──────────────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────────────┘

Partial Prerendering is enabled via cacheComponents: true (next.config.ts). The "use cache" directive marks bodies that should be served from the data cache and tagged for on-demand revalidation; everything outside it stays dynamic and streams in.


Caching and revalidation

Every cached read declares its tags in src/lib/cache-tags.ts:

products
products:featured
product:<slug>
categories
category:<slug>
category:<slug>:products

A merchandiser changing a price calls one webhook:

curl -X POST "$URL/api/revalidate?tag=product:elstar-apples" \
  -H "x-revalidate-secret: $REVALIDATE_SECRET"

revalidateTag(..., "max") propagates instantly to every page that read that tag — product detail, category grid, featured carousel, search. The secret check uses timingSafeEqual and fails closed when unset (src/app/api/revalidate/route.ts).


Edge locale routing

src/proxy.ts runs at the edge before each non-asset request:

  1. If the path already starts with a known locale (DE/GB/US), continue.
  2. Otherwise prefer the locale cookie, falling back to x-vercel-ip-country.
  3. 307 to the locale-prefixed URL.

The LocalePicker writes the cookie on switch, so the choice survives the next visit without another geo lookup. generateMetadata emits per-page hreflang alternates for SEO.


Discoverability and structured data

src/app/sitemap.ts emits one entry per home/category/product with full hreflang alternates (de-DE, en-GB, en-US, x-default). src/app/robots.ts disallows everything on preview deployments and points production at the sitemap.

Each page injects schema.org JSON-LD via src/lib/jsonld.ts: Organization plus WebSite in the locale layout, Product plus BreadcrumbList on detail pages, CollectionPage plus ItemList on category pages.

For AI agents, every product/category/home URL also serves Markdown when the request sends Accept: text/markdownnext.config.ts rewrites those to a parallel /[locale]/md/… tree. /llms.txt advertises the catalog as a flat link list. Vary: Accept is appended in the proxy and the global headers so the CDN keys HTML and Markdown variants separately.

curl -H "Accept: text/markdown" "$URL/DE/product/elstar-apples"

Personalized banner without CLS

LocalOfferBanner reads x-vercel-ip-country / x-vercel-ip-country-region / x-vercel-ip-city (decoded), maps the region to a curated offer, and streams the card into a Suspense boundary. The fallback renders the same card with the DEFAULT region so the layout never shifts — the user sees the generic offer for ~50ms, then the localized one swaps in.

?region=DE-BY&city=München overrides the geo for demos.


Search

  • Generated tsvector column with weighted name/brand/description (src/lib/db/schema.ts), indexed with GIN.
  • searchProducts tokenizes and prefix-matches with to_tsquery and ts_rank; the cached runSearch is keyed by sorted tokens so case and word order share a cache entry.
  • Hero autocomplete hits /api/search/suggest (debounced 200ms, s-maxage=60, stale-while-revalidate=300). The /search page is RSC, also cached.

Auth and cart (mock)

The brief is platform-focused, so auth is a small WebCrypto-signed JWT (HS256) over a two-user fixture. Ctrl+Shift+U toggles the user switcher. Cart state lives in a process-global map seeded per user — fine for a demo, intentionally not production. The add-to-cart buttons are visual only; they exist to show the rendering boundary (button is client-side, badge reads the per-user count via /api/header-state). Wiring a real action would be a server action that writes to the store and calls revalidateTag on the header endpoint — no rendering changes needed.


Security headers

next.config.ts ships a static CSP plus the usual hardening: Strict-Transport-Security (2y, preload), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a locked-down Permissions-Policy (no camera, microphone, geolocation, FLoC).

The CSP keeps 'unsafe-inline' on script-src because Next.js inlines its hydration payload (__next_f) and switching to a per-request nonce would force every page dynamic — incompatible with PPR. Upgrade path: experimental.sri once it stabilizes, which keeps static rendering and drops 'unsafe-inline'.


Observability

@vercel/analytics and @vercel/speed-insights are wired in src/app/layout.tsx. After deploying, the Vercel dashboard shows real-user LCP/CLS/INP per route — the lever to pull when a category page regresses.


Performance vs. real grocers

Lighthouse 13 desktop preset, headless Chrome, German residential connection (2026-05-02). bargen is the median of 3 runs (consistently scores 100); competitors are single cold runs. For a defensible benchmark take medians of 5+ runs per site.

Site Score LCP TBT CLS JS shipped Requests
bargen (/DE) 100 600 ms 0 ms 0.000 177 KB 36
rewe.de (/shop) 53 3395 ms 460 ms 0.019 1,307 KB 190
tesco.com 50 2652 ms 639 ms 0.142 ⚠ 3,088 KB 464
lidl.com 54 4909 ms ⚠ 37 ms 0.378 ⚠ 2,034 KB 114

Core Web Vitals "good" thresholds: LCP ≤ 2.5 s, TBT ≤ 200 ms, CLS ≤ 0.1. bargen is the only one of the four that lands in the green zone for all three.

What the numbers say:

  • LCP is dominated by render-blocking JS and origin TTFB. bargen's static shell paints at ~600 ms because the hero is in the prerendered HTML and the doc itself is served from the edge in ~20 ms.
  • TBT tracks JavaScript bootup almost 1:1. bargen ships ~177 KB of JS, the others 1.3–3.1 MB — that ratio shows up directly as 0 ms vs. 460–640 ms of blocking time.
  • CLS is a content-stability problem, not a perf one. Lidl's 0.378 means the page visibly jumps during load; bargen's Suspense fallbacks render the same shape as the streamed content, so the layout never shifts.

Honest caveats: the competitors are real shops with auth, geolocation, ads, consent, and A/B tooling — bargen is a focused demo. A like-for-like test would compare a category page on each after consent has been accepted, and include a mobile (4G + slow CPU) run, where the JS-budget gap usually widens.


Local development

pnpm install
cp .env.example .env.local        # set DATABASE_URL and REVALIDATE_SECRET
pnpm db:push                      # create schema
pnpm db:migrate-fts               # add tsvector column + GIN index
pnpm db:seed                      # load products and categories
pnpm dev

AUTH_SECRET is optional in dev; set it in production.


Demo path

  1. Cold load / — DevTools shows the doc streaming, hero is LCP-ready immediately, banner swaps from default to local.
  2. Switch country in the locale picker — URL becomes /GB, banner recomputes from the new geo.
  3. Search "milk" in the hero — autocomplete from the FTS suggest API, ranked.
  4. Hit a product page — static shell paints, body streams, breadcrumb and detail render together.
  5. Revalidate livecurl the webhook with tag=product:<slug>; refresh the page, the new price is in milliseconds, no rebuild.
  6. Cart and userCtrl+Shift+U switches users; the header cart badge reads the per-user seeded count from /api/header-state without rerendering the static shell. (The add-to-cart buttons themselves are visual placeholders — see "Auth and cart".)
  7. Agent-friendly fetchcurl -H "Accept: text/markdown" any product or category URL; the same page comes back as Markdown, served from a parallel rewrite tree.

About

Next.js 16 e-commerce demo — PPR, edge routing, and a deliberate caching strategy applied to a real webshop

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors