Back to Blog
beginner

Build Shamazon: Your First Svelte & SvelteKit Project

December 1, 2025 10 min read

Welcome to your first Svelte and SvelteKit project! We’re building Shamazon - a product browser that teaches you Svelte 5 and SvelteKit fundamentals.

What You’ll Learn

  • Svelte 5 Runes - $props, $state, $derived
  • SvelteKit Routing - File-based and dynamic routes
  • Data Loading - Load functions and API integration
  • Component Composition - Reusable UI components
  • TypeScript - Type-safe development
  • Tailwind CSS + DaisyUI - Rapid UI development

What We’re Building

Shamazon fetches product data from DummyJSON API and includes:

  • Product listing with pagination
  • Product detail pages with image galleries
  • Category filtering
  • Search functionality
  • Responsive design

Prerequisites

  • Node.js 22+ (check with node -v)
  • Basic JavaScript knowledge
  • VS Code recommended

Part 1: Project Setup

Create the Project

Open your terminal and run:

npx sv create shamazon

When prompted, select these options:

┌  Welcome to the Svelte CLI!
│
◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with TypeScript?
│  Yes, using TypeScript syntax
│
◆  Project created
│
◇  What would you like to add to your project?
│  prettier, eslint, vitest, playwright, tailwindcss
│
◇  tailwindcss: Which plugins would you like to add?
│  typography
│
◇  Which package manager do you want to install dependencies with?
│  pnpm
│
◆  Successfully installed dependencies
│
◇  Successfully setup integrations
│
◇  Project next steps ─────────────────────────────────────────────────────╮
│                                                                          │
│  1: cd shamazon                                                          │
│  2: git init && git add -A && git commit -m "Initial commit" (optional)  │
│  3: pnpm run dev -- --open                                               │
│                                                                          │
│  To close the dev server, hit Ctrl-C                                     │
│                                                                          │
│  Stuck? Visit us at https://svelte.dev/chat                              │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────╯
│
└  You're all set!

Navigate and Install DaisyUI

cd shamazon
pnpm add -D daisyui

Configure DaisyUI

Open src/routes/layout.css (created by sv) and update it:

@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin 'daisyui';

That’s it! Tailwind CSS 4 uses the @plugin directive - no config file needed.

Start the Dev Server

pnpm run dev

Visit http://localhost:5173 to see the default page.


Part 2: Understanding the File Structure

SvelteKit uses file-based routing. Here’s our target structure:

src/
├── routes/
│   ├── +layout.svelte          # App shell (navbar, footer)
│   ├── +page.svelte            # Homepage (/)
│   ├── +page.ts                # Data loader for homepage
│   ├── +error.svelte           # Error page
│   ├── layout.css              # Tailwind + DaisyUI
│   ├── search/
│   │   ├── +page.svelte        # /search
│   │   └── +page.ts
│   └── products/
│       ├── [id]/
│       │   ├── +page.svelte    # /products/123
│       │   └── +page.ts
│       └── category/[slug]/
│           ├── +page.svelte    # /products/category/phones
│           └── +page.ts
├── lib/
│   ├── components/
│   │   ├── ProductCard.svelte
│   │   ├── Pagination.svelte
│   │   └── SearchBar.svelte
│   └── types/
│       └── product.ts
└── app.html

Key concepts:

  • +page.svelte = The page component
  • +page.ts = Data loader (runs before render)
  • +layout.svelte = Wraps child routes
  • [id] = Dynamic route parameter
  • $lib/ = Alias for src/lib/

Part 3: TypeScript Types

Create src/lib/types/product.ts:

export interface Product {
	id: number;
	title: string;
	description: string;
	price: number;
	discountPercentage: number;
	rating: number;
	stock: number;
	brand: string;
	category: string;
	thumbnail: string;
	images: string[];
}

export interface ProductsResponse {
	products: Product[];
	total: number;
	skip: number;
	limit: number;
}

export interface Review {
	rating: number;
	comment: string;
	date: string;
	reviewerName: string;
	reviewerEmail: string;
}

Part 4: Reusable Components

ProductCard Component

Create src/lib/components/ProductCard.svelte:

<script lang="ts">
	import type { Product } from '$lib/types/product';

	let { product }: { product: Product } = $props();
</script>

<a
	href="/products/{product.id}"
	class="card bg-base-100 shadow-md transition-shadow hover:shadow-xl"
>
	<figure class="h-48">
		<img
			src={product.thumbnail}
			alt={product.title}
			class="h-full w-full object-cover"
		/>
	</figure>
	<div class="card-body">
		<h2 class="card-title text-base">{product.title}</h2>
		<p class="line-clamp-2 text-sm text-gray-600">
			{product.description}
		</p>
		<div class="card-actions mt-2 items-center justify-between">
			<div class="flex items-center gap-2">
				<span class="text-lg font-bold">${product.price}</span>
				{#if product.discountPercentage > 0}
					<span class="badge badge-error badge-outline text-xs">
						-{Math.round(product.discountPercentage)}%
					</span>
				{/if}
			</div>
			<span class="badge badge-outline">{product.category}</span>
		</div>
	</div>
</a>

Key Svelte 5 concepts:

  • $props() receives data from parent components
  • {#if} for conditional rendering
  • {product.id} interpolates values in attributes

Pagination Component

Create src/lib/components/Pagination.svelte:

<script lang="ts">
	let {
		currentPage,
		totalPages,
		baseUrl = '',
	}: {
		currentPage: number;
		totalPages: number;
		baseUrl?: string;
	} = $props();
</script>

<div class="join">
	{#if currentPage > 1}
		<a
			href="{baseUrl}?page={currentPage - 1}"
			class="btn join-item btn-sm"
		>
			«
		</a>
	{:else}
		<button class="btn btn-disabled join-item btn-sm">«</button>
	{/if}

	<button class="btn btn-active join-item btn-sm">
		Page {currentPage} of {totalPages}
	</button>

	{#if currentPage < totalPages}
		<a
			href="{baseUrl}?page={currentPage + 1}"
			class="btn join-item btn-sm"
		>
			»
		</a>
	{:else}
		<button class="btn btn-disabled join-item btn-sm">»</button>
	{/if}
</div>

Key concepts:

  • Default prop values with baseUrl = ''
  • {:else} for if/else blocks
  • DaisyUI’s join groups buttons together

SearchBar Component

Create src/lib/components/SearchBar.svelte:

<script lang="ts">
	import { goto } from '$app/navigation';

	let query = $state('');

	function handleSearch(e: Event) {
		e.preventDefault();
		if (query.trim()) {
			goto(`/search?q=${encodeURIComponent(query.trim())}`);
		}
	}
</script>

<form onsubmit={handleSearch} class="join">
	<input
		type="text"
		bind:value={query}
		placeholder="Search products..."
		class="input join-item input-bordered input-sm w-full max-w-xs"
	/>
	<button type="submit" class="btn btn-primary join-item btn-sm">
		Search
	</button>
</form>

Key concepts:

  • $state('') creates reactive local state
  • bind:value two-way binds input to state
  • goto() programmatically navigates
  • onsubmit (not on:submit) - Svelte 5 syntax

Part 5: Layout and Navigation

Replace src/routes/+layout.svelte:

<script lang="ts">
	import './layout.css';
	import SearchBar from '$lib/components/SearchBar.svelte';

	let { children } = $props();
</script>

<div class="bg-base-200 flex min-h-screen flex-col">
	<!-- Navbar -->
	<nav class="navbar bg-base-100 shadow-md">
		<div class="flex-1">
			<a href="/" class="btn btn-ghost text-xl">🛒 Shamazon</a>
		</div>
		<div class="flex-none gap-2">
			<SearchBar />
		</div>
	</nav>

	<!-- Main Content -->
	<main class="container mx-auto flex-1 p-4">
		{@render children()}
	</main>

	<!-- Footer -->
	<footer
		class="footer footer-center bg-base-300 text-base-content p-4"
	>
		<p>Built with SvelteKit + DaisyUI</p>
	</footer>
</div>

Key concepts:

  • {@render children()} renders nested routes (Svelte 5 syntax)
  • Layout wraps all pages automatically
  • DaisyUI classes: navbar, footer, btn-ghost

Part 6: Homepage with Products

Data Loader

Create src/routes/+page.ts:

import type { PageLoad } from './$types';
import type { ProductsResponse } from '$lib/types/product';

export const load: PageLoad = async ({ fetch, url }) => {
	const page = Number(url.searchParams.get('page')) || 1;
	const limit = 12;
	const skip = (page - 1) * limit;

	const res = await fetch(
		`https://dummyjson.com/products?limit=${limit}&skip=${skip}`,
	);
	const data: ProductsResponse = await res.json();

	return {
		products: data.products,
		total: data.total,
		currentPage: page,
		totalPages: Math.ceil(data.total / limit),
	};
};

Key concepts:

  • +page.ts runs before the page renders
  • fetch is enhanced by SvelteKit (handles SSR)
  • url.searchParams reads query params like ?page=2
  • Returned data becomes data prop in page

Page Component

Replace src/routes/+page.svelte:

<script lang="ts">
	import ProductCard from '$lib/components/ProductCard.svelte';
	import Pagination from '$lib/components/Pagination.svelte';
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();
</script>

<div class="space-y-6">
	<h1 class="text-3xl font-bold">All Products</h1>

	<div class="grid grid-cols-1 gap-6 md:grid-cols-3 lg:grid-cols-4">
		{#each data.products as product (product.id)}
			<ProductCard {product} />
		{/each}
	</div>

	<div class="flex justify-center">
		<Pagination
			currentPage={data.currentPage}
			totalPages={data.totalPages}
		/>
	</div>
</div>

Key concepts:

  • PageData is auto-generated from +page.ts return type
  • {#each ... as ... (key)} - keyed iteration
  • {product} shorthand for product={product}
  • Responsive grid with Tailwind breakpoints

Part 7: Product Detail Page

Data Loader

Create src/routes/products/[id]/+page.ts:

import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import type { Product } from '$lib/types/product';

export const load: PageLoad = async ({ params, fetch }) => {
	const res = await fetch(
		`https://dummyjson.com/products/${params.id}`,
	);

	if (!res.ok) {
		error(404, 'Product not found');
	}

	const product: Product = await res.json();
	return { product };
};

Key concepts:

  • params.id comes from [id] folder name
  • error() throws to show error page
  • Dynamic routes match any value in that segment

Page Component

Create src/routes/products/[id]/+page.svelte:

<script lang="ts">
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();
	let currentImage = $state(0);
</script>

<div class="space-y-6">
	<!-- Breadcrumb -->
	<div class="text-sm">
		<a href="/" class="link-primary">Home</a>
		<span class="mx-2">/</span>
		<a
			href="/products/category/{data.product.category}"
			class="link-primary"
		>
			{data.product.category}
		</a>
		<span class="mx-2">/</span>
		<span>{data.product.title}</span>
	</div>

	<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
		<!-- Image Gallery -->
		<div class="space-y-4">
			<div
				class="bg-base-100 aspect-square overflow-hidden rounded-lg"
			>
				<img
					src={data.product.images[currentImage]}
					alt={data.product.title}
					class="h-full w-full object-contain"
				/>
			</div>

			<!-- Thumbnails -->
			<div class="flex gap-2">
				{#each data.product.images as image, i}
					<button
						onclick={() => (currentImage = i)}
						class="h-16 w-16 overflow-hidden rounded border-2
							{currentImage === i ? 'border-primary' : 'border-transparent'}"
					>
						<img
							src={image}
							alt=""
							class="h-full w-full object-cover"
						/>
					</button>
				{/each}
			</div>
		</div>

		<!-- Product Info -->
		<div class="space-y-4">
			<div>
				<p class="text-sm text-gray-600">{data.product.brand}</p>
				<h1 class="text-3xl font-bold">{data.product.title}</h1>
			</div>

			<div class="flex items-center gap-4">
				<span class="text-4xl font-bold text-primary">
					${data.product.price}
				</span>
				{#if data.product.discountPercentage > 0}
					<span class="badge badge-error text-lg">
						-{Math.round(data.product.discountPercentage)}% OFF
					</span>
				{/if}
			</div>

			<!-- Rating -->
			<div class="flex items-center gap-2">
				<div class="rating rating-sm">
					{#each Array(5) as _, i}
						<input
							type="radio"
							class="mask mask-star-2 bg-orange-400"
							checked={i < Math.round(data.product.rating)}
							disabled
						/>
					{/each}
				</div>
				<span class="text-sm text-gray-600">
					({data.product.rating.toFixed(1)})
				</span>
			</div>

			<div class="divider"></div>

			<p class="text-gray-600">{data.product.description}</p>

			<div class="flex gap-4">
				<div class="stat bg-base-100 rounded-lg p-4">
					<div class="stat-title">Stock</div>
					<div class="stat-value text-lg">{data.product.stock}</div>
				</div>
				<div class="stat bg-base-100 rounded-lg p-4">
					<div class="stat-title">Category</div>
					<a
						href="/products/category/{data.product.category}"
						class="stat-value link-primary text-lg"
					>
						{data.product.category}
					</a>
				</div>
			</div>

			<button class="btn btn-primary btn-lg w-full">
				Add to Cart
			</button>
		</div>
	</div>
</div>

Key concepts:

  • $state(0) tracks selected image index
  • onclick={() => (currentImage = i)} updates state
  • Image changes automatically when currentImage changes
  • No manual DOM manipulation needed!

Part 8: Category Pages

Data Loader

Create src/routes/products/category/[slug]/+page.ts:

import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import type { ProductsResponse } from '$lib/types/product';

export const load: PageLoad = async ({ params, fetch }) => {
	const res = await fetch(
		`https://dummyjson.com/products/category/${params.slug}`,
	);

	if (!res.ok) {
		error(404, `Category "${params.slug}" not found`);
	}

	const data: ProductsResponse = await res.json();

	return {
		products: data.products,
		category: params.slug,
		total: data.total,
	};
};

Page Component

Create src/routes/products/category/[slug]/+page.svelte:

<script lang="ts">
	import ProductCard from '$lib/components/ProductCard.svelte';
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();
</script>

<div class="space-y-6">
	<div class="flex items-center gap-4">
		<a href="/" class="btn btn-ghost btn-sm">← Back</a>
		<h1 class="text-3xl font-bold capitalize">{data.category}</h1>
		<span class="badge">{data.total} products</span>
	</div>

	<div class="grid grid-cols-1 gap-6 md:grid-cols-3 lg:grid-cols-4">
		{#each data.products as product (product.id)}
			<ProductCard {product} />
		{/each}
	</div>
</div>

Part 9: Search Page

Data Loader

Create src/routes/search/+page.ts:

import type { PageLoad } from './$types';
import type { ProductsResponse } from '$lib/types/product';

export const load: PageLoad = async ({ url, fetch }) => {
	const query = url.searchParams.get('q');

	if (!query) {
		return { products: [], query: '', total: 0 };
	}

	const res = await fetch(
		`https://dummyjson.com/products/search?q=${encodeURIComponent(query)}`,
	);
	const data: ProductsResponse = await res.json();

	return {
		products: data.products,
		query,
		total: data.total,
	};
};

Page Component

Create src/routes/search/+page.svelte:

<script lang="ts">
	import ProductCard from '$lib/components/ProductCard.svelte';
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();
</script>

<div class="space-y-6">
	<div class="flex items-center gap-4">
		<a href="/" class="btn btn-ghost btn-sm">← Back</a>
		{#if data.query}
			<h1 class="text-3xl font-bold">
				Results for "{data.query}"
			</h1>
			<span class="badge">{data.total} found</span>
		{:else}
			<h1 class="text-3xl font-bold">Search</h1>
		{/if}
	</div>

	{#if data.products.length > 0}
		<div class="grid grid-cols-1 gap-6 md:grid-cols-3 lg:grid-cols-4">
			{#each data.products as product (product.id)}
				<ProductCard {product} />
			{/each}
		</div>
	{:else if data.query}
		<div class="alert">
			<span>No products found for "{data.query}"</span>
		</div>
	{:else}
		<div class="alert">
			<span>Enter a search term to find products</span>
		</div>
	{/if}
</div>

Part 10: Error Page

Create src/routes/+error.svelte:

<script lang="ts">
	import { page } from '$app/stores';
</script>

<div class="hero min-h-[50vh]">
	<div class="hero-content text-center">
		<div class="max-w-md">
			<h1 class="text-9xl font-bold">{$page.status}</h1>
			<p class="py-6 text-xl">{$page.error?.message}</p>
			<a href="/" class="btn btn-primary">Go Home</a>
		</div>
	</div>
</div>

Key concepts:

  • $page store contains error info
  • $page.status is the HTTP status code
  • DaisyUI’s hero centers content beautifully

Recap: What You’ve Learned

Svelte 5 Runes

RunePurposeExample
$props()Receive props from parentlet { data } = $props()
$state()Create reactive local statelet count = $state(0)
$derived()Computed values (not covered)let doubled = $derived(x*2)

SvelteKit Patterns

FilePurposeRuns Where
+page.sveltePage componentBrowser
+page.tsLoad dataServer + SSR
+layout.svelteWrapper componentBrowser
+error.svelteError handlingBrowser

DaisyUI Classes Used

  • Layout: navbar, footer, hero, container
  • Cards: card, card-body, card-title, card-actions
  • Buttons: btn, btn-primary, btn-ghost, btn-disabled
  • Forms: input, input-bordered, join
  • Feedback: badge, alert, rating
  • Typography: divider, stat

Want More?

This tutorial covers the fundamentals. Sign up for free to get:

  • Step-by-step video walkthroughs
  • Downloadable source code
  • More Svelte and SvelteKit tutorials

👉 Start Learning Free →


Happy coding! Questions? Join our community.

Related Posts

Continue learning with these related articles

Get notified about new content

Sign up to receive updates when we publish new blog posts and courses