IAMUVIN

Case Studies & Build Logs

Building FreshMart: Redesigning a UK Grocery Platform for Performance

Uvin Vindula·September 8, 2025·12 min read
Share

TL;DR

FreshMart came to me with a grocery delivery platform that was bleeding conversions. Slow page loads, a checkout flow that confused users, and a CMS that the content team had given up on. I spent the first two weeks writing a 10-document engineering specification before touching any code. Then I rebuilt the entire platform on Next.js 15, Supabase, Stripe, Sanity CMS, and Upstash Redis — deployed on Vercel. The result: PageSpeed scores above 95 on mobile, LCP under 1.5 seconds, a checkout conversion rate that jumped measurably, and a content team that actually enjoys updating the site. The whole project took 10 weeks. This is the full breakdown of every decision, every tradeoff, and every lesson.


The Brief

The FreshMart team reached out through my website in early 2025. They were a mid-sized grocery delivery service operating across several regions in England, and their existing platform was holding them back.

The problems were specific and quantifiable. Their mobile PageSpeed score sat around 38. Product pages took over 6 seconds to become interactive on a 4G connection. The checkout flow had five steps — three of which asked for information that could have been inferred or combined. Their Contentful-based CMS had become so complex that the marketing team had stopped publishing recipes and seasonal content entirely, defaulting to social media posts instead. Their cart abandonment rate was north of 70%.

What made this project interesting to me was not just the technical rebuild. It was the business context. UK grocery delivery is one of the most competitive ecommerce verticals in the world. You are competing against Tesco, Sainsbury's, Ocado, and a growing list of rapid delivery startups. Every millisecond of load time, every unnecessary click in the checkout, every missing product image — these are not abstract quality concerns. They are revenue. In this market, performance is not a nice-to-have. It is survival.

The brief was straightforward: rebuild the platform for speed and conversion. Keep the existing product catalog and order history. Improve the content workflow so the team can publish without developer involvement. Ship within a quarter.

I took the project.


Why a Complete Rebuild

This is the question I always have to answer first, because the default assumption should always be iteration, not replacement. Rebuilding from scratch carries real risk — you lose battle-tested edge cases, you reset QA to zero, you burn calendar time on infrastructure instead of features. I have turned down rebuild projects before when incremental improvement was the smarter path.

FreshMart was genuinely a rebuild case. Here is why.

The existing platform was built on a custom PHP backend with a React SPA frontend. The architecture was a single monolithic API serving both the customer-facing site and an internal admin panel. There was no server-side rendering. The entire product catalog — thousands of items with images, descriptions, categories, pricing tiers, and availability data — was fetched client-side on every page load. The search was a client-side filter over the full catalog. The checkout was a multi-page form that round-tripped to the server on every step.

This was not a case where you could swap in a CDN and call it done. The rendering model was fundamentally wrong for the use case. Grocery shoppers browse dozens of products per session. They need instant category navigation, fast search with autocomplete, real-time stock availability, and a checkout that respects their time. A client-rendered SPA fetching everything on mount is the wrong architecture for this.

Beyond performance, the codebase had accumulated significant technical debt. No TypeScript. Minimal test coverage. A CSS architecture that mixed inline styles, CSS modules, and a global stylesheet with 4,000 lines of overrides. The deployment process was a manual SSH-and-rsync operation that the original developer had documented in a personal Notion page that nobody else had access to.

I presented three options to the FreshMart team: patch the performance issues (2-3 weeks, modest improvement), incrementally migrate to Next.js while keeping the backend (8-12 weeks, significant disruption), or clean rebuild with a proper specification phase (10-12 weeks, best outcome). They chose the rebuild. I told them the specification phase would take 2 weeks before any code was written, and that this was non-negotiable. They agreed.


The 10-Document Engineering Spec

This is where my process differs from most freelancers and agencies. Before I write a single component, I write the project. Not a brief. Not a one-page proposal. A comprehensive engineering specification that serves as the contract between what we said we would build and what we actually build.

For FreshMart, the specification consisted of 10 documents:

1. Project Charter. Scope, timeline, stakeholders, success metrics. The charter defined three measurable goals: PageSpeed mobile score above 90, checkout completion rate above 40% (up from approximately 30%), and content publishing turnaround under 30 minutes (down from 2-3 days requiring developer involvement).

2. Technical Architecture. The full system diagram. Every service, every data flow, every integration point. This is where I committed to the stack: Next.js 15 with the App Router, Supabase for auth and database, Stripe for payments, Sanity CMS for content, Upstash Redis for caching, Vercel for hosting.

3. Data Model. Every table, every relationship, every index. PostgreSQL schema designed for the query patterns we knew we needed — product listings with category filtering, user carts with real-time stock validation, order history with status tracking, delivery slot management with capacity constraints.

4. API Specification. Every endpoint, every request/response shape, every error code. RESTful where it made sense, Server Actions where it made more sense. This document saved weeks of back-and-forth during implementation.

5. Component Library. Every UI component cataloged before implementation. Atomic design methodology — atoms (buttons, badges, inputs), molecules (product cards, search bars, cart items), organisms (navigation, product grids, checkout forms), templates (category page, product detail, checkout flow).

6. Content Model. The Sanity CMS schema. Document types, field definitions, validation rules, editorial workflows. I designed this with the content team directly, watching them work with the old CMS and identifying every friction point.

7. Performance Budget. Specific targets for every metric. LCP under 1.5 seconds. Total blocking time under 200 milliseconds. Cumulative layout shift under 0.05. JavaScript bundle under 150KB gzipped for the initial load. Image budgets per page type.

8. Security Specification. Authentication flows, authorization rules, input validation, CORS policy, CSP headers, rate limiting strategy. Row Level Security policies for Supabase.

9. Testing Strategy. What gets unit tested, what gets integration tested, what gets E2E tested. Playwright scripts for the critical user journeys: browse, search, add to cart, checkout, order tracking.

10. Deployment and Operations. CI/CD pipeline, preview deployments, environment management, monitoring, alerting, rollback procedures.

Two weeks. Ten documents. Every decision recorded, every tradeoff explicit, every assumption stated. When I started writing code in week three, I knew exactly what I was building. There were no ambiguous requirements to discover mid-sprint. No architectural decisions to debate during implementation. No "I thought we agreed on X" conversations.

This specification approach is the single biggest factor in why my projects ship on time. It is also the hardest thing to sell to clients, because it feels like you are paying for two weeks of "not coding." Every client who has gone through the process has told me afterward that it was the most valuable part of the engagement.


Architecture Decisions

The stack for FreshMart was chosen for specific reasons, not preferences. Every tool earned its place.

Next.js 15 with App Router. The grocery platform needed server-side rendering for SEO and initial load performance, but it also needed client-side interactivity for the shopping experience — real-time cart updates, instant search, dynamic filtering. Next.js gives you both without the complexity of managing two separate applications. The App Router's Server Components let me render product listings, category pages, and content pages entirely on the server, streaming HTML to the browser before any JavaScript loads. Client Components handle the cart, search overlay, and checkout form — the pieces that genuinely need client-side state.

The rendering strategy was deliberate. I used static generation with ISR for category pages and content — these change infrequently and benefit from edge caching. Product detail pages use dynamic rendering with short cache TTLs because stock and pricing can change throughout the day. The cart and checkout are fully dynamic, no caching, real-time validation against current stock levels.

Supabase. PostgreSQL with a managed auth layer, real-time subscriptions, and Row Level Security. For a grocery platform, the relational model is non-negotiable — products have categories, categories have hierarchies, orders have line items, users have addresses, delivery slots have capacity constraints. This is relational data. The alternative would have been a traditional PostgreSQL instance, but Supabase gives you auth, RLS, and the admin dashboard for free, which reduced the development timeline by at least a week.

Stripe. Non-negotiable for UK ecommerce. PCI compliance handled. Apple Pay and Google Pay out of the box. Strong Customer Authentication for PSD2 compliance — which is a legal requirement in the UK, not an optional feature. I used Stripe Checkout for the payment step, which means the actual card processing happens on Stripe's hosted page. This eliminates an entire category of security concerns.

Sanity CMS. The content team needed to manage recipes, seasonal promotions, banner content, and editorial pages without touching code. I evaluated Contentful (their existing CMS), Strapi, and Sanity. Sanity won for three reasons: the real-time collaborative editing experience is genuinely superior, the GROQ query language is more powerful than GraphQL for the content patterns we needed, and the portable text format gives the frontend complete control over rendering. The content team was productive within an hour of their first training session.

Upstash Redis. Caching layer for product catalog queries, session data, and rate limiting. Serverless Redis that scales to zero — critical for a Vercel deployment where you do not want to manage connection pools to a traditional Redis instance. More on the caching strategy later.

Vercel. Edge network, preview deployments, analytics. The deployment pipeline writes itself: push to a branch, get a preview URL, merge to main, deploy to production. Zero-downtime deployments with instant rollback.


The Design System — Harvest Modern

Every FreshMart page needed to feel fresh, trustworthy, and fast. I designed a system I called Harvest Modern — warm and organic enough to evoke quality groceries, clean and structured enough to not get in the way of shopping.

The color palette started with the brand's existing green, which I refined into a system:

Primary         #2D7A3A        (Forest Green — trust, freshness)
Primary Light   #4A9E5B        (Leaf Green — hover states, accents)
Primary Dark    #1B5E28        (Deep Green — headers, emphasis)

Accent          #F4A534        (Harvest Gold — CTAs, promotions, badges)
Accent Warm     #E8913A        (Autumn Orange — urgency, offers)

Background      #FAFBF7        (Cream White — warm neutral base)
Surface         #FFFFFF        (Pure White — cards, panels)
Elevated        #F0F3EC        (Sage Mist — secondary backgrounds)

Text Primary    #1A2E1D        (Deep Forest — body text)
Text Secondary  #4A6B50        (Muted Green — secondary info)
Text Muted      #8A9E8F        (Sage Grey — placeholders, captions)

Success         #22A652        (Bright Green — in stock, confirmed)
Warning         #E8913A        (Amber — low stock, delivery notes)
Danger          #D94444        (Red — out of stock, errors)

Typography used two families: DM Sans for headings and navigation — geometric, modern, excellent at large sizes — and Inter for body text and UI elements. The combination gives you warmth without sacrificing readability at small sizes on mobile screens.

The spacing system followed an 8px base grid. Every margin, every padding, every gap is a multiple of 8. This is not arbitrary — it creates visual rhythm that users feel even if they cannot articulate it. Product cards, category headers, section dividers, checkout form fields — everything aligns to the same invisible grid.

Component design followed three rules. First, every interactive element has a minimum touch target of 48x48 pixels. Grocery shopping is a mobile-first activity — over 70% of FreshMart's traffic comes from phones. Second, product images are always 1:1 aspect ratio with reserved space, eliminating layout shift. Third, every button has three clearly distinct states: default, hover/focus, and disabled. No ambiguity about what is clickable.

The product card deserves specific mention because it is the most repeated element on the platform. Each card shows the product image, name, unit price, price-per-unit (required by UK law for grocery items), stock status, and an add-to-cart button. All of this in a card that loads in under 50 milliseconds and takes up exactly the right amount of space on a 375px-wide mobile screen. I went through eleven iterations of this card before landing on the final design. The difference between iteration 3 and iteration 11 is subtle to describe but obvious to use.


Performance Strategy — LCP Under 1.5s

The performance budget was not aspirational. It was a hard requirement, tested on every deployment.

Server Components as the default. Every page that does not need client-side interactivity renders entirely on the server. The category page — which is the most visited page type — sends zero JavaScript to the browser for the product grid itself. The products render as static HTML streamed from the edge. The only JavaScript on a category page is the cart widget, the search overlay, and the filter controls. Everything else is HTML and CSS.

Image pipeline. Product images go through a strict pipeline. Source images are uploaded at 1200x1200 minimum resolution. Next.js Image component handles format negotiation (AVIF where supported, WebP as fallback), responsive sizing, and lazy loading. Above-the-fold images on category pages use priority to trigger preload hints. Every product image has explicit width and height attributes, eliminating layout shift entirely. The hero banner on the homepage uses a fetchpriority="high" preload link in the document head.

Font loading. DM Sans and Inter are self-hosted, not loaded from Google Fonts. The CSS uses font-display: swap with a carefully chosen fallback stack that matches the metrics of the web fonts. This means text is visible immediately on first paint, and the font swap when the web font loads causes zero layout shift because the fallback has been adjusted to match the line height and character width.

Bundle strategy. The initial JavaScript bundle is under 120KB gzipped. I achieved this through aggressive code splitting — every page loads only the JavaScript it needs. The checkout form, the account dashboard, the recipe pages — these are all separate chunks that load on navigation, not on initial page load. Third-party scripts (analytics, customer support widget) load after the load event, never blocking the critical path.

Streaming and Suspense. Product listings use React Suspense with streaming. The page shell — header, navigation, category title, filter sidebar — renders instantly. The product grid streams in as the database query completes. On a fast connection this is imperceptible. On a slow 3G connection, the user sees the page structure immediately and watches products fill in over the next second or two. This is dramatically better than staring at a blank screen for 4 seconds.

Core Web Vitals results on production:

LCP     1.2s - 1.5s     (category pages, mobile 4G)
INP     < 80ms           (add to cart, filter interactions)
CLS     0.01             (reserved image space, font matching)
TTFB    < 200ms          (Vercel edge, ISR cache hits)

PageSpeed Insights consistently scores 95-98 on mobile for category and product pages. The homepage scores 96. The checkout page — which has more client-side JavaScript for form validation and Stripe integration — scores 92.


Stripe Integration

Payment integration for a UK grocery platform has requirements that a typical ecommerce project does not.

PSD2 and Strong Customer Authentication. European regulation requires two-factor authentication for online payments above certain thresholds. Stripe handles this through 3D Secure, but you need to build your checkout flow to accommodate the redirect and return flow. I used Stripe Checkout in embedded mode — the payment form renders within the FreshMart checkout page, but the actual card processing is handled by Stripe. This gives the user a seamless experience while keeping FreshMart completely out of scope for PCI DSS compliance.

The checkout flow. I reduced the checkout from five steps to two. Step one: delivery details. The user confirms their delivery address (pre-filled from their account), selects a delivery slot, and reviews their order. Step two: payment. Stripe Checkout renders inline with Apple Pay, Google Pay, and card entry. That is it. Two steps. No separate "review order" page that just repeats what you already saw. No login wall if you are already authenticated. No mandatory account creation — guest checkout is supported with an optional "save my details" checkbox.

Delivery slot management. This was the most complex piece of the checkout. Each delivery slot has a capacity limit. When a user selects a slot, I hold it with a temporary reservation in Redis (5-minute TTL) to prevent overselling. If the user completes payment within 5 minutes, the reservation converts to a confirmed booking. If they abandon, the slot releases automatically. This pattern avoids the problem of slot exhaustion from abandoned checkouts while still preventing double-booking.

Webhooks. Stripe sends payment confirmation via webhooks. The webhook handler validates the signature using the endpoint secret, processes the payment intent, creates the order record in Supabase, sends the confirmation email via Resend, and updates the delivery slot capacity. Idempotency keys prevent duplicate order creation if Stripe retries the webhook. The entire webhook handler runs in under 200 milliseconds.

Subscription-style orders. FreshMart offers weekly recurring orders for staple items. I implemented this using Stripe Subscriptions with a custom billing cycle. Every Monday morning, a Vercel Cron job checks for active recurring orders, validates stock availability for each item, and either processes the payment automatically or notifies the customer if an item is out of stock. The customer can pause, modify, or cancel their recurring order from their account dashboard at any time.


Sanity for Content

The content team at FreshMart had effectively stopped publishing because their previous CMS made everything painful. My goal was to make content publishing feel as natural as writing in a word processor.

Sanity Studio was deployed as a route within the Next.js application at /studio. The content team accesses it directly — no separate application, no separate login. Their Supabase authentication carries over.

I designed five document types:

Recipe. Title, slug, description, hero image, ingredients (array of references to products in the catalog), method steps (portable text with inline images), prep time, cook time, servings, dietary tags (vegetarian, vegan, gluten-free, dairy-free), and a featured flag. When a recipe references a product, the frontend automatically renders an "Add all ingredients to cart" button that adds every referenced product in the correct quantity. This single feature — connecting content to commerce — increased average order value measurably.

Promotion. Banner content for the homepage and category pages. Start date, end date, hero image, copy, CTA link, and display rules (which pages, which user segments). Promotions auto-publish and auto-expire based on their date range. No developer intervention required.

Editorial Page. Free-form content pages for "About Us," "Delivery Areas," "Sustainability," and similar. Portable text with full rich text support, image galleries, and embedded components.

FAQ. Question and answer pairs organized by category, rendered with structured data markup for search engine rich results.

Announcement Bar. A simple document type for the site-wide announcement bar. Text, link, background color. The content team can update it in seconds.

The GROQ queries for fetching this content are co-located with the components that render them. Each query is typed end-to-end using Sanity's TypeGen — the TypeScript types for the query response are generated from the schema, so there is zero runtime type mismatch risk.

Content previews use Sanity's real-time preview mode. The content team clicks "Preview" in the studio and sees exactly how their content will render on the live site, with live updates as they type. No deploy cycle. No staging environment. Real-time visual feedback.


Redis Caching Layer

Upstash Redis serves three purposes in the FreshMart architecture: response caching, session management, and rate limiting.

Product catalog caching. The full product catalog query — categories, subcategories, products with images and pricing — takes approximately 400 milliseconds to execute against PostgreSQL. For a category page that might receive hundreds of requests per minute, hitting the database every time is wasteful. I cache the catalog response in Redis with a 5-minute TTL. When a product is updated in the admin panel, a webhook invalidates the relevant cache keys. The result: category page data loads in under 10 milliseconds from cache, with a guaranteed freshness window of 5 minutes.

Cart session storage. Guest users (no account) have their cart stored in Redis, keyed by a session cookie. Authenticated users have their cart in Supabase with a Redis cache layer. This means cart operations — add, remove, update quantity — resolve in single-digit milliseconds regardless of authentication state. When a guest user creates an account, the Redis cart merges with any existing Supabase cart automatically.

Rate limiting. Every API endpoint has rate limiting enforced via Upstash Redis. The token bucket algorithm allows burst traffic (a user rapidly adding items to their cart) while preventing abuse. The rate limits are different per endpoint type: read endpoints allow 100 requests per minute, write endpoints allow 30, and the checkout endpoint allows 5. Rate-limited responses include a Retry-After header.

Cache invalidation strategy. This is where most caching implementations go wrong. I used a tag-based invalidation system. Every cached response is tagged with the entities it depends on — a category page cache is tagged with category:{id} and products:{categoryId}. When a product is updated, I invalidate all cache entries tagged with that product's category. This is surgical invalidation — only the affected caches are cleared, not the entire cache. The invalidation happens synchronously in the admin update handler, so there is zero delay between updating a product and the cached data being refreshed.


Results and Lessons

The FreshMart rebuild launched after 10 weeks of development, precisely on schedule. The first two weeks were the specification phase. Weeks three through eight were implementation. Weeks nine and ten were testing, content migration, and launch preparation.

Performance results. Mobile PageSpeed jumped from 38 to 95-98 depending on the page. LCP dropped from over 6 seconds to consistently under 1.5 seconds. Time to Interactive went from 8+ seconds to under 2 seconds. These are not synthetic benchmark improvements — they translate directly to user experience. The site feels instant on modern phones and genuinely fast even on budget Android devices over 4G.

Business results. Checkout completion rate improved significantly in the first month after launch. Average session duration increased, which for an ecommerce site indicates that users are browsing more products, not struggling with navigation. The content team published more editorial content in the first month on the new CMS than they had in the previous six months on the old one. The recipe-to-cart feature became one of the highest-converting paths on the site.

Technical lessons. Several things I would emphasize for anyone building a similar platform:

First, the specification phase is not optional for projects of this size. Two weeks of planning saved at least four weeks of rework, scope negotiation, and architectural backtracking. Every client pushes back on this upfront. Every client is grateful for it afterward.

Second, Server Components change the economics of frontend performance. When 70% of your pages send zero JavaScript to the browser, you start from a fundamentally different performance baseline. The mental model shift — thinking of components as server-first and opting into client-side interactivity only when needed — takes time, but the payoff is enormous.

Third, cache invalidation is a design problem, not an implementation problem. If you design your cache keys and tags thoughtfully upfront, invalidation becomes trivial. If you bolt caching onto an existing system without thinking about invalidation, you will spend more time debugging stale data than you saved on database queries.

Fourth, the checkout flow is the most important page on any ecommerce site, and most ecommerce sites treat it as an afterthought. Every unnecessary field, every extra step, every moment of confusion is a customer who closes the tab. Two steps. Auto-fill everything you can. Do not ask for information you already have.

Fifth, content-to-commerce integration is undervalued. The recipe feature — where editorial content directly connects to purchasable products — is not a gimmick. It is a fundamentally different user journey that bypasses the traditional browse-search-add funnel. Users who arrive via a recipe have higher intent and larger baskets.


Key Takeaways

  1. Spec before you build. A 10-document engineering specification is not overhead. It is the foundation that makes a 10-week delivery possible.
  1. Performance is a feature. In competitive UK ecommerce, the difference between a 6-second and a 1.5-second load time is not a technical metric. It is revenue.
  1. Server Components are a paradigm shift. Default to server rendering. Opt into client-side interactivity deliberately and sparingly.
  1. Cache with intent. Tag-based invalidation, short TTLs, and surgical cache clearing. Never cache without a clear invalidation strategy.
  1. Checkout is sacred. Two steps maximum. Auto-fill everything. Guest checkout always. Every additional field costs you conversions.
  1. Connect content to commerce. Recipes, editorial content, and seasonal guides should link directly to products. Content is a conversion channel, not a blog.
  1. Choose tools that earn their place. Every tool in the FreshMart stack — Next.js, Supabase, Stripe, Sanity, Upstash, Vercel — was chosen for a specific reason documented in the architecture spec. No defaults. No assumptions.

*I am Uvin Vindula — a Web3 and AI engineer working across Sri Lanka and the UK. I build production-grade platforms with a focus on performance, conversion, and clean architecture. FreshMart is one example of how I approach UK market projects: specification-first, performance-obsessed, and built to scale.*

*If you are looking for someone to build or rebuild your platform, check out my services or reach out directly at contact@uvin.lk. I take on a limited number of client projects each quarter to ensure every one gets the full IAMUVIN treatment.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.