Lilac: A Modern E-commerce Frontend That Doesn't Typecast

25 days ago

FrontendCheck it out
Technologies
GraphQLPrisma ORMKeystoneSearch Engine Optimization (SEO)Performance OptimizationFramer Motion
Illustration

The Context

E-commerce is table stakes now. Everyone expects Amazon-level polish, but most teams ship glorified product catalogs with a PayPal button. At Lilac, we wanted to build something different—a furniture store that felt like browsing a curated gallery, not wrestling with a 2010s shopping cart.

The requirements seemed straightforward: mid-century modern furniture catalog, seamless checkout, mobile-first design. But beneath that simplicity lived a beast of technical complexity. Real-time cart synchronization. GraphQL code generation pipelines. Payment orchestration. Animation systems that don't make users seasick. Search that actually works.

This is the story of how we built an e-commerce frontend that scales, performs, and doesn't make developers want to quit programming.

The Problem

Modern e-commerce frontends are a minefield of technical debt waiting to explode:

  1. The TypeScript Tango: GraphQL schemas change constantly, but your frontend types lag behind by weeks. Suddenly, product.variant.price is undefined and your checkout is broken.
  2. State Management Hell: Cart state lives in localStorage. User state lives in Apollo cache. UI state lives in React state. Search filters live in URL params. Good luck keeping them synchronized.
  3. The Animation Performance Cliff: Framer Motion makes everything look amazing until you have 50 product cards animating simultaneously and your 120fps becomes a slideshow.
  4. Payment Integration Nightmares: Stripe works great in development. Production users report mysterious payment failures that only happen on iOS Safari with Dark Mode enabled.
  5. The Mobile Responsiveness Trap: Material-UI's responsive system works until you need custom breakpoints, then you're writing media queries like it's 2015.

The Architecture

GraphQL Code Generation Pipeline

The backbone of our type safety came from a sophisticated GraphQL code generation setup that goes far beyond basic type generation.

// codegen.ts
const config: CodegenConfig = {
  schema: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
  documents: "src/lib/graphql/**/*.graphql",
  generates: {
    "src/lib/graphql/helpers/index.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-react-apollo",
        "typescript-mock-data",
      ],
      config: {
        apolloClientInstanceImport: "../apollo",
        terminateCircularRelationships: true,
      },
    },
  },
};

The Problem: Generated code often needs post-processing to work with our specific Apollo Client setup and Next.js SSR requirements.

The Solution: A custom post-processing pipeline that modifies generated files:

// post-codegen.js
const modifyGeneratedFiles = (filePath) => {
  fs.readFile(filePath, "utf8", (err, data) => {
    let modifiedData = data.replace(
      /Document,\s?(\n.*)?options/gm,
      "Document, options as any"
    );

    if (!modifiedData.includes("getApolloClient")) {
      modifiedData = `
        import { getApolloClient } from '../apollo'\n${modifiedData}`;
    }

    fs.writeFile(filePath, modifiedData, "utf8");
  });
};

This generates fully typed hooks with SSR support and mock data for testing—all automatically synced with our backend schema.

Unified State Management Architecture

Rather than fighting multiple state management libraries, we built a unified system around Apollo Client and React Context:

// common.provider.tsx
interface ICommonState {
  cart: {
    items: DeepRequired<ICartItem>[];
    updating: boolean;
  };
  search: {
    query: string;
    filters: IFilters;
  };
  ui: {
    showSearch: boolean;
    popover: string | null;
  };
}

const CommonContext = createContext<{
  state: ICommonState;
  dispatch: Dispatch<ICommonAction>;
}>();

The Nuance: Cart state needs to be optimistically updated for UX, but also synchronized with the server for persistence. We handle this with a dual-layer approach:

// common.hooks.tsx
export const useCartActions = ({ id }: { id: string }) => {
  const { state, dispatch } = useContext(CommonContext);

  const handleAdd = (variantId: string) => async (ev: MouseEvent) => {
    // Optimistic update
    dispatch({ type: "cart-add-optimistic", payload: { variantId } });

    try {
      const { data } = await handleAddToCart({
        variables: { where: { id: variantId } },
      });

      // Server confirmation
      dispatch({ type: "cart-sync", payload: data.addToCart });
    } catch (error) {
      // Rollback optimistic update
      dispatch({ type: "cart-rollback", payload: { variantId } });
    }
  };
};

Performance-Optimized Animation System

Framer Motion is powerful but can destroy performance if not used carefully. We built a constraint system:

// ProductsGrid.tsx
const item = {
  hidden: {
    y: 50,
    opacity: 0,
    scale: 0.95,
  },
  visible: {
    y: 0,
    opacity: 1,
    scale: 1,
    transition: {
      type: "spring",
      damping: 25,
      stiffness: 500,
    },
  },
};

// Stagger animations with performance budgets
const container = {
  visible: {
    transition: {
      staggerChildren: 0.1,
      delayChildren: 0.2,
    },
  },
};

The Critical Insight: Animate transforms and opacity only. Never animate width, height, or anything that triggers layout recalculation.

Stripe Payment Orchestration

Payment processing is where most e-commerce sites fail. Our implementation handles the complete flow:

// Checkout.tsx
const handleSubmit = async (event: FormEvent) => {
  event.preventDefault();

  // 1. Validate form
  const { error: submitError } = await elements.submit();
  if (submitError) throw new Error(submitError.message);

  // 2. Create payment intent with coupon
  const { data } = await createPaymentIntent({
    variables: { couponCode },
  });

  // 3. Confirm payment with Stripe
  const { paymentIntent, error } = await stripe.confirmPayment({
    elements,
    redirect: "if_required",
    clientSecret: data.createPaymentIntent.client_secret,
  });

  // 4. Create order on successful payment
  if (paymentIntent.status === "succeeded") {
    await confirmPaymentAndCreateOrder({
      variables: { paymentIntentId: paymentIntent.id, couponCode },
    });
  }
};

The Challenge: Handling edge cases like network failures between payment confirmation and order creation. Our solution uses idempotent operations and retry logic.

Responsive Image Optimization

Every product image is optimized for multiple breakpoints using Cloudinary:

// CloudImage.tsx
export const generateSizes = (sizes: { xs: number; md: number }) => {
  return Object.entries(sizes)
    .map(([key, value]) =>
      `(max-width: ${breakpoints[key]}px) ${Math.round(100 / value)}vw`
    )
    .join(', ');
};

<CloudImage
  src={imageURL}
  sizes={generateSizes({ xs: 6, md: 3 })}
  style={{ objectFit: 'cover' }}
/>

Real-Time Product Filtering

Our filtering system handles complex queries with real-time updates:

// ProductsGrid.tsx
const handleApply = (config: IFilters) => {
  let refetchQuery: PaginatedProductsQueryVariables["where"] = {};

  if (config.price.length === 2) {
    const [min, max] = config.price;
    refetchQuery = {
      ...refetchQuery,
      lowestPrice: { gte: min, lte: max },
    };
  }

  if (config.category.length >= 1) {
    refetchQuery = {
      ...refetchQuery,
      category: { slug: { in: config.category } },
    };
  }

  handleRefetch(refetchQuery, refetchSortQuery);
};

Infinite Scroll with Apollo Cache Management

Managing paginated data with Apollo Client requires careful cache management:

// ProductsGrid.tsx
const { data, loading, fetchMore } = usePaginatedProductsQuery({
  variables: { limit, where },
  notifyOnNetworkStatusChange: true,
});

const handleFetchMore = () => {
  fetchMore({
    variables: { skip: dataArray.length },
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev;

      return {
        ...fetchMoreResult,
        products: [...prev.products, ...fetchMoreResult.products],
      };
    },
  });
};

The Impact

  • Code Generation Is Infrastructure: Treating GraphQL code generation as a build tool rather than a convenience transformed our development velocity. The initial setup took a week. The time saved in the following months was immeasurable.
  • Optimistic Updates Are UX Magic: The difference between 100ms and 500ms response times isn't just numbers—it's the difference between an app that feels responsive and one that feels broken.
  • State Management Complexity Is Unavoidable: You can't avoid state management complexity in e-commerce—you can only choose where to put it. We chose to centralize it early and never regretted that decision.
  • Animation Budgets Are Real: Beautiful animations that destroy performance are worse than no animations. Every animation should justify its performance cost.
  • Payment Integration Is Never "Just Stripe": Payment flows touch every part of your application. Plan for complexity from day one, or plan to rewrite everything later.

Building Lilac taught us that modern e-commerce frontends aren't just about React and TypeScript—they're about orchestrating complex systems that happen to render in browsers. The frameworks and libraries are tools, but the real work is in the architecture, the performance budgets, and the thousand tiny decisions that make the difference between software that works and software that scales.