Інтеграція Headless Commerce з Vue/Nuxt.js Storefront
Nuxt.js — зрілий фреймворк для headless e-commerce на Vue.js. Nuxt 3 з Nitro-сервером підтримує SSR, SSG та гібридний рендеринг на рівні маршрутів. Для команд з Vue-експертизою це пряма альтернатива Next.js з сопівставними можливостями.
Стек
{
"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 з гібридним рендерингом
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt',
'@nuxtjs/i18n',
'@nuxtjs/tailwindcss',
'@vueuse/nuxt',
],
// Гібридний рендеринг: каталог — статика, кошик — CSR
routeRules: {
'/products/**': { isr: 3600 }, // ISR щогодини
'/categories/**': { prerender: true }, // Повна статика
'/checkout/**': { ssr: false }, // Тільки клієнт
'/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
У Nuxt 3 логіка роботи з API інкапсульована в 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 };
};
Сторінка товара
<!-- 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: 'Товар не знайдено' });
// 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 для кошика
// stores/cart.ts
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', () => {
const cartToken = useCookie<string>('cart_token', {
maxAge: 60 * 60 * 24 * 30, // 30 днів
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 };
});
Каталог з фільтрами
<!-- 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>
Інтернаціоналізація
// i18n.config.ts
export default defineI18nConfig(() => ({
legacy: false,
locale: 'uk',
fallbackLocale: 'en',
}));
// nuxt.config.ts (i18n секція)
i18n: {
locales: [
{ code: 'uk', iso: 'uk-UA', file: 'uk.json' },
{ code: 'en', iso: 'en-US', file: 'en.json' },
],
defaultLocale: 'uk',
strategy: 'prefix_except_default',
}
Терміни розробки
| Модуль | Термін |
|---|---|
| Налаштування проекту, роутинг, i18n | 3-5 днів |
| Каталог + сторінка товара | 2-3 тижні |
| Кошик + Checkout | 1-2 тижні |
| Особистий кабінет | 1 тиждень |
| Пошук (Typesense/Algolia) | 3-5 днів |
| Разом | 5-8 тижнів |







