Build Shamazon: Your First Svelte & SvelteKit Project
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 forsrc/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
joingroups 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 statebind:valuetwo-way binds input to stategoto()programmatically navigatesonsubmit(noton: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.tsruns before the page rendersfetchis enhanced by SvelteKit (handles SSR)url.searchParamsreads query params like?page=2- Returned data becomes
dataprop 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:
PageDatais auto-generated from+page.tsreturn type{#each ... as ... (key)}- keyed iteration{product}shorthand forproduct={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.idcomes from[id]folder nameerror()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 indexonclick={() => (currentImage = i)}updates state- Image changes automatically when
currentImagechanges - 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:
$pagestore contains error info$page.statusis the HTTP status code- DaisyUI’s
herocenters content beautifully
Recap: What You’ve Learned
Svelte 5 Runes
| Rune | Purpose | Example |
|---|---|---|
$props() | Receive props from parent | let { data } = $props() |
$state() | Create reactive local state | let count = $state(0) |
$derived() | Computed values (not covered) | let doubled = $derived(x*2) |
SvelteKit Patterns
| File | Purpose | Runs Where |
|---|---|---|
+page.svelte | Page component | Browser |
+page.ts | Load data | Server + SSR |
+layout.svelte | Wrapper component | Browser |
+error.svelte | Error handling | Browser |
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
Happy coding! Questions? Join our community.
Continue learning with these related articles
Sign up to receive updates when we publish new blog posts and courses