SvelteKit
Build a localized SvelteKit blog with Paragraph CMS, including sitemap.xml, robots.txt, llms.txt, and RSS output.
This guide builds a localized SvelteKit blog from scratch with Paragraph CMS,
using server load functions for content, locale-aware /blog URLs under an
optional [[locale]] route segment, and generated sitemap.xml, robots.txt,
llms.txt, and RSS output. The code below follows the same integration
pattern as the full
paragraphcms/sveltekit-advanced
example and matches the current starter: / redirects to /blog, the
default locale stays unprefixed, translated routes live under
src/routes/[[locale]]/blog, and Paragraph content is rendered with
renderParagraphContentHtml().
Want a ready-made project instead? Use the
paragraphcms/sveltekit-advanced,
which already includes localized blog routes and generated sitemap.xml,
robots.txt, llms.txt, and RSS.
This guide assumes you already have a standard SvelteKit project scaffolded
with sv create, so the root src/routes/+layout.svelte and global styles
setup are already in place.
Install the packages
Add the Paragraph CMS client, Svelte renderer, and SEO package to your SvelteKit app:
npm install @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seopnpm add @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seoyarn add @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seobun add @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seoSet environment variables
Create a .env file in the project root:
PARAGRAPH_API_KEY=your_api_key
PARAGRAPH_SITE_NAME=Blog
PARAGRAPH_SITE_DESCRIPTION=Latest posts from the blog.PARAGRAPH_SITE_NAME and PARAGRAPH_SITE_DESCRIPTION are optional, but
the current example reads them when present.
Create paragraph.config.ts
Before you wire up the client, create a collection named blog in your
Paragraph CMS workspace at
https://app.paragraphcms.com and keep
your blog posts there. This guide uses blog as the public section name
for routes, feeds, and generated SEO documents.
Create one shared client, a shared locale config, a shared site object,
and a shared seo instance.
One detail here is easy to misread: routes is not your SvelteKit file
tree. It is the route map used by @paragraphcms/seo when it generates
canonical URLs, sitemap entries, RSS links, and llms.txt links.
In this example:
defaultLocaleandlocalescome from Paragraph CMS. The route loaders and RSS handler use them to keep the default locale unprefixed and reject unknown locale codes.home: localizedRoute('blog')tells the SEO library to treat/blogas the public entry point. It resolves to/blogfor the default locale and/${locale}/blogfor translated locales.blog: localizedContentRoute('blog')defines the actual blog content route. It tells the library that the blog index lives at/blogand/${locale}/blog, and that post URLs should be built by appending the page slug.
Both entries use 'blog' on purpose. The first says 'this is the public
home URL', and the second says 'this is the blog content section'.
If your real homepage lived at /, you would use home: localizedRoute()
instead.
Set site.url to your production site URL so generated sitemap entries and
feeds point to the correct domain.
The current example also passes site.defaultLocale so generated links
follow the same unprefixed-default-locale rule as the route loaders.
If you later want the SEO layer to filter the blog collection explicitly
too, extend localizedContentRoute('blog') with
params: { collection: 'blog' }.
import { env } from '$env/dynamic/private';
import { Client } from '@paragraphcms/client';
import { SEO, localizedContentRoute, localizedRoute } from '@paragraphcms/seo';
const apiKey = env.PARAGRAPH_API_KEY;
if (!apiKey) {
throw new Error('PARAGRAPH_API_KEY environment variable is not set');
}
export const client = new Client({ apiKey });
const defaultLocaleResult = await client.locales.getDefaultLocale();
const defaultLocaleError = defaultLocaleResult.error;
if (defaultLocaleError) {
throw defaultLocaleError;
}
export const defaultLocale = defaultLocaleResult.data;
const { data: availableLocales, error: localesError } =
await client.locales.list();
if (localesError) {
throw localesError;
}
export const locales = [
...new Set([defaultLocale, ...availableLocales.map((locale) => locale.code)]),
];
export const site = {
url: 'https://example.com',
name: env.PARAGRAPH_SITE_NAME ?? 'Blog',
description: env.PARAGRAPH_SITE_DESCRIPTION,
defaultLocale,
};
export const seo = new SEO({
client,
site,
routes: {
home: localizedRoute('blog'),
blog: localizedContentRoute('blog'),
},
});Create example components
Create the same two small components used in the current starter: a blog index component that keeps links locale-aware, and a post component that renders Paragraph content.
The current example keeps these files in legacy Svelte syntax. If your app forces runes mode globally, keep them in legacy mode or translate the snippets to runes syntax.
<script lang="ts">
import type { PageSummaryWithSlug } from '@paragraphcms/client';
export let pages: PageSummaryWithSlug[];
export let locale: string;
export let defaultLocale: string;
</script>
<main>
<h1>Blog</h1>
<ul>
{#each pages as page}
<li>
<a
href={
locale === defaultLocale
? `/blog/${page.slug}`
: `/${locale}/blog/${page.slug}`
}
>
{page.title}
</a>
</li>
{/each}
</ul>
</main><script lang="ts">
import type { PageSummaryWithSlug } from '@paragraphcms/client';
import { renderParagraphContentHtml } from '@paragraphcms/parser-svelte';
export let page: PageSummaryWithSlug;
$: content = renderParagraphContentHtml({ content: page.content ?? [] });
</script>
<main>
<h1>{page.title}</h1>
{@html content ?? ''}
</main>Redirect / and /:locale to the blog
This starter treats /blog as the public entry point, so the root route
is just a redirect. Add a second redirect so /{locale} resolves to the
corresponding blog index and invalid locale codes return 404.
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
throw redirect(308, '/blog');
};import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { defaultLocale, locales } from '../../../paragraph.config';
export const load: PageServerLoad = async ({ params }) => {
if (!locales.includes(params.locale)) {
throw error(404, 'Not Found');
}
throw redirect(
308,
params.locale === defaultLocale ? '/blog' : `/${params.locale}/blog`,
);
};Share locale data from src/routes/[[locale]]/blog/+layout.server.ts
Use one optional route segment so the same route tree serves /blog,
/blog/[slug], /{locale}/blog, /{locale}/blog/[slug], and localized RSS.
The layout loader strips the default locale prefix, validates locale codes,
and exposes locale plus defaultLocale to child routes.
import { error, redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { defaultLocale, locales } from '../../../../paragraph.config';
export const load: LayoutServerLoad = async ({ params, url }) => {
if (params.locale === defaultLocale) {
const pathname = url.pathname.slice(`/${defaultLocale}`.length) || '/';
throw redirect(308, `${pathname}${url.search}`);
}
if (params.locale && !locales.includes(params.locale)) {
throw error(404, 'Not Found');
}
return {
locale: params.locale ?? defaultLocale,
defaultLocale,
};
};This keeps /blog reserved for the default locale and prevents duplicate
content under /{default-locale}/blog.
Build /blog and /{locale}/blog
Use parent() to read the locale chosen by the layout loader. Setting
requiredSlug: true narrows the result to PageSummaryWithSlug[], so
page.slug is safe to use directly.
import type { PageServerLoad } from './$types';
import { client } from '../../../../paragraph.config';
export const load: PageServerLoad = async ({ parent }) => {
const { locale } = await parent();
const { data, error } = await client.pages.list({
language: locale,
requiredSlug: true,
});
if (error) {
throw error;
}
return { pages: data };
};<script lang="ts">
import type { PageProps } from './$types';
import Blog from '$lib/components/blog/blog.svelte';
let { data }: PageProps = $props();
</script>
<Blog pages={data.pages} locale={data.locale} defaultLocale={data.defaultLocale} />By default, client.pages.list() returns published pages only. If you want
to include unpublished pages too or limit the query to the blog
collection, pass additional parameters under the same call:
client.pages.list({
language: locale,
requiredSlug: true,
published: false,
collection: 'blog',
});Build /blog/[slug] and /{locale}/blog/[slug]
For article pages, resolve the post by both slug and language through
client.pages.list() instead of client.page.getBySlug(). That keeps the
requested locale and the resolved page aligned, including translated
routes.
import { error } from '@sveltejs/kit';
import type { PageSummaryWithSlug } from '@paragraphcms/client';
import type { PageServerLoad } from './$types';
import { client } from '../../../../../paragraph.config';
export const load: PageServerLoad = async ({ params, parent }) => {
const { locale } = await parent();
const { data, error: clientError } = await client.pages.list({
slug: params.slug,
language: locale,
requiredSlug: true,
includeContent: true,
});
if (clientError) {
throw clientError;
}
const page = data[0] as PageSummaryWithSlug | undefined;
if (!page) {
throw error(404, 'Not Found');
}
return { page };
};<script lang="ts">
import type { PageProps } from './$types';
import Post from '$lib/components/blog/post.svelte';
let { data }: PageProps = $props();
</script>
<Post page={data.page} />This keeps /blog/[slug] and /{locale}/blog/[slug] aligned with the
current locale while keeping the API key on the server.
Add robots.txt, sitemap.xml, and llms.txt
Once seo is configured, each generated document is just a small route
handler that returns the string from @paragraphcms/seo.
import type { RequestHandler } from './$types';
import { seo } from '../../../paragraph.config';
export const GET: RequestHandler = async () => {
return new Response(await seo.robotsTxt(), {
headers: {
'content-type': 'text/plain; charset=utf-8',
},
});
};import type { RequestHandler } from './$types';
import { seo } from '../../../paragraph.config';
export const GET: RequestHandler = async () => {
return new Response(await seo.sitemapXml(), {
headers: {
'content-type': 'application/xml; charset=utf-8',
},
});
};import type { RequestHandler } from './$types';
import { seo } from '../../../paragraph.config';
export const GET: RequestHandler = async () => {
return new Response(await seo.llmsTxt(), {
headers: {
'content-type': 'text/plain; charset=utf-8',
},
});
};Add RSS for the default locale and translated locales
Because src/routes/[[locale]]/blog/rss.xml/+server.ts lives under the
optional [[locale]] segment, one handler can serve both /blog/rss.xml
and /{locale}/blog/rss.xml.
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { defaultLocale, locales, seo } from '../../../../../paragraph.config';
export const GET: RequestHandler = async ({ params, url }) => {
if (params.locale === defaultLocale) {
const pathname = url.pathname.slice(`/${defaultLocale}`.length) || '/';
throw redirect(308, `${pathname}${url.search}`);
}
if (params.locale && !locales.includes(params.locale)) {
throw error(404, 'Not Found');
}
return new Response(
await seo.rssXml({
locale: params.locale,
route: 'blog',
}),
{
headers: {
'content-type': 'application/rss+xml; charset=utf-8',
},
},
);
};Verify the finished setup
After these changes, your app should expose or redirect:
/redirects to/blog/blog/blog/[slug]/{locale}redirects to/{locale}/blogfor non-default locales and/blogfor the default locale/{locale}/blog/{locale}/blog/[slug]/robots.txt/sitemap.xml/llms.txt/blog/rss.xml/{locale}/blog/rss.xml
Requests that include the default locale prefix, such as
/{default-locale}/blog or /{default-locale}/blog/rss.xml, redirect to
the unprefixed versions, and unknown locale codes return 404.
That gives you the same route and generated-document shape as the current
sveltekit-advanced example while keeping the guide self-contained from
the initial install onward.