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.
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 |
┌─ 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.
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).
src/proxy.ts runs at the edge before each non-asset request:
- If the path already starts with a known locale (
DE/GB/US), continue. - Otherwise prefer the
localecookie, falling back tox-vercel-ip-country. - 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.
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/markdown — next.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"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.
- Generated
tsvectorcolumn with weighted name/brand/description (src/lib/db/schema.ts), indexed with GIN. searchProductstokenizes and prefix-matches withto_tsqueryandts_rank; the cachedrunSearchis 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/searchpage is RSC, also cached.
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.
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'.
@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.
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.
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 devAUTH_SECRET is optional in dev; set it in production.
- Cold load
/— DevTools shows the doc streaming, hero is LCP-ready immediately, banner swaps from default to local. - Switch country in the locale picker — URL becomes
/GB, banner recomputes from the new geo. - Search "milk" in the hero — autocomplete from the FTS suggest API, ranked.
- Hit a product page — static shell paints, body streams, breadcrumb and detail render together.
- Revalidate live —
curlthe webhook withtag=product:<slug>; refresh the page, the new price is in milliseconds, no rebuild. - Cart and user —
Ctrl+Shift+Uswitches users; the header cart badge reads the per-user seeded count from/api/header-statewithout rerendering the static shell. (The add-to-cart buttons themselves are visual placeholders — see "Auth and cart".) - Agent-friendly fetch —
curl -H "Accept: text/markdown"any product or category URL; the same page comes back as Markdown, served from a parallel rewrite tree.