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.
// 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:
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.
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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'svite-imagetoolsplugin 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.






Comments
Loading comments…