Nuxt + Hydra
Build a storefront using Nuxt 3 and the Hydra SDK. Nuxt provides server-side rendering with useAsyncData, composables for shared state, and file-based routing out of the box.
Prerequisites
- Node.js 18+
- A Hydra project with API keys (get them from your admin panel)
1. Create your project
npx nuxi@latest init my-store
cd my-store
npm install @gethydra/sdk
2. Environment variables
Create a .env file in your project root:
# Server-side only — used in server routes and useAsyncData on the server
HYDRA_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Client-side — prefixed with NUXT_PUBLIC_ so Nuxt exposes it via runtimeConfig
NUXT_PUBLIC_HYDRA_PUBLISHABLE_KEY=pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Register the public key in your nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
hydraSecretKey: '', // set via HYDRA_SECRET_KEY env var
public: {
hydraPublishableKey: '', // set via NUXT_PUBLIC_HYDRA_PUBLISHABLE_KEY env var
},
},
});
Secret keys (sk_live_*) are only available on the server via useRuntimeConfig().hydraSecretKey. Publishable keys (pk_live_*) are safe for client-side code — they only allow read access and cart operations.
3. SDK setup
Create a server-side composable for the SDK.
// server/utils/hydra.ts
import { Hydra } from '@gethydra/sdk';
let instance: Hydra | null = null;
export function useHydraServer() {
if (!instance) {
const config = useRuntimeConfig();
instance = new Hydra({
secret_key: config.hydraSecretKey,
base_url: 'https://api.hydrajs.dev',
});
}
return instance;
}
Create a client-side composable for cart operations.
// composables/useHydraClient.ts
import { Hydra } from '@gethydra/sdk';
let instance: Hydra | null = null;
export function useHydraClient() {
if (!instance) {
const config = useRuntimeConfig();
instance = new Hydra({
publishable_key: config.public.hydraPublishableKey,
base_url: 'https://api.hydrajs.dev',
});
}
return instance;
}
4. Product listing page
Use a server API route to fetch products, then call it with useAsyncData in your page.
// server/api/products.get.ts
export default defineEventHandler(async (event) => {
const hydra = useHydraServer();
const query = getQuery(event);
const limit = Number(query.limit) || 20;
const { data: products, pagination } = await hydra.products.list({
limit,
expand: 'variants,images',
});
return { products, pagination };
});
<!-- pages/products/index.vue -->
<script setup lang="ts">
const { data } = await useAsyncData('products', () =>
$fetch('/api/products', { query: { limit: 20 } })
);
</script>
<template>
<main>
<h1>Products</h1>
<div class="product-grid">
<NuxtLink
v-for="product in data?.products"
:key="product.id"
:to="`/products/${product.handle}`"
class="product-card"
>
<img
v-if="product.images[0]"
:src="product.images[0].src"
:alt="product.images[0].alt ?? product.title"
:width="product.images[0].width"
:height="product.images[0].height"
loading="lazy"
/>
<h2>{{ product.title }}</h2>
<p>${{ (product.variants[0].price / 100).toFixed(2) }}</p>
</NuxtLink>
</div>
</main>
</template>
<style scoped>
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.product-card {
text-decoration: none;
color: inherit;
}
.product-card img {
width: 100%;
height: auto;
border-radius: 8px;
}
</style>
5. Product detail page
// server/api/products/[handle].get.ts
export default defineEventHandler(async (event) => {
const handle = getRouterParam(event, 'handle');
if (!handle) {
throw createError({ statusCode: 400, statusMessage: 'Handle is required' });
}
const hydra = useHydraServer();
try {
const { data: product } = await hydra.products.getByHandle(handle, {
expand: 'variants,images',
});
return { product };
} catch {
throw createError({ statusCode: 404, statusMessage: 'Product not found' });
}
});
<!-- pages/products/[handle].vue -->
<script setup lang="ts">
const route = useRoute();
const { data } = await useAsyncData(
`product-${route.params.handle}`,
() => $fetch(`/api/products/${route.params.handle}`)
);
if (!data.value) {
throw createError({ statusCode: 404, statusMessage: 'Product not found' });
}
const adding = ref(false);
function getCartId(): string | null {
if (import.meta.server) return null;
return localStorage.getItem('hydra_cart_id');
}
function setCartId(id: string) {
localStorage.setItem('hydra_cart_id', id);
}
async function addToCart(variantId: string) {
adding.value = true;
const hydra = useHydraClient();
try {
let cartId = getCartId();
if (!cartId) {
const { data: cart } = await hydra.cart.create({
items: [{ variant_id: variantId, quantity: 1 }],
});
setCartId(cart.id);
} else {
await hydra.cart.addItem(cartId, {
variant_id: variantId,
quantity: 1,
});
}
} catch (error) {
console.error('Failed to add to cart:', error);
} finally {
adding.value = false;
}
}
</script>
<template>
<main v-if="data?.product">
<h1>{{ data.product.title }}</h1>
<img
v-if="data.product.images[0]"
:src="data.product.images[0].src"
:alt="data.product.images[0].alt ?? data.product.title"
:width="data.product.images[0].width"
:height="data.product.images[0].height"
/>
<div v-if="data.product.body_html" v-html="data.product.body_html" />
<div class="variants">
<div
v-for="variant in data.product.variants"
:key="variant.id"
class="variant-row"
>
<span>{{ variant.title }}</span>
<span>${{ (variant.price / 100).toFixed(2) }}</span>
<button @click="addToCart(variant.id)" :disabled="adding">
{{ adding ? 'Adding...' : 'Add to cart' }}
</button>
</div>
</div>
</main>
</template>
<style scoped>
.variants {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.5rem;
}
.variant-row {
display: flex;
align-items: center;
gap: 1rem;
}
</style>
6. Cart composable
Create a reusable composable for cart state that can be shared across pages.
// composables/useCart.ts
interface CartItem {
id: string;
variant_id: string;
title: string;
quantity: number;
price: number;
}
interface Cart {
id: string;
items: CartItem[];
total: number;
}
const cart = ref<Cart | null>(null);
const loading = ref(false);
export function useCart() {
const hydra = useHydraClient();
async function loadCart() {
if (import.meta.server) return;
const cartId = localStorage.getItem('hydra_cart_id');
if (!cartId) return;
loading.value = true;
try {
const response = await hydra.cart.get(cartId);
cart.value = response.data;
} catch {
localStorage.removeItem('hydra_cart_id');
cart.value = null;
} finally {
loading.value = false;
}
}
async function removeItem(itemId: string) {
if (!cart.value) return;
await hydra.cart.removeItem(cart.value.id, itemId);
const response = await hydra.cart.get(cart.value.id);
cart.value = response.data;
}
async function checkout() {
if (!cart.value) return;
const { data: session } = await hydra.checkout.create({
cart_id: cart.value.id,
});
navigateTo(session.checkout_url, { external: true });
}
const itemCount = computed(() =>
cart.value?.items.reduce((sum, item) => sum + item.quantity, 0) ?? 0
);
const formattedTotal = computed(() =>
cart.value ? `$${(cart.value.total / 100).toFixed(2)}` : '$0.00'
);
return {
cart: readonly(cart),
loading: readonly(loading),
itemCount,
formattedTotal,
loadCart,
removeItem,
checkout,
};
}
7. Cart page
<!-- pages/cart.vue -->
<script setup lang="ts">
const { cart, loading, formattedTotal, loadCart, removeItem, checkout } = useCart();
const checkingOut = ref(false);
async function handleCheckout() {
checkingOut.value = true;
await checkout();
}
onMounted(() => {
loadCart();
});
</script>
<template>
<main>
<h1>Cart</h1>
<p v-if="loading">Loading cart...</p>
<template v-else-if="!cart || cart.items.length === 0">
<p>Your cart is empty.</p>
<NuxtLink to="/products">Browse products</NuxtLink>
</template>
<template v-else>
<ul>
<li v-for="item in cart.items" :key="item.id">
{{ item.title }} x {{ item.quantity }} —
${{ (item.price / 100).toFixed(2) }}
<button @click="removeItem(item.id)">Remove</button>
</li>
</ul>
<p><strong>Total: {{ formattedTotal }}</strong></p>
<button @click="handleCheckout" :disabled="checkingOut">
{{ checkingOut ? 'Redirecting...' : 'Checkout' }}
</button>
</template>
</main>
</template>
8. Deploy to Vercel
npx nuxi build
npx vercel
Set your environment variables in the Vercel dashboard under Settings > Environment Variables:
HYDRA_SECRET_KEY— your secret keyNUXT_PUBLIC_HYDRA_PUBLISHABLE_KEY— your publishable key
Vercel automatically detects Nuxt 3 projects and configures the Nitro server preset. No additional configuration is required.
Next steps
- SDK reference — full list of available methods
- Authentication — API key types and usage
- Pagination — cursor-based pagination for large catalogs