Skip to content

Headless WordPress with SvelteKit and WPGraphQL

Justin Ahinon Updated

Resources

Frontend example: Svelte 5.55.0 / SvelteKit 2.55.0; mock GraphQL integration tested

WordPress can remain your editing system while SvelteKit serves the public site. WPGraphQL connects them: a server load function requests a published post, then Svelte renders its HTML. The interesting work starts after that first page: previews, block styling, redirects, and publishing updates all need an owner.

I first wrote this guide in 2023. This example uses Svelte 5 and SvelteKit 2. The frontend was checked and built with Svelte 5.55.0, SvelteKit 2.55.0, and sanitize-html 2.17.0. HTTP behavior was tested against a mock GraphQL endpoint, not a running WordPress installation. Check your installed WPGraphQL schema before copying the query.

When headless WordPress is worth the work

This setup fits a team that wants to keep WordPress editing while building a custom SvelteKit interface. It also fits a site whose content feeds several applications. For a straightforward marketing site that editors already manage well in a WordPress theme, I would first ask what the separate frontend makes possible. You will maintain two deployments and rebuild any theme or plugin behavior the frontend needs.

Rendered block HTML does not bring the theme's CSS, JavaScript, navigation, forms, or SEO output with it. Test representative content before committing to the architecture: a long article, image gallery, embed, table, and any custom block your editors rely on.

Set up the WordPress endpoint

Install and activate WPGraphQL on a development WordPress site. Confirm that its GraphQL endpoint is reachable, normally at /graphql, and publish a test post. Run the query below in your GraphQL IDE with that post's slug. A published, publicly accessible post should be readable without adding an administrator credential to the frontend.

WPGraphQL's posts and pages documentation describes the post query and identifier types. The example intentionally handles a single post slug; a whole WordPress URL tree, including hierarchical pages and custom post types, needs URI-based routing and type-specific rendering.

Use debug mode while diagnosing a development installation, then disable it in production. Public schema introspection is useful for tooling but is not required by this handwritten query. Do not enable debugging on a public site simply to follow a tutorial.

Fetch a published post on the server

In a SvelteKit TypeScript project, install sanitize-html and its types. Set WORDPRESS_GRAPHQL_URL=https://your-wordpress-site.example/graphql in local .env and in the deployment environment. Keep any later preview credentials in server-only environment variables too; see SvelteKit environment variables.

Create src/routes/blog/[slug]/+page.server.ts. This uses a GraphQL variable rather than interpolating the URL slug into the query. An upstream HTTP failure, invalid JSON, timeout, or GraphQL error becomes a 502 response. A successful query with no accessible post becomes a 404. That distinction keeps a WordPress outage from looking like deleted content.

npm install sanitize-html@2.17.0
npm install -D @types/sanitize-html@2.16.0
import { env } from '$env/dynamic/private';
import { error } from '@sveltejs/kit';
import sanitizeHtml from 'sanitize-html';
import type { PageServerLoad } from './$types';

type PostResponse = {
  data?: { post: { title: string | null; content: string | null } | null };
  errors?: { message: string }[];
};

const load: PageServerLoad = async ({ params, fetch, setHeaders }) => {
  if (!env.WORDPRESS_GRAPHQL_URL) error(503, 'Content is temporarily unavailable');
  let payload: PostResponse;
  try {
    const response = await fetch(env.WORDPRESS_GRAPHQL_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        query: `query Post($slug: ID!) {
          post(id: $slug, idType: SLUG) { title content }
        }`,
        variables: { slug: params.slug }
      }),
      signal: AbortSignal.timeout(5000)
    });
    if (!response.ok) throw new Error('WordPress HTTP error');
    payload = await response.json();
    if (payload.errors?.length || !payload.data) throw new Error('WordPress GraphQL error');
  } catch {
    error(502, 'Content is temporarily unavailable');
  }
  if (!payload.data?.post) error(404, 'Post not found');
  const post = payload.data.post;
  setHeaders({ 'cache-control': 'no-store' });
  return {
    post: {
      title: sanitizeHtml(post.title ?? '', { allowedTags: [], allowedAttributes: {} }),
      content: sanitizeHtml(post.content ?? '', {
        allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
        allowedAttributes: {
          ...sanitizeHtml.defaults.allowedAttributes,
          img: ['src', 'alt', 'width', 'height']
        },
        allowedSchemes: ['http', 'https', 'mailto']
      })
    }
  };
};

export { load };

Render the result with Svelte 5

Create src/routes/blog/[slug]/+page.svelte. Both HTML fields below are sanitized on the server before rendering. Svelte's HTML directive does not sanitize input for you. Never pass arbitrary visitor input directly into it.

<script lang="ts">
  import type { PageProps } from './$types';
  let { data }: PageProps = $props();
</script>

<article>
  <h1>{@html data.post.title}</h1>
  <div class="post-content">{@html data.post.content}</div>
</article>

The sanitizer keeps a basic article and image vocabulary, not every WordPress block. It removes script tags and event-handler attributes; it also removes unsupported embeds and styling. Review its allowlist against your real content instead of disabling sanitization when a block looks different. Add typography and image styles to your frontend stylesheet. This minimal component omits page metadata; provide a plain-text document title, description, canonical URL, and social image before launch.

SvelteKit server load functions keep the request on the server. Returning data still makes that data available to the browser, so never return authentication headers or tokens alongside the post. The response types here describe expected data; they are not runtime schema validation or generated WPGraphQL types.

Previews need authentication and a separate path

A query parameter saying preview=true must not grant access to drafts. Build a preview entry point that verifies the viewer or a short-lived signed link, establishes an appropriate preview session, and requests WordPress content with server-side credentials. Restrict the account to the capabilities it needs. WordPress Application Passwords provide revocable application credentials; use HTTPS and keep them out of client JavaScript and URLs.

Match the preview request to your installed plugin version. The current WPGraphQL preview documentation recommends the X-GraphQL-Preview header and marks asPreview deprecated. It requires an authenticated user who can edit the post. Confirm support in your installed release rather than assuming a tutorial's preview arguments still apply.

Send preview responses with Cache-Control: private, no-store, exclude them from shared caches, and use noindex metadata. Check an unpublished draft and an autosaved edit to an already published post. Then repeat both requests while signed out. The public URL must continue to show only published content. These preview steps are an implementation checklist; the public-post example above does not implement them.

Decide what happens when an editor presses Publish

The example sends no-store for the frontend response and adds no application cache. It requests WordPress on each server load, although a cache in front of WordPress may still serve an older response. Check both layers when testing updates.

For a cached site, write down the freshness promise first. If changes can take a minute to appear, a short shared-cache lifetime may be enough. If publication must be immediate, have a verified publish webhook purge the affected post, archive, category, and sitemap entries. Reject unauthenticated webhook calls, retry failed purges, and monitor the last successful update. A browser-side invalidate call does not purge a CDN.

For prerendered pages, publishing requires a rebuild and deployment. Unpublishing matters too: test that removing a post removes its old public response. Keep preview content out of build output and cache keys shared with ordinary visitors.

Preserve URLs and plan the running costs

Before switching domains, export the existing public URLs and map each to its frontend destination. Preserve paths where possible. Put permanent redirects for changed paths on the public host; changing WordPress permalinks alone will not configure the SvelteKit deployment. Check canonical URLs, sitemap links, image URLs, internal links, and 404 status codes on the new domain. SEO plugins can supply data through compatible extensions, but SvelteKit still has to render that data.

Budget for WordPress hosting, database and media backups, plugin maintenance, the SvelteKit runtime or build service, CDN traffic, monitoring, and any paid plugin licenses. Headless does not remove the WordPress bill. Measure query latency and build duration with your real content before choosing a hosting plan. An uncached server-rendered site also needs a plan for WordPress outages; this example returns a 502 rather than serving stale content.

Test the editing workflow before launch

Use a staging site to publish, edit, preview, unpublish, and rename a post. Check media, custom blocks, canonical URLs, and redirect targets. Simulate a WordPress error and verify that the frontend does not report a missing post. Confirm that the chosen adapter can run your dependencies and that the production environment contains the endpoint setting.

The screenshots below show the original 2023 experiment. They are retained as historical context, not current setup instructions. The original repository also contains that earlier implementation, not the refreshed example above.

Original 2023 example: WPGraphQL settings page
Original 2023 example: GenQL data folder
Original 2023 example: 404 error when post does not exist
Original 2023 example: Single post page

Need help with the integration?

For another example of a CMS-backed SvelteKit site, see Calmer Divorce, one of my own projects. It uses Statamic rather than WordPress: editors manage structured content in the CMS, and Svelte components render the guides, worksheets, and interactive checklist.

If your WordPress editors need a SvelteKit frontend, send me the project details. Include the current site, custom blocks or plugins, preview requirements, and the publishing delay you can accept. I can help scope the integration before we agree on a sprint.