/assets/seo.jpg

How to add a XML Sitemap to SvelteKit

Motivation

There is only a point in adding a sitemap, if you want to help search engines index your website. For a single page application there is no point in doing so. The website itself will be sufficient. But here in this example we will be showing you a way to generate a sitemap from existing routes as well as from external sources, like a Content Management System. In this case we will be using Ghost CMS.

What is an XML sitemap?

An XML sitemap is a kind of list object that has your websites’ pages listed. You can see one here https://myrmod.de/sitemap.xml in action.

Our sitemap will consist of 2 different kind of pages:

  • posts & pages from our content management system (cms)
  • pages inside our router (eg. /privacy)

The setup

The setup is easy. If you initialize a SvelteKit project you will find a directory called “routes” inside your “src” directory. This is the place where SvelteKit keeps its page components as well as its routes. One of those routes will be our sitemap. Our directory might look like this:

directory structure

In this routes directorys root we will be creating a file called “sitemap.xml.ts”. If you’re using plain JavaScript you change the filename to “sitemap.xml.js”. Our route will be reachable by a simple GET request.

js
export async function get() {
const body = "Hello sitemap!";
const headers = {
'Cache-Control': `max-age=0, s-max-age=${600}`,
'Content-Type': 'application/xml',
};
return {
body,
headers,
};
}

Each route can return custom headers and should return a body. A sitemap is an XML file. So we need to set the corresponding header. Our sitemap should not be cached for a long time, otherwise updates to the sitemap might not be reflected on each visit, which might stop search engines from indexing your website. If you call http://example.com/sitemap.xml you will see “Hello World” being printed to you.

Creating our Sitemap

An XML sitemap has a specific structure we need to match for it to work. We will create a function called “render” to do this for us. This function takes a few parameters, our pages, posts and static pages:

js
const render = (pages: PostsOrPages, staticPages: Array<string>, posts: PostsOrPages) => `<?xml version="1.0" encoding="UTF-8" ?>
<urlset
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
xmlns:pagemap="http://www.google.com/schemas/sitemap-pagemap/1.0"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
>
// our pages
</urlset>
`

We see several attributes on the urlset element. If you add all of those you shouldn’t have an issue no matter what URLs you add. Those you don’t need can be omitted of course.

Getting our page routes

Now we need to populate our sitemap with our URLs. So we go back to our get function. At first we want our static pages we have in our routes directory. ViteJS helps us a little with this with the “import.meta.glob()” function, which returns an object containing key value pairs. We need to convert it to an array containing only the keys. These will become our pages.

js
const staticPages = Object.keys(import.meta.glob('/src/routes/**/!(_)*.svelte'))

That’s not all the way though. We still get some pages from routes we don’t want. So we need to filter them.

js
const staticPages = Object.keys(import.meta.glob('/src/routes/**/!(_)*.svelte')).filter(page => {
const filters: Array<string> = [
'slug]',
'_',
'private',
'/src/routes/index.svelte',
]
return !filters.find(filter => page.includes(filter))
})

As you can see we’re removing all pages containing [...slug] or [slug], as well as our __layout.svelte file. Some private files and the index route. You should specify this filtering to your needs.

Now we’re almost where we want to be. Currently we’re still getting pages that look like “/src/routes/my-page/index.svelte” and “/src/routes/awesome-page.svelte”. Both the beginning and the ending are not the way we need them to be. They’re not entirely wrong though. We need to map them correctly, so our staticPages will look like this:

js
const staticPages = Object.keys(import.meta.glob('/src/routes/**/!(_)*.svelte')).filter(page => {
const filters: Array<string> = [
'slug]',
'_',
'private',
'/src/routes/index.svelte',
]
return !filters.find(filter => page.includes(filter))
}).map(page => {
return page.replace('/src/routes', 'https://example.com').replace('/index.svelte', '').replace('.svelte', '')
})

Now for getting our dynamic pages and posts from our CMS. Using Ghost CMS we need some imports

js
import GhostContentAPI, { PostsOrPages } from '@tryghost/content-api'
import { API_URL, CONTENT_API_KEY } from '$lib/util/env'

Here we have the client side API as well as the type “PostsOrPages” and two environment variables, that we get from our Ghost CMS. These variables are for authentication only

js
const api = new GhostContentAPI({
url: `${API_URL}`,
key: `${CONTENT_API_KEY}`,
version: 'v3',
})

Instead of importing them like in the example you can use either ({}).VITE_CONTENT_API_KEY or dotenv. It depends on the security you’re striving for as well as convenience. From Ghost we’re able to get our resources with

js
const posts = await api.posts.browse({
limit: 'all',
fields: ['slug', 'updated_at'],
})
const pages = await api.pages.browse({
limit: 'all',
fields: ['slug', 'updated_at'],
})

We want all our pages, the default would be 15. We don’t need all fields from our cms, so we define which fields we want explicity. With those two in place we’re ready to add our render function to out get function.

js
const body = render(pages, staticPages, posts)

Creating our url elements

In an XML sitemap our url elements have 3 distinct information.

  • location ()
  • last modification ()
  • frequency of change () The location is the URL, where the page can be found. Last modification and frequency of change are exactly what their names imply. We need to map each of our pages to those elements.

For our pages and posts it’s straight forward

js
${pages.map(page => `
<url>
<loc>https://example.com/${page.slug !== 'homepage' ? page.slug : ''}</loc>
<lastmod>${new Date(page.updated_at).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
</url>
`)}
${posts.map((post) => `
<url>
<loc>https://example.com/posts/${post.slug}</loc>
<lastmod>${new Date(post.updated_at).toISOString()}</lastmod>
<changefreq>daily</changefreq>
</url>
`)}

For our pages and posts we’re using the provided slug in combination with out domain as location. For pages we had to catch a special case, because we added “homepage” as slug for our homepage with the URL ”/“.

The last modification date is our updated_at value. We make sure that it has the format we want by wrapping it in the Date function. Change frequency can be a string of daily, weekly, monthly or yearly. Use what suits your needs, but try to use the longest time perios applicable, because search engines don’t crawl indefinitely at once. For the staticPages we’re going with another approach, because these are files.

js
${staticPages.map(staticPage => `
<url>
<loc>${staticPage}</loc>
<lastmod>${`${process.env.VITE_BUILD_TIME}`}</lastmod>
<changefreq>monthly</changefreq>
</url>
`)}

We’re getting the last modification date from a custom defined constant. This constant is being defined at build time and is the same for each of our static pages. To define this constant we have to modify our svelte.config.js

js
...
kit: {
vite: {
define: {
'process.env.VITE_BUILD_TIME': JSON.stringify(new Date().toISOString()),
},
...

With this we have a new constant we can use throughout our application. You don’t have to call them like in the example. That’s just what felt “right” to me.

Wrapping Up

With all of this in place our sitemap.xml.ts should now look something like this

js
import GhostContentAPI, { PostsOrPages } from '@tryghost/content-api'
import { API_URL, CONTENT_API_KEY } from '$lib/util/env'
export async function get() {
const staticPages = Object.keys(import.meta.glob('/src/routes/**/!(_)*.svelte')).filter(page => {
const filters: Array<string> = [
'slug]',
'_',
'private',
'/src/routes/index.svelte',
]
return !filters.find(filter => page.includes(filter))
}).map(page => {
return page.replace('/src/routes', 'https://example.com').replace('/index.svelte', '').replace('.svelte', '')
})
const api = new GhostContentAPI({
url: `${API_URL}`,
key: `${CONTENT_API_KEY}`,
version: 'v3',
})
const posts = await api.posts.browse({
limit: 'all',
fields: ['slug', 'updated_at'],
})
const pages = await api.pages.browse({
limit: 'all',
fields: ['slug', 'updated_at'],
})
const body = render(pages, staticPages, posts)
const headers = {
'Cache-Control': `max-age=0, s-max-age=${600}`,
'Content-Type': 'application/xml',
}
return {
body,
headers,
}
}
const render = (pages: PostsOrPages, staticPages: Array<string>, posts: PostsOrPages) => `<?xml version="1.0" encoding="UTF-8" ?>
<urlset
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
xmlns:pagemap="http://www.google.com/schemas/sitemap-pagemap/1.0"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
>
${pages.map(page => `
<url>
<loc>https://example.com/${page.slug !== 'homepage' ? page.slug : ''}</loc>
<lastmod>${new Date(page.updated_at).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
</url>
`)}
${staticPages.map(staticPage => `
<url>
<loc>${staticPage}</loc>
<lastmod>${`${process.env.VITE_BUILD_TIME}`}</lastmod>
<changefreq>monthly</changefreq>
</url>
`)}
${posts.map((post) => `
<url>
<loc>https://example.com/posts/${post.slug}</loc>
<lastmod>${new Date(post.updated_at).toISOString()}</lastmod>
<changefreq>daily</changefreq>
</url>
`)}
</urlset>
`

We can see the results when opening https://example.com/sitemap.xml. You can confirm that it’s working by using different online tools like for example

I hope you learned something and if you have suggestions or found some errors please let me know using Twitter.

Thank you for reading!