SvelteKit components
Aug 25, 2026 SvelteKit
src/routes/products/+page.svelte is a component. Any .svelte file is. Svelte compiles them. SvelteKit maps some of them to URLs.
The routing post already built the hardware shop as pages. The list and the [slug] page both paste the same <article>. This post moves that markup into $lib.
The tutorial used src/lib for TypeScript helpers. A .svelte file imports from there the same way.
This post uses Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib. The SvelteKit 3 migration guide lists those renames.
We will end up with these files:
src/lib/
└── ProductCard.svelte
src/lib/server/
└── products.ts
src/routes/
├── +layout.svelte
├── +page.svelte
└── products/
├── +page.server.ts
├── +page.svelte
└── [slug]/
├── +page.server.ts
└── +page.svelte Table of Contents
Step 1: create the project
Reuse the shop from routing if you have it. Otherwise install Node 18 or newer and run the Svelte CLI:
npx sv create shop
cd shop
npm install
npm run dev Pick TypeScript. Skip the add-ons.
Open http://localhost:5173. Vite is the dev server.
Replace src/routes/+layout.svelte. {@render children()} is the page.
<script lang="ts">
let { children } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
</nav>
{@render children()} Replace src/routes/+page.svelte:
<h1>Northside Hardware</h1>
<p>Hammers, tape, sandpaper.</p> Step 2: a list that copies the card
Create src/lib/server/products.ts. Keep it under $lib/server so a .svelte file cannot import it. That is the full-stack split. The catalog stays off the client bundle.
export type Product = {
slug: string;
name: string;
price: string;
description: string;
};
export const products: Product[] = [
{
slug: 'claw-hammer',
name: 'Claw hammer',
price: '$18',
description: '16 oz steel head. The claw pulls nails.'
},
{
slug: 'masking-tape',
name: 'Masking tape',
price: '$4',
description: '24 mm roll. Holds paper while paint dries.'
},
{
slug: '80-grit-sandpaper',
name: '80 grit sandpaper',
price: '$6',
description: 'Five sheets. For stripping old finish.'
}
];
export function getProduct(slug: string): Product | null {
return products.find((product) => product.slug === slug) ?? null;
} Create src/routes/products/+page.server.ts:
import { products } from '$lib/server/products';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { products };
}; Create src/routes/products/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Products</h1>
<ul>
{#each data.products as product (product.slug)}
<li>
<article>
<h2>
<a href="/products/{product.slug}">{product.name}</a>
</h2>
<p>{product.description}</p>
<p>{product.price}</p>
</article>
</li>
{/each}
</ul> $props() is how Svelte 5 reads props. data is whatever load returned. The routing post already covered {#each} keys. Use product.slug, not the index.
Create src/routes/products/[slug]/+page.server.ts:
import { error } from '@sveltejs/kit';
import { getProduct } from '$lib/server/products';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const product = getProduct(params.slug);
if (!product) {
error(404, 'Product not found');
}
return { product };
}; Create src/routes/products/[slug]/+page.svelte with the same card, minus the link. You are already on that product.
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<article>
<h2>{data.product.name}</h2>
<p>{data.product.description}</p>
<p>{data.product.price}</p>
</article>
<p><a href="/products">All products</a></p> Open /products, then a title. The <article> is copied. Bold the price in one file and the other still looks old.
Step 3: extract ProductCard
Create src/lib/ProductCard.svelte. $props is how the parent passes name, price, and description. href is missing on the detail page, so make it optional. Snippet is extra markup the parent might pass. The list will pass none.
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
name: string;
price: string;
description: string;
href?: string;
children?: Snippet;
}
let { name, price, description, href, children }: Props = $props();
</script>
<article>
{#if href}
<h2><a {href}>{name}</a></h2>
{:else}
<h2>{name}</h2>
{/if}
<p>{description}</p>
<p class="price">{price}</p>
{@render children?.()}
</article>
<style>
article {
border: 1px solid #d4d4d8;
padding: 1rem;
max-width: 20rem;
background: var(--card-bg, white);
}
.price {
font-weight: 600;
}
</style> {href} means href={href}. {@render children?.()} skips the render when the parent left the inside empty. Layouts do the same thing with children, except a layout always has a page to put there.
CSS in <style> is scoped to this file. A p rule here cannot restyle the nav. Svelte stamps a class hash on the selectors. --card-bg is a CSS custom property. Until a parent sets it, var(--card-bg, white) is white.
A capitalised tag is the component. <div> is HTML. Write <productcard> and the browser treats it as unknown HTML. You get an empty box.
Replace src/routes/products/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
import ProductCard from '$lib/ProductCard.svelte';
let { data }: { data: PageData } = $props();
</script>
<h1>Products</h1>
<ul>
{#each data.products as product (product.slug)}
<li>
<ProductCard
name={product.name}
price={product.price}
description={product.description}
href="/products/{product.slug}"
/>
</li>
{/each}
</ul> Reload /products. Each card should have a border.
Do not import products from $lib/server in this file. load already fetched the rows. The page hands the card three strings. ProductCard has no slug prop. The list page owns the URL.
Step 4: children on the detail page
Markup between <ProductCard> and </ProductCard> is the children snippet. Put the add-to-cart button there. The list has no button, so it passes nothing.
Replace src/routes/products/[slug]/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
import ProductCard from '$lib/ProductCard.svelte';
let { data }: { data: PageData } = $props();
let added = $state(false);
</script>
<ProductCard
--card-bg="#fff7ed"
name={data.product.name}
price={data.product.price}
description={data.product.description}
>
<button onclick={() => (added = true)}>
{added ? 'Added' : 'Add to cart'}
</button>
</ProductCard>
<p><a href="/products">All products</a></p> --card-bg="#fff7ed" is not a prop. It sets the CSS variable. Open /products/claw-hammer. That card is orange. The list stays white.
Click the button. The label becomes Added. added is $state on the page. Keep the cart out of ProductCard. If the nav needs a count later, that is context and $state.
Old tutorials used <slot>. Snippets replaced it. A search result that starts with <slot> is Svelte 4.
Callback props
The detail page put onclick on a native <button> because the card had no reason to own it. When the child does own the button, pass the function in. createEventDispatcher is Svelte 4. I still land on it in search results.
A tiny wrapper looks like this:
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
onclick?: () => void;
}
let { children, onclick }: Props = $props();
</script>
<button {onclick}>
{@render children()}
</button> The parent still writes onclick={() => (added = true)}. Same as on a <button>.
Skip Button.svelte for this shop. One native button is enough. Extract it when a second page wants the same markup.
Where the file goes
$lib is where I put a file that two routes import. /products and /products/[slug] both use the card, so it goes there.
If only one folder used it, I would drop ProductCard.svelte next to that folder’s +page.svelte. SvelteKit only treats + files as routes. src/routes/products/ProductCard.svelte is not /products/ProductCard. Routing already said that about helpers.
Do not put .svelte files under $lib/server. Those modules are Node code and secrets. A component renders on the server during SSR and again in the browser. Import $lib/server/products.ts from one and the build fails.
src/lib/components can wait. I would not make that folder for one card.
Things that trip people up
export let name is Svelte 4. New files use $props(). A tutorial that opens with export let will compile in compatibility mode and then fight you when you mix in runes.
createEventDispatcher is the same generation. Pass onclick or onadd as a prop.
Mutating a prop object you did not create, product.price = 'free', trips an ownership warning. Pass a callback up, or use $bindable on an input wrapper if parent and child should share one value. The card above never writes to name.
<productcard> in the template is not your component. The tag has to match the import, capital P.
Forgetting {@render children()} in a wrapper. You pass markup in, nothing comes out. The layout bug is the same one.
Importing $lib/server/products from ProductCard.svelte or from +page.ts. Keep that import in +page.server.ts.
Building a class name with concatenation if you later add Tailwind. The scanner never sees the full string. The card in this post uses a <style> block, so that trap does not apply yet.
What to add next
The same card can take Tailwind classes instead of the <style> block. The Tailwind post already styled a one-off product card. Put those class names on ProductCard if you want the list and the [slug] page to match.
A cart that survives navigation is Svelte 5 store alternatives. Context in the layout, not a module-level let cart.
Bits UI is components you import, one file at a time. The bundle cost post measured what a global UI script costs this site.