Hydra is now in beta|Get started free|Follow our journey on X.com

Nuxt
On this page

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 key
  • NUXT_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