Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 086 / 100

Building an e-commerce store with SvelteKit: a deep dive

A real project end to end.

Expona Admin11 min read

When scaling a SvelteKit project from prototype to production e-commerce, the difference between throwing routes together and architecting a system that handles catalog, cart, checkout, and SEO becomes crystal clear. Building an e-commerce store with SvelteKit: a deep dive means embracing its file-based routing, server-side rendering, and form actions to ship a store that's fast, maintainable, and discoverable by search engines.

Catalog — Prerendered product pages

Product pages are the engine of any e-commerce store. With potentially hundreds or thousands of SKUs, prerendering them at build time eliminates runtime overhead and gives you static HTML that CDNs love. SvelteKit's prerender configuration combined with +page.server.ts makes this practical.

Start by defining your product structure. A typical product needs an ID, name, description, price, images, inventory count, and category. We recommend storing this in a database (PostgreSQL works well here) or a headless CMS, then exporting a list of all product IDs at build time.

typescript
// src/routes/products/[id]/+page.server.ts
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';

export const load: PageServerLoad = async ({ params, fetch }) => {
  const response = await fetch(`/api/products/${params.id}`);
  
  if (!response.ok) {
    throw error(404, 'Product not found');
  }
  
  const product = await response.json();
  return { product };
};

// src/routes/products/[id]/+page.svelte
<script lang="ts">
  import type { PageData } from './$types';
  
  export let data: PageData;
  
  let quantity = 1;
</script>

<svelte:head>
  <title>{data.product.name} | Store</title>
  <meta name="description" content={data.product.description} />
</svelte:head>

<div class="product">
  <img src={data.product.image} alt={data.product.name} />
  <h1>{data.product.name}</h1>
  <p>${data.product.price.toFixed(2)}</p>
  <input type="number" bind:value={quantity} min="1" max={data.product.stock} />
  <button on:click={() => addToCart(data.product.id, quantity)}>Add to Cart</button>
</div>

To enable prerendering, configure svelte.config.js:

typescript
export default {
  kit: {
    prerender: {
      entries: async () => {
        const response = await fetch('http://localhost:5173/api/products');
        const products = await response.json();
        return products.map(p => `/products/${p.id}`);
      }
    }
  }
};

This approach fetches all product IDs during the build step, generates static HTML for each, and deploys them as flat files. Updates to inventory or prices require a rebuild, so consider using prerender: false for high-velocity stores and relying on server-side rendering with aggressive caching headers instead.

Cart — Reactive state persisted in cookies

The cart is the lifeblood of conversion. It must persist across sessions, update instantly in the UI, and survive browser refreshes. Rather than reaching for a complex state manager, SvelteKit's Svelte stores combined with cookies provide a clean, performant solution.

Store cart data in a cookie (encrypted for security) and keep an in-memory Svelte store in sync. This gives you instant UI reactivity while ensuring data persists server-side.

typescript
// src/lib/store/cart.ts
import { writable } from 'svelte/store';

export interface CartItem {
  productId: string;
  quantity: number;
  price: number;
}

export const cart = writable<CartItem[]>([]);

export function addToCart(productId: string, quantity: number, price: number) {
  cart.update(items => {
    const existing = items.find(i => i.productId === productId);
    if (existing) {
      existing.quantity += quantity;
    } else {
      items.push({ productId, quantity, price });
    }
    return items;
  });
}

export function removeFromCart(productId: string) {
  cart.update(items => items.filter(i => i.productId !== productId));
}

export function clearCart() {
  cart.set([]);
}

On the server side, use a +layout.server.ts to load the cart from cookies on each request:

typescript
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { json } from '@sveltejs/kit';

export const load: LayoutServerLoad = async ({ cookies }) => {
  const cartCookie = cookies.get('cart') || '[]';
  
  try {
    const cart = JSON.parse(cartCookie);
    return { cart };
  } catch {
    return { cart: [] };
  }
};

In the client layout, hydrate the store from the server data and persist changes back to cookies:

typescript
// src/routes/+layout.svelte
<script lang="ts">
  import { cart } from '$lib/store/cart';
  import type { LayoutData } from './$types';
  
  export let data: LayoutData;
  
  // Hydrate store from server on mount
  $: cart.set(data.cart);
  
  // Subscribe to updates and sync to cookies
  $: if (browser) {
    document.cookie = `cart=${JSON.stringify($cart)}; path=/; max-age=${60 * 60 * 24 * 30}`;
  }
</script>

<slot />

This approach keeps the cart reactive in the UI (users see quantity changes instantly) while maintaining server state through cookies. If you need additional security, sign cookies using crypto.subtle or a library like js-sha256.

Checkout — Form actions and Stripe

Checkout is where friction kills sales. SvelteKit's form actions make it straightforward: collect order details, validate server-side, charge the customer, and confirm. We lean on Stripe for payment processing because it handles tokenization, PCI compliance, and dispute management.

Define a form action in a +page.server.ts file:

typescript
// src/routes/checkout/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import Stripe from 'stripe';
import { env } from '$env/dynamic/private';

const stripe = new Stripe(env.STRIPE_SECRET_KEY);

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    throw redirect(302, '/login');
  }
  return {};
};

export const actions: Actions = {
  default: async ({ request, locals, cookies }) => {
    if (!locals.user) {
      return fail(401, { message: 'Not authenticated' });
    }
    
    const data = await request.formData();
    const email = data.get('email') as string;
    const paymentMethodId = data.get('paymentMethodId') as string;
    const cartCookie = cookies.get('cart') || '[]';
    
    const cart = JSON.parse(cartCookie);
    const amount = cart.reduce((sum: number, item: any) => sum + item.price * item.quantity, 0);
    
    if (amount <= 0) {
      return fail(400, { message: 'Cart is empty' });
    }
    
    try {
      const paymentIntent = await stripe.paymentIntents.create({
        amount: Math.round(amount * 100), // cents
        currency: 'usd',
        payment_method: paymentMethodId,
        confirm: true,
        metadata: { userId: locals.user.id }
      });
      
      if (paymentIntent.status === 'succeeded') {
        // Save order to database
        await db.orders.create({
          userId: locals.user.id,
          email,
          items: cart,
          total: amount,
          stripeId: paymentIntent.id,
          createdAt: new Date()
        });
        
        cookies.delete('cart', { path: '/' });
        throw redirect(303, `/order-confirmation/${paymentIntent.id}`);
      } else {
        return fail(402, { message: 'Payment failed' });
      }
    } catch (err) {
      return fail(500, { message: 'Checkout error' });
    }
  }
};

The corresponding page collects payment details using Stripe's Elements library:

typescript
// src/routes/checkout/+page.svelte
<script lang="ts">
  import { loadStripe } from '@stripe/stripe-js';
  
  let email = '';
  let loading = false;
  let paymentMethodId = '';
  let stripe: any;
  let elements: any;
  
  onMount(async () => {
    stripe = await loadStripe(PUBLIC_STRIPE_KEY);
    elements = stripe.elements();
    const card = elements.create('card');
    card.mount('#card-element');
  });
  
  async function handleSubmit() {
    loading = true;
    const { paymentMethod, error } = await stripe.createPaymentMethod({
      type: 'card',
      card: elements.getElement('card'),
      billing_details: { email }
    });
    
    if (error) {
      loading = false;
      return;
    }
    
    paymentMethodId = paymentMethod.id;
    // Form will now submit with paymentMethodId
  }
</script>

<form method="POST" on:submit={handleSubmit}>
  <input type="email" bind:value={email} required />
  <div id="card-element"></div>
  <input type="hidden" name="paymentMethodId" bind:value={paymentMethodId} />
  <button type="submit" disabled={loading}>{loading ? 'Processing...' : 'Pay Now'}</button>
</form>

This pattern keeps payment logic on the server, never exposing your Stripe secret key. The form action validates input, communicates with Stripe, records the order, and redirects to a success page.

SEO — Product structured data and sitemaps

E-commerce stores live or die by search visibility. Google's rich snippets for products (price, availability, ratings) require structured data in JSON-LD format. SvelteKit's svelte:head makes injecting this simple.

Add JSON-LD structured data to each product page:

typescript
// src/routes/products/[id]/+page.svelte
<script lang="ts">
  import type { PageData } from './$types';
  
  export let data: PageData;
  
  $: productSchema = {
    '@context': 'https://schema.org/',
    '@type': 'Product',
    name: data.product.name,
    description: data.product.description,
    image: data.product.image,
    brand: { '@type': 'Brand', name: data.product.brand },
    offers: {
      '@type': 'Offer',
      url: `https://store.com/products/${data.product.id}`,
      priceCurrency: 'USD',
      price: data.product.price.toString(),
      availability: data.product.stock > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
      itemCondition: 'https://schema.org/NewCondition'
    },
    aggregateRating: data.product.reviews ? {
      '@type': 'AggregateRating',
      ratingValue: data.product.averageRating,
      reviewCount: data.product.reviews.length
    } : undefined
  };
</script>

<svelte:head>
  <script type="application/ld+json">
    {JSON.stringify(productSchema)}
  </script>
</svelte:head>

Generate a dynamic sitemap that lists all product pages:

typescript
// src/routes/sitemap.xml/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ fetch }) => {
  const response = await fetch('/api/products');
  const products = await response.json();
  
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${products.map(p => `
  <url>
    <loc>https://store.com/products/${p.id}</loc>
    <lastmod>${new Date(p.updatedAt).toISOString().split('T')[0]}</lastmod>
    <changefreq>daily</changefreq>
    <priority>0.8</priority>
  </url>
`).join('')}
</urlset>`;
  
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' }
  });
};

Add the sitemap to your robots.txt:

typescript
// src/routes/robots.txt/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async () => {
  const text = `User-agent: *
Allow: /
Disallow: /admin
Disallow: /checkout
Sitemap: https://store.com/sitemap.xml`;
  
  return new Response(text, {
    headers: { 'Content-Type': 'text/plain' }
  });
};

Common mistakes

  • Not invalidating product cache on updates: If you prerender pages and a product price changes, the old HTML stays live until the next build. Use a webhook from your CMS or database to trigger rebuilds, or switch to server-side rendering with cache headers for high-velocity stores.
  • Storing sensitive cart data in plain cookies: Customer information and pricing should be signed or encrypted. Always validate cart contents on the server during checkout—never trust client-side totals.
  • Missing error handling in Stripe integration: Network timeouts, declined cards, and Stripe outages happen. Always wrap payment logic in try-catch, provide clear user feedback, and log errors for debugging.
  • Forgetting to optimize product images: High-resolution images kill page load times. Use next-gen formats (WebP), lazy loading, and responsive <picture> elements. SvelteKit's vite-imagetools plugin helps here.
  • Skipping server-side form validation: JavaScript can be disabled or manipulated. Always validate email format, quantity bounds, and payment method server-side.

Key takeaways

  • Prerender product pages at build time for static HTML that CDNs serve instantly, with fallback to server-side rendering for high-velocity catalogs.
  • Use Svelte stores synchronized with signed cookies to keep cart state reactive in the UI while persisting across sessions.
  • Move all payment logic to SvelteKit form actions, never exposing secret keys to the client, and validate cart contents server-side before charging.
  • Inject JSON-LD structured data and dynamic sitemaps to help search engines discover and understand your product catalog.
  • Always encrypt sensitive data in cookies, handle payment errors gracefully, and validate every critical user input server-side.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles