Headless Commerce Integration with Vue/Nuxt.js Storefront
Nuxt.js is a mature framework for headless e-commerce on Vue.js. Nuxt 3 with Nitro server supports SSR, SSG, and hybrid rendering at the route level. For teams with Vue expertise, this is a direct Next.js alternative with comparable features.
Stack
{
"dependencies": {
"nuxt": "^3.10",
"vue": "^3.4",
"@pinia/nuxt": "^0.5",
"@nuxtjs/i18n": "^8.0",
"@nuxtjs/tailwindcss": "^6.0",
"graphql-request": "^6.0",
"@vueuse/nuxt": "^10.0"
}
}
Nuxt configuration with hybrid rendering
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt',
'@nuxtjs/i18n',
'@nuxtjs/tailwindcss',
'@vueuse/nuxt',
],
// Hybrid rendering: catalog - static, cart - CSR
routeRules: {
'/products/**': { isr: 3600 }, // ISR every hour
'/categories/**': { prerender: true }, // Full static
'/checkout/**': { ssr: false }, // Client only
'/account/**': { ssr: false },
'/api/**': { cors: true },
},
runtimeConfig: {
commerceApiSecret: process.env.COMMERCE_API_SECRET,
public: {
commerceApiUrl: process.env.NUXT_PUBLIC_COMMERCE_API_URL,
typesenseApiKey: process.env.NUXT_PUBLIC_TYPESENSE_KEY,
},
},
});
Commerce Composable
In Nuxt 3, API logic is encapsulated in composables:
// composables/useCommerce.ts
import { GraphQLClient } from 'graphql-request';
import type { Product, Category, Cart } from '~/types/commerce';
export const useCommerce = () => {
const config = useRuntimeConfig();
const client = new GraphQLClient(config.public.commerceApiUrl, {
headers: { 'Accept': 'application/json' },
});
const getProduct = async (slug: string): Promise<Product> => {
const { product } = await client.request(GET_PRODUCT_QUERY, { slug });
return normalizeProduct(product);
};
const getProducts = async (params: ProductsParams) => {
const data = await client.request(GET_PRODUCTS_QUERY, params);
return {
items: data.products.data.map(normalizeProduct),
pagination: data.products.paginatorInfo,
};
};
const getCategories = async (): Promise<Category[]> => {
const { categories } = await client.request(GET_CATEGORIES_QUERY);
return categories;
};
return { getProduct, getProducts, getCategories };
};
Product page
<!-- pages/products/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { getProduct } = useCommerce();
const { data: product, error } = await useAsyncData(
`product-${route.params.slug}`,
() => getProduct(route.params.slug as string),
{ server: true }
);
if (error.value) throw createError({ statusCode: 404, message: 'Product not found' });
// SEO
useSeoMeta({
title: () => product.value?.name,
description: () => product.value?.description?.slice(0, 160),
ogImage: () => product.value?.images[0]?.url,
});
useSchemaOrg([
defineProduct({
name: () => product.value?.name ?? '',
sku: () => product.value?.sku ?? '',
offers: defineOffer({
price: () => product.value?.price ?? 0,
priceCurrency: 'UAH',
}),
}),
]);
</script>
<template>
<div v-if="product" class="grid grid-cols-1 lg:grid-cols-2 gap-12">
<ProductGallery :images="product.images" />
<div>
<h1 class="text-3xl font-bold">{{ product.name }}</h1>
<ProductPrice :price="product.price" :compare-at="product.compareAtPrice" />
<VariantSelector :variants="product.variants" />
<AddToCartButton :product-id="product.id" />
</div>
</div>
</template>
Pinia Store for cart
// stores/cart.ts
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', () => {
const cartToken = useCookie<string>('cart_token', {
maxAge: 60 * 60 * 24 * 30, // 30 days
sameSite: 'strict',
});
const items = ref<CartItem[]>([]);
const total = ref(0);
const loading = ref(false);
const commerce = useCommerce();
const addItem = async (productId: string, variantId?: string, qty = 1) => {
loading.value = true;
try {
if (!cartToken.value) {
const cart = await commerce.createCart();
cartToken.value = cart.token;
}
const updatedCart = await commerce.addToCart(cartToken.value, {
productId,
variantId,
quantity: qty,
});
items.value = updatedCart.items;
total.value = updatedCart.total;
} finally {
loading.value = false;
}
};
const removeItem = async (lineId: string) => {
if (!cartToken.value) return;
const updatedCart = await commerce.removeFromCart(cartToken.value, lineId);
items.value = updatedCart.items;
total.value = updatedCart.total;
};
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
);
return { cartToken, items, total, loading, itemCount, addItem, removeItem };
});
Catalog with filters
<!-- pages/categories/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { getProducts } = useCommerce();
const page = ref(1);
const selectedFilters = ref<Record<string, string[]>>({});
const { data, pending, refresh } = await useAsyncData(
`category-${route.params.slug}-${page.value}`,
() => getProducts({
category: route.params.slug as string,
page: page.value,
filters: selectedFilters.value,
perPage: 24,
}),
{ watch: [page, selectedFilters] }
);
const updateFilter = (code: string, value: string) => {
const current = selectedFilters.value[code] ?? [];
selectedFilters.value = {
...selectedFilters.value,
[code]: current.includes(value)
? current.filter(v => v !== value)
: [...current, value],
};
page.value = 1;
};
</script>
<template>
<div class="flex gap-8">
<FilterSidebar
:filters="data?.filters"
:selected="selectedFilters"
@update="updateFilter"
/>
<div>
<ProductGrid :products="data?.items" :loading="pending" />
<Pagination v-model="page" :total-pages="data?.pagination.lastPage" />
</div>
</div>
</template>
Internationalization
// i18n.config.ts
export default defineI18nConfig(() => ({
legacy: false,
locale: 'uk',
fallbackLocale: 'en',
}));
// nuxt.config.ts (i18n section)
i18n: {
locales: [
{ code: 'uk', iso: 'uk-UA', file: 'uk.json' },
{ code: 'en', iso: 'en-US', file: 'en.json' },
],
defaultLocale: 'uk',
strategy: 'prefix_except_default',
}
Development timeline
| Module | Timeline |
|---|---|
| Project setup, routing, i18n | 3-5 days |
| Catalog + product page | 2-3 weeks |
| Cart + Checkout | 1-2 weeks |
| Account | 1 week |
| Search (Typesense/Algolia) | 3-5 days |
| Total | 5-8 weeks |







