KINDERAS.COM

Tue Nov 08 2022

Implementing Sanity.io preview in a SvelteKit project

This article is a complete guide to implementing document preview in Sanity Studio targeting a SvelteKit site. We'll take a look at the setup for Sanity in brief, and we will focus mainly on the SvelteKit part, since there are plenty of resources for the Sanity config.

A screenshot of Sanity studio with a dropdown showing the preview plugin selected
Using the Sanity preview plugin

Before we get started, note that this approach will only work for server rendered SvelteKit sites. It will not work for statically generated sites.

The main idea is to have Sanity Studio point to an api endpoint in the SvelteKit site. This api endpoint will then setup preview for the site and redirect to the correct page, which will then display any available preview data. Preview data in a Sanity context is draft documents.

There are two main parts, the Sanity Studio preview setup and the Setup for the SvelteKit site. Let's get to it!

Sanity Studio preview setup

Here I'll assume that you have already a running Sanity Studio instance ready to go.

First, install the Production Preview plugin and follow the docs to get set up. Url: https://www.sanity.io/docs/preview-content-on-site.

Note that you might prefer to use an iframe plugin to handle preview instead, especially if you want to add preview for only some documents. See this link for more info: https://www.sanity.io/plugins/iframe-pane

The resolveProductionUrl.js|ts file you created when installing the Preview Plugin will probably need some tweaking.

We will be passing information to the SelteKit site using searchParams/queryParams.

It's usually a good idea to pass a secret key (you can use https://randomkeygen.com to generate one) which will be used in the SvelteKit site to validate that the request is coming from Sanity Studio. This prevents outsiders from calling the preview api route in the SvelteKit site).

When it comes to which params to send to the SvelteKit site, that depends on the structure of you site. You'll need to pass enough info to be able to construct the paths on the SvelteKit site.

For this site, we have pages and blog posts. The pages have slugs that render on the root of the site (like /about). The blog posts are grouped by date and slug. So for this site the resolveProductionUrl.ts will look something like this:

export default function resolveProductionUrl(doc: {
	_type: string;
	_id: string;
	slug: { current: string };
	publishedAt?: string;
}) {
	const baseUrl = "url to the seveltkit site";
	const previewUrl = new URL(baseUrl);
	previewUrl.pathname = `/api/preview`;

	// Add static query params
	// Note: The «previewSecret» is an env var
	previewUrl.searchParams.append(`secret`, previewSecret);
	previewUrl.searchParams.append("_type", doc._type);

	if (doc?.slug && doc.slug.current) {
		// Append the slug as a query param if it exits
		previewUrl.searchParams.append("slug", doc.slug.current);
	}

	if (doc.publishedAt) {
		// Append the published date if it exists (for blog posts)
		previewUrl.searchParams.append("publishedAt", doc.publishedAt);
	}

	return previewUrl.toString();
}

The resolveProductionUrl will now return the correct url and and search params to our SvelteKit site. It will look something like this (for localhost): http://localhost:5173/api/preview?_type="page"&slug="aboutPage"&secret=my-secret

Moving on to the SvelteKit site where we'll use the params from this url eventually.

SvelteKit site preview setup

Again, we will assume that you have a SvelteKit project setup and working. We will also assume that you are using Typescript, but this is not required.

There are several parts to handling preview in the SvelteKit project.

  • Writing groq queries that returns both drafts and published documents
  • Handling previews in your Sanity data fetching methods
    • Filtering documents based on whether preview is active
    • Using the preview param in pages
  • Creating an api route to handle the request from Sanity
  • Setting up a hook to inform pages about the preview status

We'll start with the pages and work our way to handling the url we just created in the Sanity setup.

Setting up preview for your pages

[If you are using Typescript] First go into the `src/app.d.ts` and change `Locals` to the following:

interface Locals {
	preview: boolean;
}

This will add proper typing when using locals in pages.

Speaking of pages, open one of the (server) pages where you need to implement preview. In my case I want to have preview enabled for the about page (src/routes/about/+page.server.ts). First we import locals and extract the preview param from it. For now, locals.preview will always be undefined. We'll fix that later with a hook.

Note that the steps taken for the about page in this example needs to be repeated for all pages that should support preview.
// src/routes/about/+page.server.ts
export const load: PageServerLoad = async ({ params, locals }) => {
	// Extract the preview param from locals
	const preview = locals.preview;
	// Get data for the about page
	const aboutPage = await getAboutPageData(preview);
	return { aboutPage };
}

The getAboutPageData function takes preview as a parameter. This will let the function know whether to return a published page, or a draft (if it exists). This is how this function might look (please read the comments):

export async function getAboutPage(preview = false): Promise<Page> {
	const query = `*[_type == "pages" && slug.current == "about"]`;
	// Passing preview to the «getSanityClient» makes it
	// not use a cdn if preview is true
	// Note that the request returns an array of about pages!
	const pages = await getSanityClient(preview).fetch(query);
	
	// If preview is true, return a draft (if it exists)
	// Otherwise return a published page
	return filterDataToSingleItem(pages, preview);
}
// The filter function (filterDataToSingleItem.ts)
export function filterDataToSingleItem<T>(data: Array<T>, preview: boolean): T {
	if (!Array.isArray(data)) {
		return data;
	}

	if (data.length === 1) {
		return data[0];
	}

	if (preview) {
		return data.find((item) => (item as any)._id.startsWith(`drafts.`)) || data[0];
	}

	return data[0];
}

The api route and the hook/middleware

Back in the Sanity setup we implemented the resolveProductionUrl function. This function returned a url which was something like http://localhost:5173/api/preview?_type="page"&slug="aboutPage"&secret=""....

Now we need to create the /api/preview endpont in our SelteKit app and have it redirect to the correct page. Let's start by creating a new endpoint at src/routes/api/preview/+server.ts.

// src/routes/api/preview/+server.ts

// Get the same secret we we generated in the Sanity config
// This should probably be a private env variable
import { SANITY_PREVIEW_SECRET } from "$env/static/private";
import type { RequestHandler } from "./$types";
import { redirect } from "@sveltejs/kit";

// this endpoint will only be called from within Sanity Studio
export const GET: RequestHandler = async ({ url, cookies }) => {
	// Grab the secret key from the search param
	// and validate that it's correct
	const secretKey = url.searchParams.get("secret");
	if (secretKey !== SANITY_PREVIEW_SECRET) {
		return new Response("Invalid token", { status: 401 });
	}
	
	// Grab all the search params we need to figure out the
	// correct path for our page
	const docType = url.searchParams.get("_type");
	const docSlug = url.searchParams.get("slug") || undefined;

	// Set a preview cookie
	// This is a secure, httpOnly cookie
	// It can only be read on the server side
	// You can optionally store additional preview data in the cookie. 
	// Here we simply set the cookie as a flag that preview is active.
	cookies.set("preview", "true", { path: "/" });

	// redirect to the correct page to preview
	// Using hardcoded path «about» for this demo
	// you would need some logic here to resolve the real path
	// using the searchParams
	const path = "/about"; // figureOutPathToPage(docType, docSlug);
	// redirect to the correct path
	throw redirect(307, path);
};

To summarize. This endpint take in all search parameters from Sanity, validates the key and builds a path to the correct url (in this case the about page). It also sets a cookie as a flag that preview is

The last step is the hook. If you haven't used server hooks in SvelteKit, take a look at the docs here: https://kit.svelte.dev/docs/hooks#server-hooks

Remember back when we defined the server route for the about page(src/routes/about/+page.server.ts)? We used the locals.preview in that route? Now we are going to set the value for locals.preview using a server hook.

// src/hooks.server.ts

import type { Handle } from "@sveltejs/kit";

/**
 * This hook/middleware runs for each request
 * @see https://kit.svelte.dev/docs/hooks#server-hooks
 */
export const handle: Handle = async ({ event, resolve }) => {
	// If there is a preview cookie, then enable preview for all routes
	event.locals.preview = event.cookies.get("preview") ? true : false;

	const response = await resolve(event);

	if (event.locals.preview) {
		// We don't want caching when preview is active
		response.headers.set("cache-control", "no-cache, no-store, must-revalidate");
		response.headers.set("Pragma", "no-cache");
		response.headers.set("Expires", "0");
	}

	return response;
};

That's it. Now preview will work for any pages you have configured.

Side note. Exiting preview.

If you open a page in preview mode in Sanity you will be "stuck" in preview mode until you close the browser. The preview cookie is true for the duration of the session. It might be a good idea to provide an escape hatch allowing any Sanity editors to exit preview mode without having to close the browser. A simple way of doing this is to provide a link to a route that removes the preview cookie. An example of such a route can be found below.

// src/routes/api/exit-preview/+server.ts

import type { RequestHandler } from "./$types";
import { redirect } from "@sveltejs/kit";

export const GET: RequestHandler = async ({ cookies }) => {
  // Remove the preview cookie
	cookies.delete("preview", { path: "/" });
  // redirect to the home page
	throw redirect(307, "/");
};

References