Deno logoDeno
An ecommerce site with a perfect lighthouse score.

How to Build an E-commerce Site with a Perfect Lighthouse Score


Today’s consumers are more demanding than ever, especially when it comes to shopping online. These experiences must feel intuitive and snappy. Even a 100-millisecond delay in load time can hurt conversion rates by 7%.

Our merch store (source here), built with Fresh and Shopify’s storefront API, is server-side rendered (SSR) with some islands of interactivity and deployed close to users on the edge. Sending only what the client needs keeps the site lean and fast, earning it a perfect Lighthouse score.

A perfect mobile lighthouse score

This is a tutorial on how to build an e-commerce site with a perfect Lighthouse score using Fresh and Shopify.

Fresh

We used Fresh, an edge-first web development framework that sends zero JavaScript to the client by default. In cases where interactivity is needed, such as the image carousel on the product detail page or the shopping cart, Fresh uses islands architecture, which sends client-side JavaScript on a component basis:

Islands on our product details page Everything is server-side rendered as static HTML with the exception of some islands of interactivity.

For the shop backend we used Shopify. It's Storefront API that can retrieve inventory data, and keep track of a users' the shopping cart, and handle checkout and payments.

Fresh uses a filesystem routing system. To better understand how it works, let’s take a look at the directory structure.

merch/
├── README.md
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── main.ts
├── components
├── islands
│   ├── AddToCart.tsx
│   ├── Cart.tsx
│   └── ProductDetails.tsx
├── routes
│   ├── api
│   │   └── shopify.ts
│   ├── products
│   │   └── [product].tsx
│   ├── _app.tsx
│   └── index.tsx
├── static
│   ├── favicon.ico
│   └── logo.svg
└── utils

The two main subdirectories where the logic for server side rendering and islands happen are routes/ and islands/.

Routes

Each .tsx file in the routes/ folder server-side renders a page or exposes an API endpoint.

The index page, or https://merch.deno.com, is index.tsx. When a request is made, the edge server retrieves the data from Shopify in the handler function, then renders that through the Home component.

The products/[product].tsx file dynamically generates a server-side rendered product page. The value of the [product] in the path is accessed in the handler function through the parameter ctx (ctx.params.product). For example, when a request is made to https://merch.deno.com/products/sticker-sheet, the handler function grabs the value sticker-sheet, retrieves the product data from Shopify, then renders it through the ProductPage component.

Finally, api/shopify.ts exposes programmatic access to updating the shopping cart. Every time a user modifies the shopping cart, the request doesn’t go directly from the user’s browser to Shopify. Instead, the request goes through this endpoint, which then handles it and forwards it to Shopify. (More on this in the Shopify section below.)

Islands

An e-commerce site can’t be completely static, since certain components like the shopping cart need to be interactive. We can add those interactive components in islands/.

For example, on each product page, when a user clicks on the arrow, the images rotate:

Image carousel on the product page

This client-side rendered behavior is defined in ProductDetails.tsx, where we declare the changeImage function and bind that to onClick in the ProductDetails component.

Similarly, other interactive elements include adding to cart and updating the cart, whose client-side logic can be found in AddToCart.tsx and Cart.tsx respectively. In each island component, we define functions and bind them to the onClick listener.

Note that to manage state on the client-side, we import and use useState from preact/hooks, which triggers a re-render when the state changes.

Shopify Storefront API

There are a two main ways where this storefront interfaces with Shopify's API.

First is retrieving inventory data from Shopify. This is a simple graphql GET request that is made in the handler function in each of the /routes components files:

// ./routes/index.tsx

const q = `{
  products(first: 10) {
    nodes {
      id
      handle
      title
      featuredImage {
        url(transform: {preferredContentType: WEBP, maxWidth:400, maxHeight:400})
        altText
      }
      priceRange {
        minVariantPrice {
          amount
          currencyCode
        }
        maxVariantPrice {
          amount
          currencyCode
        }
      }
    }
  }
}`;

export const handler: Handlers<Data> = {
  async GET(_req, ctx) {
    const data = await graphql<Data>(q);
    return ctx.render(data); // This function passes `data` to the component.
  },
};

Second is updating Shopify’s shopping cart. There are three parts to this:

  • graphql wrapper function (/utils/shopify.ts), which adds authentication and other relevant headers to the request
  • a bunch of helper functions in /utils/data.ts, which abstracts away the queries into human-readable functions
  • our endpoint, /routes/api/shopify.ts, which receives a query and input from the client (e.g. when a user adds a product to cart) and creates a request to Shopify's API via the graphql wrapper

When a user interacts with the shopping cart, the relevant islands/ component will call a helper function (e.g. addToCart). That function will call our /api/shopify endpoint with the corresponding query and payload, then calls our graphql wrapper function, which adds authentication to the request and ultimately sends it to Shopify’s API.

Image Optimizations

In order to achieve the perfect Lighthouse score, it's important to be intentional about the size of every file coming from the server.

Shopify's Storefront API can apply a transform to our images. In the below snippet of a graphql query, we request the image at 400x400 and content type WEBP.

featuredImage {
  url(transform: {preferredContentType: WEBP, maxWidth:400, maxHeight:400})
  altText
}

Serve your site close to your users

You could have the fastest store, but if your users are thousands of miles away from your server, your site’s time to first byte is still at the mercy of the speed of light.

To further minimize latency, we’ve hosted our merch store globally on the edge with Deno Deploy.

Every time someone visits our store, the closest edge server receives a GET request, asks Shopify for the relevant data, renders it in HTML, then sends it back. With 34 global locations, no user will be further than a couple of millisconds away from your site.

See how fast our store shows a new product image after we update the product in Shopify:

Updating an image on Shopify, then refreshing merch store

Setting up Deno Deploy is as simple as connecting your GitHub, selecting the repo, and the entrypoint file:

Connecting GitHub and selecting the “merch” repo

Every time you merge to your main branch, it is updated on Deno Deploy within seconds.

What’s next?

While building for the web has gotten easier, in some ways its more complex than ever. We must support a wide variety of screen sizes and internet speeds. Though we can't control whether our users will be at their laptop or on a train under a tunnel, we can control what gets sent from the server.

We’ve built and open sourced our merch store to show everyone how simple (and fun!) it can be to create an e-commerce store with a perfect Lighthouse score.

Are you building with Deno or Fresh? Share it with us on Discord or Twitter!