• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Advanced Usage

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/seo
pnpm add @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seo
yarn add @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seo
bun add @paragraphcms/client @paragraphcms/parser-svelte @paragraphcms/seo

Set environment variables

Create a .env file in the project root:

.env
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:

  • defaultLocale and locales come 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 /blog as the public entry point. It resolves to /blog for the default locale and /${locale}/blog for translated locales.
  • blog: localizedContentRoute('blog') defines the actual blog content route. It tells the library that the blog index lives at /blog and /${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' }.

paragraph.config.ts
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.

src/lib/components/blog/blog.svelte
<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>
src/lib/components/blog/post.svelte
<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.

src/routes/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  throw redirect(308, '/blog');
};
src/routes/[locale]/+page.server.ts
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.

src/routes/[[locale]]/blog/+layout.server.ts
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.

src/routes/[[locale]]/blog/+page.server.ts
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 };
};
src/routes/[[locale]]/blog/+page.svelte
<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.

src/routes/[[locale]]/blog/[slug]/+page.server.ts
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 };
};
src/routes/[[locale]]/blog/[slug]/+page.svelte
<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.

src/routes/robots.txt/+server.ts
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',
    },
  });
};
src/routes/sitemap.xml/+server.ts
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',
    },
  });
};
src/routes/llms.txt/+server.ts
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.

src/routes/[[locale]]/blog/rss.xml/+server.ts
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}/blog for non-default locales and /blog for 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.

Nuxt

Build a localized Nuxt blog with Paragraph CMS from scratch, including prerendered routes, sitemap.xml, robots.txt, llms.txt, and RSS output.

Manual

Build a production-ready Paragraph CMS integration manually with custom routing, localization, generated SEO documents, and safe rendering strategy.

On this page

Install the packagesSet environment variablesCreate paragraph.config.tsCreate example componentsRedirect / and /:locale to the blogShare locale data from src/routes/[[locale]]/blog/+layout.server.tsBuild /blog and /{locale}/blogBuild /blog/[slug] and /{locale}/blog/[slug]Add robots.txt, sitemap.xml, and llms.txtAdd RSS for the default locale and translated localesVerify the finished setup