Skip to main content

Best practices

SEO

Edit this page on GitHub

The most important aspect of SEO is to create high-quality content that is widely linked to from around the web. However, there are a few technical considerations for building sites that rank well.

Out of the box

SSR

While search engines have got better in recent years at indexing content that was rendered with client-side JavaScript, server-side rendered content is indexed more frequently and reliably. SvelteKit employs SSR by default, and while you can disable it in handle, you should leave it on unless you have a good reason not to.

SvelteKit's rendering is highly configurable and you can implement dynamic rendering if necessary. It's not generally recommended, since SSR has other benefits beyond SEO.

Performance

Signals such as Core Web Vitals impact search engine ranking. Because Svelte and SvelteKit introduce minimal overhead, it's easier to build high performance sites. You can test your site's performance using Google's PageSpeed Insights or Lighthouse.

Normalized URLs

SvelteKit redirects pathnames with trailing slashes to ones without (or vice versa depending on your configuration), as duplicate URLs are bad for SEO.

Manual setup

<title> and <meta>

Every page should have well-written and unique <title> and <meta name="description"> elements inside a <svelte:head>. Guidance on how to write descriptive titles and descriptions, along with other suggestions on making content understandable by search engines, can be found on Google's Lighthouse SEO audits documentation.

A common pattern is to return SEO-related data from page load functions, then use it (as $page.data) in a <svelte:head> in your root layout.

Structured data

Structured data helps search engines understand the content of a page. If you're using structured data alongside svelte-preprocess, you will need to explicitly preserve ld+json data (this may change in future):

svelte.config.js
ts
import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: preprocess({
preserve: ['ld+json']
// ...
})
};
export default config;

Sitemaps

Sitemaps help search engines prioritize pages within your site, particularly when you have a large amount of content. You can create a sitemap dynamically using an endpoint:

src/routes/sitemap.xml/+server.js
ts
export async function GET() {
return new Response(
`
<?xml version="1.0" encoding="UTF-8" ?>
<urlset
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="https://www.w3.org/1999/xhtml"
xmlns:mobile="https://www.google.com/schemas/sitemap-mobile/1.0"
xmlns:news="https://www.google.com/schemas/sitemap-news/0.9"
xmlns:image="https://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="https://www.google.com/schemas/sitemap-video/1.1"
>
<!-- <url> elements go here -->
</urlset>`.trim(),
{
headers: {
'Content-Type': 'application/xml'
}
}
);
}
src/routes/sitemap.xml/+server.ts
ts
export async function GET() {
return new Response(
`
<?xml version="1.0" encoding="UTF-8" ?>
<urlset
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="https://www.w3.org/1999/xhtml"
xmlns:mobile="https://www.google.com/schemas/sitemap-mobile/1.0"
xmlns:news="https://www.google.com/schemas/sitemap-news/0.9"
xmlns:image="https://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="https://www.google.com/schemas/sitemap-video/1.1"
>
<!-- <url> elements go here -->
</urlset>`.trim(),
{
headers: {
'Content-Type': 'application/xml',
},
}
);
}

AMP

An unfortunate reality of modern web development is that it is sometimes necessary to create an Accelerated Mobile Pages (AMP) version of your site. In SvelteKit this can be done by setting the inlineStyleThreshold option...

svelte.config.js
ts
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// since <link rel="stylesheet"> isn't
// allowed, inline all styles
inlineStyleThreshold: Infinity
}
};
export default config;

...disabling csr in your root +layout.js/+layout.server.js...

src/routes/+layout.server.js
ts
export const csr = false;
src/routes/+layout.server.ts
ts
export const csr = false;

...adding amp to your app.html

<html amp>
...

...and transforming the HTML using transformPageChunk along with transform imported from @sveltejs/amp:

src/hooks.server.js
ts
import * as amp from '@sveltejs/amp';
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
let buffer = '';
return await resolve(event, {
transformPageChunk: ({ html, done }) => {
buffer += html;
if (done) return amp.transform(buffer);
}
});
}
src/hooks.server.ts
ts
import * as amp from '@sveltejs/amp';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
let buffer = '';
return await resolve(event, {
transformPageChunk: ({ html, done }) => {
buffer += html;
if (done) return amp.transform(buffer);
},
});
};

To prevent shipping any unused CSS as a result of transforming the page to amp, we can use dropcss:

src/hooks.server.js
ts
import * as amp from '@sveltejs/amp';
import dropcss from 'dropcss';
Cannot find module 'dropcss' or its corresponding type declarations.2307Cannot find module 'dropcss' or its corresponding type declarations.
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
let buffer = '';
return await resolve(event, {
transformPageChunk: ({ html, done }) => {
buffer += html;
if (done) {
let css = '';
const markup = amp
.transform(buffer)
.replace('⚡', 'amp') // dropcss can't handle this character
.replace(/<style amp-custom([^>]*?)>([^]+?)<\/style>/, (match, attributes, contents) => {
css = contents;
return `<style amp-custom${attributes}></style>`;
});
css = dropcss({ css, html: markup }).css;
return markup.replace('</style>', `${css}</style>`);
}
}
});
}
src/hooks.server.ts
ts
import * as amp from '@sveltejs/amp';
import dropcss from 'dropcss';
Cannot find module 'dropcss' or its corresponding type declarations.2307Cannot find module 'dropcss' or its corresponding type declarations.
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
let buffer = '';
return await resolve(event, {
transformPageChunk: ({ html, done }) => {
buffer += html;
if (done) {
let css = '';
const markup = amp
.transform(buffer)
.replace('⚡', 'amp') // dropcss can't handle this character
.replace(/<style amp-custom([^>]*?)>([^]+?)<\/style>/, (match, attributes, contents) => {
css = contents;
return `<style amp-custom${attributes}></style>`;
});
css = dropcss({ css, html: markup }).css;
return markup.replace('</style>', `${css}</style>`);
}
},
});
};

It's a good idea to use the handle hook to validate the transformed HTML using amphtml-validator, but only if you're prerendering pages since it's very slow.