Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 078 / 100

E-commerce frontend lessons: what developers should know

From a full storefront revamp.

Expona Admin9 min read

Last month, we completed a full storefront revamp for one of our e-commerce clients—moving from a legacy Shopify Plus setup to a custom Next.js frontend with a .NET headless commerce API. The project forced us to rethink nearly every layer of our frontend stack, and I want to share the concrete e-commerce frontend lessons that actually moved the needle on conversion rates and customer satisfaction.

Product Pages Are SEO Pages — Static Generation with Revalidation, Structured Data, Fast Images

When we started the revamp, product pages were being rendered client-side. Load times were fine on desktop, but on 3G connections, users were staring at an empty page for 2–3 seconds before the JavaScript hydrated. We were also losing organic traffic because Google's crawler couldn't index product details reliably.

The fix was straightforward in theory: generate product pages statically at build time, then revalidate them when inventory or prices change. In practice, we had to think about scale. With 50,000+ SKUs, we couldn't pre-generate everything. Instead, we used incremental static regeneration (ISR) in Next.js: generate the top 5,000 products at build time, generate others on first request, and cache for 3,600 seconds.

tsx
// pages/products/[slug].tsx
export const getStaticProps: GetStaticProps = async (context) => {
  const { slug } = context.params as { slug: string };
  
  try {
    const product = await fetchProduct(slug);
    
    if (!product) {
      return { notFound: true };
    }
    
    return {
      props: { product },
      revalidate: 3600, // Revalidate every hour
    };
  } catch (error) {
    return {
      revalidate: 60, // Shorter revalidation on error
    };
  }
};

export const getStaticPaths: GetStaticPaths = async () => {
  const topProducts = await fetchTopSellingProducts(5000);
  
  return {
    paths: topProducts.map((p) => ({
      params: { slug: p.slug },
    })),
    fallback: 'blocking', // Generate other pages on demand
  };
};

Structured data is non-negotiable for e-commerce. Google uses schema.org markup to understand product details, prices, availability, and ratings. Without it, rich snippets don't show in search results. We embedded JSON-LD on every product page:

tsx
const productSchema = {
  '@context': 'https://schema.org/',
  '@type': 'Product',
  name: product.name,
  image: product.images.map((img) => img.url),
  description: product.description,
  brand: { '@type': 'Brand', name: product.brand },
  offers: {
    '@type': 'Offer',
    url: `${baseUrl}/products/${product.slug}`,
    priceCurrency: 'USD',
    price: product.price.toString(),
    availability: product.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
    seller: { '@type': 'Organization', name: 'Our Store' },
  },
  aggregateRating: product.rating ? {
    '@type': 'AggregateRating',
    ratingValue: product.rating.average.toString(),
    reviewCount: product.rating.count.toString(),
  } : undefined,
};

For images, we optimized aggressively. Every product image was served through Next.js Image with WebP conversion, lazy loading, and responsive srcsets. Compared to Shopify's CDN, our images loaded 40% faster on 4G and 60% faster on 3G. We also configured a 60-second cache invalidation window in our CDN so updated product photos would appear quickly without stale content.

Cart State — Server-Side Cart with Optimistic Updates Feels Fast and Stays Consistent

One of the first things we killed was client-side-only cart state. We'd see bugs like "item removed but quantity still shows" or "user adds item, refreshes, and it's gone." We moved to a hybrid approach: the cart lives in our .NET API, but the React frontend shows optimistic updates immediately.

Here's the pattern: user adds an item, the UI updates instantly, and in the background we send a request to the server. If the server confirms the change, we're done. If it fails (out of stock, price changed, user logged out), we revert the UI and show an error.

tsx
// hooks/useCart.ts
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export const useCart = () => {
  const queryClient = useQueryClient();
  const [optimisticCart, setOptimisticCart] = useState(null);

  const { data: cart } = useQuery({
    queryKey: ['cart'],
    queryFn: () => fetch('/api/cart').then((r) => r.json()),
  });

  const { mutate: addItem } = useMutation({
    mutationFn: (item: CartItem) =>
      fetch('/api/cart/items', {
        method: 'POST',
        body: JSON.stringify(item),
      }).then((r) => r.json()),
    
    onMutate: async (newItem) => {
      // Cancel queries to prevent overwrite
      await queryClient.cancelQueries({ queryKey: ['cart'] });
      
      // Snapshot old data
      const previousCart = queryClient.getQueryData(['cart']);
      
      // Optimistic update
      setOptimisticCart((prev) => ({
        ...prev,
        items: [...(prev?.items || []), newItem],
      }));
      
      return { previousCart };
    },
    
    onError: (err, newItem, context) => {
      // Revert on error
      setOptimisticCart(context.previousCart);
      console.error('Failed to add item', err);
    },
    
    onSuccess: () => {
      // Refetch cart from server
      queryClient.invalidateQueries({ queryKey: ['cart'] });
      setOptimisticCart(null);
    },
  });

  return {
    cart: optimisticCart || cart,
    addItem,
  };
};

On the backend, we store the cart in Redis with a session key. When a user logs in, we merge their anonymous cart into their authenticated cart using a last-write-wins strategy for quantity conflicts. This prevents data loss and keeps the experience seamless across devices.

We also added inventory reservations. When a user adds an item to the cart, we reserve it for 15 minutes. If they don't proceed to checkout, the reservation expires and the item becomes available again. This reduced overselling by 99%.

Checkout Performance — Minimal JavaScript and No Third-Party Scripts on Payment Pages

Our payment page is intentionally stripped down. No analytics pixels, no chat widgets, no ads retargeting. Every third-party script is a trust issue and a performance risk. Payment providers like Stripe and PayPal inject their own code, so we can't avoid all external scripts, but we can minimize the blast radius.

We use a separate checkout subdomain (checkout.example.com) with strict Content Security Policy headers. This sandboxes third-party JavaScript and prevents data leakage to unrelated services.

tsx
// middleware.ts (in checkout routes)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self' https://js.stripe.com",
      "frame-src https://js.stripe.com",
      "connect-src 'self' https://api.stripe.com",
      "img-src 'self' data: https:",
      "style-src 'self' 'unsafe-inline'",
      "font-src 'self'",
    ].join('; ')
  );

  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
}

export const config = {
  matcher: ['/checkout/:path*'],
};

The checkout form itself is rendered server-side with minimal React. We use a progressive enhancement approach: the form works without JavaScript, and React layers on top for validation and UX polish. Payment processing is delegated entirely to Stripe Elements, which manages its own iframe security.

We also deferred all non-critical JavaScript. Anything that isn't involved in payment acceptance—order summary calculations, form validation, error messages—is loaded with the defer attribute or lazy-loaded after the payment form renders.

Before the revamp, our checkout page had a Largest Contentful Paint (LCP) of 2.8 seconds on 4G. After these changes, it's 0.9 seconds. More importantly, 99.2% of users now see the payment form before they finish reading the page copy.

Measure Revenue, Not Just Lighthouse — Faster Pages Should Show Up in Conversion Metrics

This is where most developers go wrong. They optimize for Lighthouse scores and forget to check whether conversion rates actually improved. We obsess over metrics that matter to the business.

We track three conversion funnels:

  • View to cart: Percentage of users who view a product and add it to the cart within 5 minutes.
  • Cart to checkout: Percentage of users with items in the cart who start the checkout flow.
  • Checkout to paid: Percentage of users who complete payment.

After the revamp, our Lighthouse score jumped from 64 to 91. But what actually mattered was that "view to cart" improved from 8.2% to 10.4%, and "checkout to paid" improved from 71% to 76%. That's a $320,000 annual lift on a business doing $5M/year.

We implemented event-based tracking to tie performance to revenue:

tsx
// utils/analytics.ts
export const trackEvent = (eventName: string, data: Record) => {
  // Send to analytics service
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify({
      event: eventName,
      timestamp: Date.now(),
      sessionId: getSessionId(),
      ...data,
    }),
  });
};

// In product page
export const ProductPage = ({ product }: Props) => {
  const { addItem } = useCart();

  const handleAddToCart = async () => {
    const startTime = performance.now();
    await addItem(product);
    const duration = performance.now() - startTime;

    trackEvent('add_to_cart', {
      productId: product.id,
      price: product.price,
      duration, // How long did the request take?
      cartTotalItems: cart.items.length,
    });
  };

  return (
    Add to Cart
  );
};

We correlate performance metrics with revenue. When page load time increases by 100ms, we see a 0.4% drop in conversion rate. This relationship is consistent across device types and geography, so we use it to justify optimization work.

Common Mistakes

  • Pre-generating all product pages at build time: With 50,000+ products, this turns your build into a 30-minute process and wastes server memory on old, unsold SKUs. Use ISR and fallback instead.
  • Storing cart state only in localStorage: Doesn't survive logout, breaks on private browsing, and creates sync issues across tabs. Use server-side storage with local caching.
  • Running A/B tests on the checkout page: Stripe and PayPal make payment processing fragile. Test variations on the product page or cart, not during payment.
  • Optimizing for lab metrics (Lighthouse) instead of field metrics (Core Web Vitals): Your real users are on 3G with older phones. Measure where it matters.
  • Serving images from a generic CDN without compression: Product photos are often 5–10MB JPGs. Optimize format, size, and delivery. We cut image traffic by 75% with WebP and responsive images.

Key Takeaways

  • Use Next.js static generation with ISR for product pages. Structure them with schema.org markup and serve optimized images. This improves SEO and first-page load time.
  • Move cart state to the server (Redis or a database) with optimistic UI updates. Reserves inventory to prevent overselling.
  • Strip down the checkout page to the essentials. Use strict Content Security Policy to sandbox third-party payment scripts. Measure LCP and form interaction time, not just Lighthouse.
  • Link performance improvements to business metrics: conversion rate, average order value, repeat purchase rate. A 100ms improvement should show up in revenue.
  • Test on real devices and real networks (4G, 3G). Lighthouse scores on a MacBook Pro don't reflect your customers' experience.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles