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

SEO in SvelteKit: a practical Svelte & SvelteKit guide

Rank SvelteKit sites.

Expona Admin8 min read

Search engine optimization often feels like an afterthought when building modern web applications, but SvelteKit gives us the tools to handle it properly. Unlike client-side-only frameworks, SvelteKit's server-side rendering and prerendering capabilities let us ship real HTML to crawlers—the foundation of SEO. In this guide, we'll walk through the practical steps to rank SvelteKit sites: managing metadata with <svelte:head>, choosing between SSR and prerender, generating sitemaps, and embedding structured data.

Understanding SEO fundamentals in SvelteKit

Search engines discover and rank pages based on content they can parse. With client-side-rendered applications, crawlers often see little more than empty <div> tags. SvelteKit changes this dynamic. By default, it renders your routes on the server, sending fully-formed HTML to both browsers and bots. This means your page titles, descriptions, and content are immediately visible to Google, Bing, and other search engines.

The challenge many teams face is that SEO in SvelteKit isn't automatic. You still need to intentionally set metadata, structure your content, and configure crawl hints. A well-optimized SvelteKit site can rank as well as any traditional server-rendered application—and often better, because you have modern tooling and caching strategies at your disposal.

<svelte:head> — Set title, description and Open Graph tags

Every page needs a unique title and description. These aren't just nice-to-have; they're critical signals to search engines and affect click-through rates in search results. In SvelteKit, the <svelte:head> component lets you inject content into the document head from any page component.

Here's a practical example from a product listing route:

typescript

<!-- src/routes/products/+page.svelte -->
<script>
  export let data;
</script>

<svelte:head>
  <title>Premium Coffee Beans | CoffeeShop</title>
  <meta name="description" content="Browse our selection of single-origin coffee beans, roasted fresh daily. Free shipping on orders over $50." />
  <meta property="og:title" content="Premium Coffee Beans | CoffeeShop" />
  <meta property="og:description" content="Browse our selection of single-origin coffee beans, roasted fresh daily." />
  <meta property="og:image" content="https://example.com/og-coffee.jpg" />
  <meta property="og:url" content="https://example.com/products" />
</svelte:head>

<h1>Our Coffee Selection</h1>
<!-- page content -->

For dynamic routes like product pages, fetch metadata from your server in the load function and use it in the head:

typescript

<!-- src/routes/products/[slug]/+page.ts -->
export async function load({ params, fetch }) {
  const response = await fetch(`/api/products/${params.slug}`);
  const product = await response.json();

  return {
    product,
    title: product.name,
    description: product.shortDescription,
    image: product.imageUrl
  };
}
typescript

<!-- src/routes/products/[slug]/+page.svelte -->
<script>
  export let data;
  const { product, title, description, image } = data;
</script>

<svelte:head>
  <title>{title} | CoffeeShop</title>
  <meta name="description" content={description} />
  <meta property="og:title" content={title} />
  <meta property="og:description" content={description} />
  <meta property="og:image" content={image} />
  <meta property="og:url" content="https://example.com/products/{product.slug}" />
</svelte:head>

<h1>{product.name}</h1>
<!-- product details -->

Open Graph tags aren't just for SEO—they control how your pages look when shared on social media. A well-crafted og:image and og:description will drive more clicks from social platforms.

SSR or prerender — Ship real HTML to crawlers

SvelteKit defaults to server-side rendering (SSR), which means every request renders the page on your server and sends HTML to the client. This is excellent for SEO because crawlers receive fully-formed pages. However, SSR requires a running server, which isn't always necessary.

For static or mostly-static sites, prerendering is often better. During the build process, SvelteKit visits every route and saves the rendered HTML to disk. The result is a static site that needs no server—just flat files served by a CDN. This is faster for users, cheaper to host, and crawlers love it.

Configure prerendering in your svelte.config.js:

typescript

import adapter from '@sveltejs/adapter-static';

export default {
  kit: {
    adapter: adapter({
      precompress: true
    }),
    prerender: {
      crawl: true,
      entries: ['/sitemap.xml', '/rss.xml']
    }
  }
};

When you set crawl: true, SvelteKit automatically discovers pages by following links from your entry points. When you build, you'll get static HTML files for every discovered route.

For dynamic routes (like our product pages above), you need to tell SvelteKit which paths to prerender:

typescript

<!-- src/routes/products/[slug]/+page.ts -->
export async function load({ params }) {
  // load product data
}

export const prerender = true;

export async function entries() {
  const products = await fetchAllProducts();
  return products.map(p => ({ slug: p.slug }));
}

Use SSR when you have frequently-changing content, personalized pages, or real-time data. Use prerender when you can build your site once and serve it statically. Many teams use both: prerender the main site and SSR a separate admin or user dashboard.

Sitemaps — Generate from routes in a +server.ts file

A sitemap tells search engines about all your pages, when they last changed, and how important they are. Without one, crawlers might miss some pages or not crawl them as frequently. In SvelteKit, we generate sitemaps dynamically using a server route.

Create a new route handler:

typescript

<!-- src/routes/sitemap.xml/+server.ts -->
export async function GET() {
  const pages = [
    { url: '/', lastmod: '2024-01-15', priority: '1.0' },
    { url: '/about', lastmod: '2024-01-10', priority: '0.8' },
    { url: '/contact', lastmod: '2024-01-12', priority: '0.7' }
  ];

  // Fetch dynamic pages from your database
  const products = await fetchAllProducts();
  pages.push(
    ...products.map(p => ({
      url: `/products/${p.slug}`,
      lastmod: p.updatedAt.split('T')[0],
      priority: '0.9'
    }))
  );

  const xml = generateSitemap(pages);

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml'
    }
  });
}

function generateSitemap(pages: Array) {
  const entries = pages
    .map(
      p => `
  <url>
    <loc>https://example.com${p.url}</loc>
    <lastmod>${p.lastmod}</lastmod>
    <priority>${p.priority}</priority>
  </url>
    `
    )
    .join('');

  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries}
</urlset>`;
}

Add your sitemap to robots.txt:

typescript

<!-- src/routes/robots.txt/+server.ts -->
export async function GET() {
  const robots = `
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api

Sitemap: https://example.com/sitemap.xml
  `.trim();

  return new Response(robots, {
    headers: {
      'Content-Type': 'text/plain'
    }
  });
}

Submit your sitemap to Google Search Console and Bing Webmaster Tools. They'll crawl it regularly and use it to discover new pages. If you're prerendering, you can even build and include the sitemap as a static file in your prerender entries.

Structured data — JSON-LD script tags in head

Search engines understand your page better when you add structured data. JSON-LD is the modern standard for this. It's a JSON object wrapped in a <script> tag in your page head that describes what your page contains: products, reviews, articles, breadcrumbs, etc.

Here's a schema for a product page:

typescript

<!-- src/routes/products/[slug]/+page.svelte -->
<script>
  export let data;
  const { product } = data;

  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.imageUrl,
    brand: {
      '@type': 'Brand',
      name: 'CoffeeShop'
    },
    offers: {
      '@type': 'Offer',
      url: `https://example.com/products/${product.slug}`,
      priceCurrency: 'USD',
      price: product.price.toString(),
      availability: 'https://schema.org/InStock',
      seller: {
        '@type': 'Organization',
        name: 'CoffeeShop'
      }
    },
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: product.avgRating,
      reviewCount: product.reviewCount
    }
  };
</script>

<svelte:head>
  <title>{product.name} | CoffeeShop</title>
  <meta name="description" content={product.description} />
  <script type="application/ld+json">
    {JSON.stringify(schema)}
  </script>
</svelte:head>

<h1>{product.name}</h1>
<!-- product details -->

For an article or blog post, use Article or NewsArticle schema:

typescript

<script>
  export let data;
  const { article } = data;

  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.excerpt,
    image: article.image,
    datePublished: article.publishedAt,
    dateModified: article.updatedAt,
    author: {
      '@type': 'Person',
      name: article.authorName
    },
    publisher: {
      '@type': 'Organization',
      name: 'CoffeeShop Blog',
      logo: 'https://example.com/logo.png'
    }
  };
</script>

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

Structured data alone won't guarantee high rankings, but it helps search engines understand your content and can improve your appearance in search results (rich snippets, review stars, etc.). Use Google's Rich Results Test to validate your schema.

Common mistakes

  • Forgetting unique titles and descriptions per page. Copying the same meta tags across all pages tells search engines your site is duplicate content. Each page should have its own, descriptive title and meta description.
  • Using prerender without a crawl function. If you prerender with crawl: true but your site is mostly dynamic routes, SvelteKit won't discover them. Always implement an entries() function for dynamic routes.
  • Not updating lastmod in sitemaps. If your sitemap shows old dates for changed pages, crawlers won't prioritize re-indexing. Regenerate sitemaps frequently and keep dates accurate.
  • Mismatching Open Graph URLs. If your og:url doesn't match the actual page URL, social platforms and SEO checkers will flag it. Always ensure og:url is the canonical page address.
  • Ignoring mobile rendering. Test how your site renders on mobile in Google Search Console. SvelteKit is mobile-friendly by default, but slow servers or heavy components can hurt rankings.

Key takeaways

  • Use <svelte:head> to set unique, data-driven titles and descriptions for every page. Include Open Graph tags for social media sharing.
  • Choose SSR for dynamic or frequently-changing content, and prerender for static or mostly-static sites. Both are SEO-friendly when configured correctly.
  • Generate sitemaps dynamically in +server.ts files, include lastmod and priority, and submit them to search engines. Update sitemaps whenever content changes.
  • Add JSON-LD structured data to product pages, articles, and other content types. Validate your schema with Google's Rich Results Test.
  • Test your SEO regularly using Google Search Console, Bing Webmaster Tools, and third-party checkers. Monitor crawl errors and indexation status.
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