The power of headless: Ecommerce success with Nuxt.js, Netlify, and Shopify

Decoupling the front end allows us to achieve perfect lighthouse scores
Decoupling the front end allows us to achieve perfect lighthouse scores

While Shopify’s liquid templates have powered countless sites, switching to a headless setup breaks free from the limitations of traditional templating, enabling a more flexible and engaging user experience. The performance benefits of a headless setup allow for optimized content delivery, leveraging features like caching, server-side rendering, and preloading to ensure pages load almost instantly.

In a headless architecture, the separation of the frontend and backend allows the storefront to deliver content almost instantly, leveraging advanced caching and rendering techniques that minimize server requests. Users experience lightning-fast page loads, even under high traffic, keeping their journey smooth and engaging. In fact, countless studies have found that performance improvements directly lead to improvements in conversion rate. These optimizations not only reduce friction in the shopping experience but also contribute to improved SEO rankings, further increasing visibility and driving organic traffic.

This article will not attempt to cover all of the features Nuxt has to offer, but rather some extremely useful tools we have at our disposal when we decide to build and e-commerce site with Nuxt.

Caching Layer

When we use a Shopify theme, we have no control over the caching layer. This is extremely powerful being able to use the http cache. We can manipulate this low level api with Cache-Control HTTP headers and completely avoid a round trip to the origin server. This is something we can easily implement with Nuxt Route Rules.

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/*': {
      headers: {
        // tell the browser to always check the freshness of the cache and treat the response as fresh for 1 hour
        'cache-control': 'public, max-age=3600, must-revalidate'
      }
    }
  }
})

If we use Netlify, it gets even better. Not only do we have control over the browser cache but we can cache a reponse on an edge node. This means the response will not need to go all the way back to the origin server.

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/*': {
      headers: {
        // tell the browser to always check the freshness of the cache and treat the response as fresh for 1 hour
        'cache-control': 'public, max-age=3600, must-revalidate',
        //  Tell the CDN to treat it as fresh for 5 minutes, but for a week after that return a stale version while it revalidates
        'netlify-cdn-cache-control': 'public, durable, s-maxage=300, stale-while-revalidate=604800'
      }
    }
  }
})
Setting specific Cache-Control headers for Netlify edge node

Having access to the cache layer means we can provide a smooth, fast, and personalized shopping experience for each user, no matter where they are in the world or how much traffic the site is experiencing.

File Structure

Shopify enforces a strict folder structure and you are not allowed to create additional folders. In each folder you can create a new file from a restricted drop down menu, and in the assets directory you can upload .css, .js, .svg ect. The problem with this is in your assets folder you have no organization.

Image we have a utility function which returns the sum of two numbers:

sum.js
export const sum = (...nums) => {
  return nums.reduce((prev, current) => {
    return prev + current
  }, 0)
}

// or better on one line
export const sum = (...nums) => nums.reduce((x, y) => x + y)

Typically in Shopify, a utility function like this would just be added to the main.js file, but in Nuxt we can neatly organize our code and simply place this function in the utils directory. If we were to have a lot of icons we could organize them, and we can easily control our page structure and urls.

  • utils
    • sum.js
  • components
    • Icon
      • IconVue.vue
      • IconNuxt.vue
  • pages
    • blog
      • [category]
        • [...slug].vue
  • public
  • app.vue
  • Progressive Web App

    Progressive Web Apps or PWA are a relatively new technology that allows a site to function more like a native app. Rather than a user having to go to an app store and download an app which creates friction, they can save the site to their homescreen. A Progressive Web App is a website, not a native app, that has additional capabilities that a normal website does not have. It opens in its own window just like a native app, and can even work offline thanks to service workers.

    Google's web.dev blog has an excellent article on How Progressive Web Apps can drive business success

    Another important point to note is that progressive web apps do not disrupt the conversion funnel, for example, if a user has an item in their cart, they can save the site to their homescreen and continue shopping and when they open the app they saved to the homescreen, the item is still in their cart.

    Shopify's Storefront API is GraphQl based and every query is a POST request. Workbox lets you intercept POST requests, however, the Cache Storage API doesn't support using POST requests as cache keys when storing a response.

    Adding this functionality to a Nuxt e-commerce site can be simplified using the Vite PWA Plugin.

    Data Transformation

    One thing that we can do when we use Shopify Storefront API is transform the data that is returned for use in our themes. This can make development a much more enjoyable and less repetitive experience.

    One example of this is fetching a metaobject with the storefront api. For this example we will have a featured products section with a product slider and a featured image. We can group all of this together in a metafield entry.

    const { data } = await shopifyFetch({ query: getFeaturedProductsMetaobjectQuery, variables })

    With our current implementation we get back this exact response:

    const metaobjectExample = {
      metaobject: {
        fields: [
          {
            key: 'featured_products_description',
            value: 'In perfect synchrony between enthralling elegance and extreme sensuality, every pair of shoes enhances the femininity of the woman who wears them.',
            references: null
          },
          {
            key: 'featured_products_title',
            value: 'Feminine Confidence',
            references: null
          },
          {
            key: 'products',
            value: ['gid://shopify/Product/7763118162086','gid://shopify/Product/8248000610470','gid://shopify/Product/8247962337446','gid://shopify/Product/8247983898790'],
            references: {
              edges: [
                {
                  node: {
                    title: 'Stretch lace and gros grain booties',
                    priceRange: {
                      minVariantPrice: {
                        amount: '650.0',
                        currencyCode: 'EUR'
                      }
                    },
                    featuredImage: {
                      url: 'https://cdn.shopify.com/s/files/1/0960/9642/4402/files/shoes1.1.jpg?v=168978416'
                    }
                  }
                },
                {
                  node: { /* ... */}
                }
              ]
            }
          }
        ]
      }
    }

    This can quickly become quite cumbersome since we might author a component and need to break this data up. What we have is an array of objects so we would need to write each time a bit of code using the find() method to get the specific object we want from the array.

    In our <FeaturedProducts /> component, the data is fetched

    const { data } = await useAsyncData(/* fetch the metaobject */)
    
    data.value.metaobject.fields // array of objects [{...}, {...}, ...]

    So if we wanted to find the products we would have to write

    const productsField = data.value.metaobject.fields.find(
      field => field.key === 'products'
    )

    This would have to be repeated for each key

    It would be much more simple to just write a function that would transform the data and let us work with that. It is also annoying having to deal with edges and nodes.

    To actually get to the products we have to write

    const productsField = data.value.metaobject.fields.find(
      field => field.key === 'products'
    )
    
    // we are back to an array of objects again
    const products = productsField.references.edges

    Taking this into account we can write a function to greatly improve developer experience

    function reshapeMetaobject(metaobject) {
      return metaobject.fields.reduce((acc, { key, value, references }) => {
        acc[key] = references ? references.edges.map(({ node }) => node) : value;
        return acc
      }, {})
    }

    Now we can use our new utility function like this:

    /**
     * shopifyFetch is a utility so we don't have 
     * to repeat fetch(...) with all the options each time
     */
    const { data } = await shopifyFetch({ query, variables })
    
    // data: { metaobject: { ... } }
    
    const transformedData = reshapeMetaobject(data.metaobject)

    transformedData will now have this shape:

    const transformedData = {
      featured_products_description: 'In perfect synchrony between enthralling elegance and extreme sensuality, every pair of shoes enhances the femininity of the woman who wears them.',
      featured_products_title: 'Feminine Confidence',
      products: [
        {
          title: 'Stretch lace and gros grain booties',
          priceRange: {
            minVariantPrice: {
              amount: '650.0',
              currencyCode: 'EUR'
            }
          },
          featuredImage: {
            url: 'https://cdn.shopify.com/s/files/1/0960/9642/4402/files/shoes1.1.jpg?v=168978416'
          }
        },
        {
          /* ... */
        }
      ]
    }

    and all we have to do to access the data is

    const transformedData = {
      /* ... */
    }
    
    transformedData.products
    transformedData.featured_products_description

    One quick tip when you create a metaobject field, you will be asked if you want one product or a list of products, or one file or a list of files.

    If you chose one file, this is a reference. if you choose a list, this will be under the field references.

    This must be taken into account because let's say that we want to have an image with our featured products section, one image would be a reference and the object would now look like this:

    const metaobjectExample = {
      metaobject: {
        fields: [
          {
            key: 'featured_products_image',
            value: 'gid://shopify/MediaImage/9398282882',
            references: null,
            reference: {
              image: {
                url: 'https://cdn.shopify.com/s/files/1/0520/9642/4302/files/a1-01-desk-lp-bags.webp?v=172120169"'
              }
            }
          },
          { /* ... */ }
        ]
      }
    }

    This requires a small modification to our helper function:

    function reshapeMetaobject(metaobject) {
      return metaobject.fields.reduce((acc, { key, value, reference, references }) => {
        acc[key] = references 
          ? references.edges.map(({ node }) => node)
          : reference
            ? reference
            : value
    
        return acc
      }, {})
    }

    Now the data has the shape we want.

    const metaobjectExample = {
      featured_products_collection_link: 'stretch-lace-and-gros-grain-booties',
      featured_products_description: 'In perfect synchrony between enthralling elegance and extreme sensuality, every pair of shoes enhances the femininity of the woman who wears them.',
      featured_products_media: {
        image: {
          url: 'https://cdn.shopify.com/s/files/1/0560/9642/4102/files/a1-01-desk-lp-bags.webp?v=1721201692'
        }
      },
      featured_products_title: 'Feminine Confidence',
      products: [
        {
          title: 'Stretch lace and gros grain booties',
          priceRange: {
            minVariantPrice: {
              amount: '650.0',
              currencyCode: 'EUR'
            }
          },
          featuredImage: {
            url: 'https://cdn.shopify.com/s/files/1/0560/9642/4102/files/shoes1.1.jpg?v=1689678416'
          }
        },
        { /* ... */}
      ]
    }

    Since this metafield in our example has an image, we might have forgotten to add altText. Our utility function can help us with that.

    function reshapeMetaobject(metaobject) {
      return metaobject.fields.reduce((acc, { key, value, reference, references }) => {
        acc[key] = references 
          ? references.edges.map(({ node }) => {
            if (node.featuredImage) {
              node.featuredImage = {
                ...node.featuredImage,
                altText: node.featuredImage.altText || `${node?.title}`
              }
            }
            return node
          })
          : reference
            ? reference
            : value
    
        return acc
      }, {})
    }

    This is just one example. A really good example where this is super useful is with our cart.

    /**
     * cart object
     */
    
    const cart = {
      lines: [
        {
          merchandise: {
            id: 1,
            selectedOptions: [
              {
                name: 'size',
                value: 'xs'
              },
              {
                name: 'color',
                value: 'black'
              }
            ]
          }
        },
        {
          /* ... */
        }
      ]
    }
    
    /**
     * now in our cart we want to show the size or color
     */
    
    function reshapeCart(cart) {
      return cart.lines.map(line => ({
        ...line,
        merchandise: {
          ...line.merchandise,
          ...line.merchandise.selectedOptions.reduce(
            (accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
            {}
          )
        }
      }))
    }
    
    /**
     * now we have color and size available
     */
    
    const cart = {
      lines: [
        {
          merchandise: {
            color: 'black',
            size: 'xs',
            id: 1,
            selectedOptions: [
              {
                name: 'size',
                value: 'xs'
              },
              {
                name: 'color',
                value: 'black'
              }
            ]
          }
        },
        {
          /* ... */
        }
      ]
    }

    These types of data transformations are not possible with liquid. There is a small trick in liquid to use the {{ ... | json }} filter but this is limited since there are many parts of liquid objects that cannot be converted to json.

    Netlify Edge Functions

    One of the coolest parts about going headless is being able to utilize things like edge functions or serverless functions. With Netlify we can even utilize cache tags. First it's important to understand why we would even need something like cache tags. This term cache keeps getting thrown around a lot but it's important to understand when you clear your browser cache, this is in the browser, and if something is cached on an edge node or cdn, clearing your browser cache does not clear the cdn cache.

    So if we have a product page and it doesn't change often we could set a long cache time on the edge node. But we could also invalidate the cache whenever something happens even if the cache control headers indicate the cached object is still fresh. This allows us to refresh the cache without redeploying the entire site.

    export default defineNuxtConfig({
      routeRules: {
        '/products/*': {
          headers: {
            'netlify-cache-tag': 'products',
            // response can be reused while fresh (1 hour)
            'cache-control': 'public, max-age=3600, must-revalidate',
            //  Tell the CDN to treat it as fresh for 5 minutes, but for a week after that return a stale version while it revalidates
            'netlify-cdn-cache-control': 'public, s-maxage=300, stale-while revalidate=604800'
          }
        }
      }
    })

    Now we can create a serverless function /netlify/functions/revalidate.js

  • netlify
    • functions
      • revalidate.js
  • app.vue
  • revalidate.js
    import { purgeCache } from "@netlify/functions"
    
    export default async (req, context) => {
      const url = new URL(req.url)
      const cacheTag = url.searchParams.get('tag')
      const tags = cacheTag ? [cacheTag] : undefined
      await purgeCache({ tags })
      
      return Response.json({ 
        msg: 'purge successful'
      }, { status: 200 })
    }

    Now we can go into our Shopify admin, setup a webhook for Product update and enter an endpoint:

    https://test.netlify.app/.netlify/functions/revalidate?tag=products

    What this will do is if our product is cached in the edge node and is still fresh, the cache will be refreshed.

    Another awesome thing we can do with severless functions is create our own email with Resend.

    export const handler = async (req, context) => {
      const res = await fetch('https://api.resend.com/emails', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${Netlify.env.get('RESEND_API_KEY')}`
        },
        body: JSON.stringify({
          from: 'onboarding@resend.dev',
          to: 'delivered@resend.dev',
          subject: 'Hello World',
          html: '<strong>it works!</strong>',
        })
      });
    
      if (res.ok) {
        const data = await res.json()
    
        return Response.json({
          data
        }, { status: 200 })
      }
    };

    Accessibility

    Working with accessibility can be a pain, but working with a reactive library such as vue makes the developer experience much better. If we have a product with size swatches, using vue's v-bind shorthand : allows us to easily change the aria-label. This is only one of many examples where vue helps us to make accessibility much easier to work with. Doing these things ourself also keeps us from having to import huge libraries that load in the browser slowing our site down just to make it accessible.

    <button class="size_swatch"
      v-for="variant in selectedProduct.variants"
      :aria-label="`
        ${variant.availableForSale
          ? `size ${variant.size} available` 
          : `size ${variant.size} not available`
        }`"
    >
      {{ variant.size }}
    </button>

    Other features

    The topics above hardly scratch the surface of what becomes possible when we decouple the front end from the backend. Some of the other things we can do are:

    • Internationalization with Nuxt I18n
    • Use Resend to handle sending emails
    • Utilize Netlify Edge Functions
    • Gain massive site performance
    • Create reusable components
    • Access to cookies
    • Middleware
    • Clean and modern code
    • Accessbility Improvements
    • Complete control over requests and responses
    • Utilize Nuxt Hybrid Rendering
    • Use Netlify Analytics
    • A/B Testing with Netlify and Vercel

    Netlify Analytics and Vercel Observability both bring data captured on the server. Ads are expensive and it's crucial to be able to see exactly where the users are coming from and what they are doing. Even better, this does not impact site load times since capturing requests happens on the server and not in the browser.

    Conclusion

    Going headless with Shopify's Storefront API and Nuxt 3 opens up incredible possibilities for building a flexible, high-performing e-commerce experience. By decoupling the front end, you are no longer constrained by Shopify's native themes or liquid template syntax, giving you the freedom to create a unique, brand-forward shopping experience.

    Additionally, gaining access to the cache layer with Nuxt 3 can significantly enhance performance by reducing API calls and speeding up load times, delivering a faster, more responsive user experience. This approach also improves SEO and allows for efficient scaling as your store grows. Embracing a headless architecture not only enhances control and adaptability but also future proofs your e-commerce solution in a way that’s built to evolve with emerging technologies and customer expectations.