├── .env.example
├── .gitignore
├── README.md
├── package.json
├── pnpm-lock.yaml
├── sanity.cli.ts
├── src
├── app.d.ts
├── app.html
├── hooks.server.ts
├── lib
│ ├── components
│ │ ├── Header.svelte
│ │ └── PreviewBanner.svelte
│ ├── config
│ │ ├── app.ts
│ │ ├── environment.ts
│ │ └── sanity
│ │ │ ├── client.ts
│ │ │ ├── components
│ │ │ └── PostsPreview.tsx
│ │ │ ├── config.ts
│ │ │ ├── index.ts
│ │ │ ├── queries.ts
│ │ │ ├── sanity.config.ts
│ │ │ ├── schemas
│ │ │ ├── author.ts
│ │ │ ├── post.ts
│ │ │ └── settings.ts
│ │ │ └── sveltekit
│ │ │ ├── aborter.ts
│ │ │ ├── currentUser.ts
│ │ │ ├── previewSubscriptionStore.ts
│ │ │ └── types.ts
│ ├── types
│ │ └── index.ts
│ └── utils
│ │ ├── index.ts
│ │ ├── preview-cookies.ts
│ │ └── sanity-studio.svelte
└── routes
│ ├── (app)
│ ├── +layout.svelte
│ ├── +page.server.ts
│ ├── +page.svelte
│ └── posts
│ │ └── [slug]
│ │ ├── +page.server.ts
│ │ └── +page.svelte
│ ├── (studio)
│ └── studio
│ │ └── [...catchall]
│ │ ├── +page.server.ts
│ │ └── +page@(studio).svelte
│ ├── +layout.server.ts
│ ├── +layout.svelte
│ └── api
│ ├── exit-preview
│ └── +server.ts
│ └── preview
│ └── +server.ts
├── static
├── avatar.jpg
├── dark.svg
├── favicon.png
└── light.svg
├── svelte.config.js
├── tsconfig.json
├── vite.config.js
└── windi.config.ts
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_SANITY_PROJECT_ID=""
2 | VITE_SANITY_DATASET="production"
3 |
4 | ## Also available on the client side, because studio is a SPA.
5 | VITE_SANITY_PREVIEW_SECRET="any-random-string"
6 |
7 | SANITY_API_READ_TOKEN=""
8 | SANITY_API_WRITE_TOKEN=""
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 | .vercel
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmultiplehats%2Fsveltekit-sanity-v3&env=VITE_SANITY_PROJECT_ID,VITE_SANITY_DATASET,VITE_SANITY_PREVIEW_SECRET,SANITY_API_READ_TOKEN,SANITY_API_WRITE_TOKEN&envDescription=These%20API%20keys%20are%20needed%20from%20Sanity%20to%20run%20this%20app.&project-name=my-sveltekit-sanity-v3&repo-name=my-svelte-sanity-v3)
2 |
3 | # Sveltekit x Sanity Studio v3
4 |
5 | Hi there 👋! This is a repo for my [talk on YouTube](https://www.youtube.com/watch?v=xELXz553LCY), from the [Sanity.io Virtual Meetup - Autumn 2022](https://www.meetup.com/meetup-group-dvjyrjdv/events/289456759/).
6 |
7 | ## Features
8 |
9 | ### ✨ Embedding Sanity V3 in a Sveltekit app
10 |
11 | When I was working on a new project that involved [Sveltekit](https://kit.svelte.dev/) and Sanity I got curious and wanted to know whether I could directly embed the Sanity Studio V3 (Release Candiate) into a SvelteKit app. I was living on the edge already, so I might as well embrace it 🌈
12 |
13 | ### 👀 Side-by-side Instant Content preview.
14 |
15 | I also go over on how we use Sanity's Side-by-side Instant Content preview feature with Sveltekit. And how you can easily implement this in your own SvelteKit applications. The code ([createPreviewSubscriptionStore](https://github.com/multiplehats/sveltekit-sanity-v3/blob/main/src/lib/config/sanity/sveltekit/previewSubscriptionStore.ts#L10)) is mostly inspired from [Sanity's toolkit for Next.js](https://github.com/sanity-io/next-sanity).
16 |
17 | #### Learn more
18 | - [Introduction to Sanity Studio v3](https://beta.sanity.io/docs/platform/studio/v2-to-v3)
19 | - [Sanity Studio V3 Announcement](https://www.sanity.io/blog/sanity-studio-v3-developer-preview)
20 |
21 | ## Developing
22 |
23 | Once you've created a project and installed dependencies with `pnpm install`. Make sure you have added all the environment variables (see env.example).
24 |
25 | ```bash
26 | pnpm dev
27 |
28 | # or start the server and open the app in a new browser tab
29 | pnpm dev -- --open
30 | ```
31 |
32 | ## Building & Previewing
33 |
34 | To build the project, run:
35 |
36 | ```bash
37 | pnpm build
38 | ```
39 |
40 | To preview the build, run:
41 | ```bash
42 | pnpm preview
43 | ```
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "daghappie",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "vite dev --port 3100",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
11 | "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
12 | "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
13 | },
14 | "devDependencies": {
15 | "@sveltejs/adapter-auto": "next",
16 | "@sveltejs/kit": "^1.0.1",
17 | "@types/react": "^18.0.26",
18 | "@types/react-dom": "^18.0.10",
19 | "@typescript-eslint/eslint-plugin": "^5.47.1",
20 | "@typescript-eslint/parser": "^5.47.1",
21 | "eslint": "^8.30.0",
22 | "eslint-config-prettier": "^8.5.0",
23 | "eslint-plugin-svelte3": "^4.0.0",
24 | "prettier": "^2.8.1",
25 | "prettier-plugin-svelte": "^2.9.0",
26 | "svelte": "^3.55.0",
27 | "svelte-check": "^2.10.3",
28 | "svelte-preprocess": "^4.10.7",
29 | "svelte2tsx": "^0.5.23",
30 | "ts-node": "^10.9.1",
31 | "tslib": "^2.4.1",
32 | "typescript": "^4.9.4",
33 | "vite": "^4.0.3",
34 | "vite-plugin-windicss": "^1.8.10",
35 | "windicss": "^3.5.6"
36 | },
37 | "dependencies": {
38 | "@sanity/client": "^3.4.1",
39 | "@sanity/groq-store": "^1.1.4",
40 | "@sanity/icons": "^1.3.10",
41 | "@sanity/image-url": "^1.0.1",
42 | "@sanity/ui": "^0.37.22",
43 | "@sanity/vision": "3.0.0-dev-preview.22",
44 | "@sanity/webhook": "^2.0.0",
45 | "groq": "^2.33.2",
46 | "react": "^18.2.0",
47 | "react-dom": "^18.2.0",
48 | "sanity": "^3.1.2",
49 | "sanity-plugin-asset-source-unsplash": "^1.0.1"
50 | },
51 | "type": "module"
52 | }
53 |
--------------------------------------------------------------------------------
/sanity.cli.ts:
--------------------------------------------------------------------------------
1 | import { createCliConfig } from 'sanity/cli';
2 |
3 | const projectId = import.meta.env.VITE_SANITY_PROJECT_ID;
4 | const dataset = import.meta.env.VITE_SANITY_DATASET;
5 |
6 | export default createCliConfig({ api: { projectId, dataset } });
7 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://kit.svelte.dev/docs/types#app
2 | // for information about these interfaces
3 | // and what to do when importing types
4 | declare namespace App {
5 | interface Locals {
6 | previewMode: boolean;
7 | }
8 | // interface PageData {}
9 | // interface Platform {}
10 | // interface PrivateEnv {}
11 | // interface PublicEnv {}
12 | }
13 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | %sveltekit.head%
8 |
9 |
10 | %sveltekit.body%
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/hooks.server.ts:
--------------------------------------------------------------------------------
1 | import { getPreviewCookie } from '$lib/utils';
2 | import type { Handle } from '@sveltejs/kit';
3 |
4 | export const handle: Handle = async ({ event, resolve }) => {
5 | const previewModeCookie = getPreviewCookie(event.cookies);
6 |
7 | event.locals.previewMode = false;
8 |
9 | if (previewModeCookie === 'true') {
10 | event.locals.previewMode = true;
11 | }
12 |
13 | const response = await resolve(event);
14 |
15 | return response;
16 | };
17 |
--------------------------------------------------------------------------------
/src/lib/components/Header.svelte:
--------------------------------------------------------------------------------
1 |
3 |
4 |
26 |
--------------------------------------------------------------------------------
/src/lib/components/PreviewBanner.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 | {#if !embedded}
10 |
11 |
12 |
13 |
14 | {#if $data?.profileImage}
15 |

16 | {/if}
17 |
18 |
19 | Hi {$data?.name ? $data.name : "there"}!
20 | This page is a draft.
21 |
22 | Exit?
23 |
24 |
25 |
26 |
27 |
36 |
37 |
38 |
39 | {/if}
40 |
--------------------------------------------------------------------------------
/src/lib/config/app.ts:
--------------------------------------------------------------------------------
1 | const app = {
2 | appName: 'Sanity Virtual Talk',
3 |
4 | };
5 |
6 | export { app as default };
7 |
--------------------------------------------------------------------------------
/src/lib/config/environment.ts:
--------------------------------------------------------------------------------
1 | export const isDev = import.meta.env.DEV;
2 | export const isProd = import.meta.env.PROD;
3 |
4 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/client.ts:
--------------------------------------------------------------------------------
1 | import sanityClient from '@sanity/client';
2 | import type { ClientConfig, SanityClient } from '@sanity/client';
3 | import { env } from '$env/dynamic/private';
4 | import { sanityConfig } from './config';
5 |
6 | const createClient = (config: ClientConfig): SanityClient => {
7 | return sanityClient(config);
8 | }
9 |
10 | export const previewClient = createClient({
11 | ...sanityConfig,
12 | useCdn: false,
13 | token: env.SANITY_API_READ_TOKEN || env.SANITY_API_WRITE_TOKEN || '',
14 | });
15 | export const client = createClient(sanityConfig);
16 | export const getSanityServerClient = (usePreview: boolean) => (usePreview ? previewClient : client);
17 |
18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 | export function overlayDrafts(docs: any[]): any[] {
20 | const documents = docs || [];
21 | const overlayed = documents.reduce((map, doc) => {
22 | if (!doc._id) {
23 | throw new Error('Ensure that `_id` is included in query projection');
24 | }
25 |
26 | const isDraft = doc._id.startsWith('drafts.');
27 | const id = isDraft ? doc._id.slice(7) : doc._id;
28 | return isDraft || !map.has(id) ? map.set(id, doc) : map;
29 | }, new Map());
30 |
31 | return Array.from(overlayed.values());
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/components/PostsPreview.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This component is responsible for rendering a preview of a post inside the Studio.
3 | * It's imported in `sanity.config.ts´ and used as a component in the defaultDocumentNode function.
4 | */
5 | import { Card, Text } from '@sanity/ui';
6 | import React from 'react';
7 |
8 | export function PostsPreview(props: unknown) {
9 | // @ts-expect-error - TODO: Fix this
10 | if (!props.document.displayed.slug) {
11 | return (
12 |
13 | Please add a slug to the post to see the preview!
14 |
15 | );
16 | }
17 |
18 | return (
19 |
20 | {/* @ts-expect-error - TODO: Fix this */}
21 |
22 |
23 | );
24 | }
25 |
26 | // @ts-expect-error - TODO: Fix this
27 | function getUrl({ document }) {
28 | const url = new URL('/api/preview', location.origin);
29 | const secret = import.meta.env.VITE_SANITY_PREVIEW_SECRET;
30 |
31 | if (secret) {
32 | url.searchParams.set('secret', secret);
33 | }
34 |
35 | url.searchParams.set('slug', document.displayed.slug.current);
36 | url.searchParams.set('type', document.displayed._type);
37 |
38 | // Needed to break the cache.
39 | url.searchParams.set('random', Math.random().toString(36).substring(7));
40 |
41 | return url.toString();
42 | }
43 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/config.ts:
--------------------------------------------------------------------------------
1 | import { isProd } from '$lib/config/environment';
2 |
3 | export const sanityConfig = {
4 | projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
5 | dataset: import.meta.env.VITE_SANITY_DATASET,
6 | useCdn: typeof document !== 'undefined' && isProd,
7 | // useCdn == true gives fast, cheap responses using a globally distributed cache.
8 | // When in production the Sanity API is only queried on build-time, and on-demand when responding to webhooks.
9 | // Thus the data need to be fresh and API response time is less important.
10 | // When in development/working locally, it's more important to keep costs down as hot reloading can incurr a lot of API calls
11 | // And every page load calls getStaticProps.
12 | // To get the lowest latency, lowest cost, and latest data, use the Instant Preview mode
13 | apiVersion: '2022-03-13',
14 | // see https://www.sanity.io/docs/api-versioning for how versioning works
15 | };
16 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/index.ts:
--------------------------------------------------------------------------------
1 | import createImageUrlBuilder from '@sanity/image-url';
2 | import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
3 | import { createPreviewSubscriptionStore } from './sveltekit/previewSubscriptionStore';
4 | import { sanityConfig } from './config';
5 | import { createCurrentUserStore } from './sveltekit/currentUser';
6 |
7 | export const previewSubscription = createPreviewSubscriptionStore(sanityConfig);
8 | export const imageBuilder = createImageUrlBuilder(sanityConfig);
9 |
10 | /**
11 | * Set up a helper function for generating Image URLs with only the asset reference data in your documents.
12 | * Read more: https://www.sanity.io/docs/image-url
13 | **/
14 | export const urlForImage = (source: SanityImageSource) => {
15 | return imageBuilder.image(source).auto('format').fit('max');
16 | };
17 |
18 | export const sanityUser = createCurrentUserStore(sanityConfig);
19 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/queries.ts:
--------------------------------------------------------------------------------
1 | import groq from 'groq';
2 |
3 | const postFields = groq`
4 | _id,
5 | name,
6 | title,
7 | date,
8 | postContent,
9 | coverImage,
10 | "slug": slug.current,
11 | "author": author->{name, picture},
12 | `;
13 |
14 | export const settingsQuery = groq`*[_type == "settings"][0]{title}`;
15 |
16 | export const postVisionQuery = groq`*[_type == "post"] | order(date desc, _updatedAt desc) {
17 | ...
18 | }`;
19 |
20 | export const indexQuery = groq`
21 | *[_type == "post"] | order(date desc, _updatedAt desc) {
22 | ${postFields}
23 | }`;
24 |
25 |
26 | // POSTS STUFF
27 | export const postQuery = groq`
28 | {
29 | "draft": *[_type == "post" && slug.current == $slug && defined(draft) && draft == true][0]{
30 | content,
31 | ${postFields}
32 | },
33 | "post": *[_type == "post" && slug.current == $slug] | order(_updatedAt desc) [0] {
34 | content,
35 | ${postFields}
36 | },
37 | "morePosts": *[_type == "post" && slug.current != $slug] | order(date desc, _updatedAt desc) [0...2] {
38 | content,
39 | ${postFields}
40 | }
41 | }`;
42 |
43 | export const allPostsQuery = groq`
44 | *[_type == "post"] | order(date desc, _updatedAt desc) {
45 | ${postFields}
46 | }`;
47 |
48 | export const postSlugsQuery = groq`
49 | *[_type == "post" && defined(slug.current)][].slug.current
50 | `;
51 |
52 | export const postBySlugQuery = groq`
53 | *[_type == "post" && slug.current == $slug][0] {
54 | ${postFields}
55 | }
56 | `;
57 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/sanity.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, type Slug } from 'sanity'
2 | import { PostsPreview } from './components/PostsPreview';
3 | import app from '../app';
4 |
5 | /*-------------- PLUGINS --------------*/
6 | import { visionTool } from '@sanity/vision';
7 | import { deskTool } from 'sanity/desk';
8 | import { unsplashImageAsset } from 'sanity-plugin-asset-source-unsplash';
9 | /*------------------------------------*/
10 |
11 | /*-------------- SCHEMAS --------------*/
12 | import authorType from '$lib/config/sanity/schemas/author';
13 | import postType from '$lib/config/sanity/schemas/post';
14 | /*------------------------------------*/
15 |
16 | export default defineConfig({
17 | basePath: '/studio',
18 | projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
19 | dataset: import.meta.env.VITE_SANITY_DATASET,
20 | title: app.appName + ' - Studio',
21 | schema: {
22 | // If you want more content types, you can add them to this array
23 | types: [ postType, authorType]
24 | },
25 | plugins: [
26 | deskTool({
27 | // `defaultDocumentNode is responsible for adding a “Preview” tab to the document pane
28 | // You can add any React component to `S.view.component` and it will be rendered in the pane
29 | // and have access to content in the form in real-time.
30 | // It's part of the Studio's “Structure Builder API” and is documented here:
31 | // https://www.sanity.io/docs/structure-builder-reference
32 | defaultDocumentNode: (S, { schemaType }) => {
33 | if (schemaType === 'post') {
34 | return S.document().views([S.view.form(), S.view.component(PostsPreview).title('Preview')]);
35 | }
36 |
37 | return null;
38 | },
39 | }),
40 | // Add an image asset source for Unsplash
41 | unsplashImageAsset(),
42 | // Vision lets you query your content with GROQ in the studio
43 | // https://www.sanity.io/docs/the-vision-plugin
44 | visionTool({
45 | defaultApiVersion: '2022-08-08',
46 | }),
47 | ],
48 | document: {
49 | productionUrl: async (prev, { document }) => {
50 | const url = new URL('/api/preview', location.origin);
51 | const secret = import.meta.env.VITE_SANITY_PREVIEW_SECRET;
52 | if (secret) {
53 | url.searchParams.set('secret', secret);
54 | }
55 |
56 | try {
57 | switch (document._type) {
58 | case postType.name:
59 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
60 | url.searchParams.set('slug', (document.slug as Slug).current!);
61 | break;
62 | default:
63 | return prev;
64 | }
65 | return url.toString();
66 | } catch {
67 | return prev;
68 | }
69 | },
70 | },
71 | })
72 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/schemas/author.ts:
--------------------------------------------------------------------------------
1 | import { UserIcon } from '@sanity/icons';
2 | import { defineType } from 'sanity';
3 |
4 | export default defineType({
5 | name: 'author',
6 | title: 'Author',
7 | icon: UserIcon,
8 | type: 'document',
9 | fields: [
10 | {
11 | name: 'name',
12 | title: 'Name',
13 | type: 'string',
14 | validation: (Rule) => Rule.required(),
15 | },
16 | {
17 | name: 'picture',
18 | title: 'Picture',
19 | type: 'image',
20 | options: { hotspot: true },
21 | validation: (Rule) => Rule.required(),
22 | },
23 | ],
24 | });
25 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/schemas/post.ts:
--------------------------------------------------------------------------------
1 | import { BookIcon } from '@sanity/icons';
2 | import { defineType } from 'sanity';
3 |
4 | import authorType from './author';
5 |
6 | /**
7 | * This file is the schema definition for a post.
8 | *
9 | * Here you'll be able to edit the different fields that appear when you
10 | * create or edit a post in the studio.
11 | *
12 | * Here you can see the different schema types that are available:
13 |
14 | https://www.sanity.io/docs/schema-types
15 |
16 | */
17 |
18 | export default defineType({
19 | name: 'post',
20 | title: 'Post',
21 | icon: BookIcon,
22 | type: 'document',
23 | fields: [
24 | {
25 | name: 'title',
26 | title: 'Title',
27 | type: 'string',
28 | validation: (Rule) => Rule.required(),
29 | },
30 | {
31 | name: 'slug',
32 | title: 'Slug',
33 | type: 'slug',
34 | options: {
35 | source: 'title',
36 | maxLength: 96,
37 | },
38 | validation: (Rule) => Rule.required(),
39 | },
40 | {
41 | name: 'postContent',
42 | title: 'Content',
43 | type: 'text',
44 | },
45 | {
46 | name: 'coverImage',
47 | title: 'Cover Image',
48 | type: 'image',
49 | options: {
50 | hotspot: true,
51 | },
52 | },
53 | {
54 | name: 'date',
55 | title: 'Date',
56 | type: 'datetime',
57 | },
58 | {
59 | name: 'author',
60 | title: 'Author',
61 | type: 'reference',
62 | to: [{ type: authorType.name }],
63 | },
64 | ],
65 | preview: {
66 | select: {
67 | title: 'title',
68 | author: 'author.name',
69 | media: 'coverImage',
70 | },
71 | prepare(selection) {
72 | const { author } = selection;
73 | return { ...selection, subtitle: author && `by ${author}` };
74 | },
75 | },
76 | });
77 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/schemas/settings.ts:
--------------------------------------------------------------------------------
1 | import { CogIcon } from '@sanity/icons';
2 | import { defineType } from 'sanity';
3 |
4 | export default defineType({
5 | name: 'settings',
6 | title: 'Settings',
7 | type: 'document',
8 | icon: CogIcon,
9 | fields: [
10 | {
11 | name: 'title',
12 | description: 'This field is the title of your blog.',
13 | title: 'Title',
14 | type: 'string',
15 | initialValue: 'Blog.',
16 | validation: (rule) => rule.required(),
17 | },
18 | ],
19 | });
20 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/sveltekit/aborter.ts:
--------------------------------------------------------------------------------
1 | export interface Aborter {
2 | abort(): void;
3 | signal: AbortSignal;
4 | }
5 |
6 | class MockAbortController {
7 | _signal = { aborted: false };
8 | get signal() {
9 | return this._signal as AbortSignal;
10 | }
11 | abort() {
12 | this._signal.aborted = true;
13 | }
14 | }
15 |
16 | export function getAborter(): Aborter {
17 | return typeof AbortController === 'undefined' ? new MockAbortController() : new AbortController();
18 | }
19 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/sveltekit/currentUser.ts:
--------------------------------------------------------------------------------
1 | import type { CurrentUser } from './types';
2 | import { getAborter, type Aborter } from './aborter';
3 | import { derived, writable } from 'svelte/store';
4 | import { onMount } from 'svelte';
5 |
6 | export function createCurrentUserStore({ projectId }: { projectId: string; dataset?: string }) {
7 | return () => currentSanityUserStore(projectId);
8 | }
9 |
10 | export async function getCurrentUser(projectId: string, abort: Aborter, token?: string): Promise {
11 | const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
12 | return fetch(`https://${projectId}.api.sanity.io/v1/users/me`, {
13 | credentials: 'include',
14 | signal: abort.signal,
15 | headers,
16 | })
17 | .then((res) => res.json())
18 | .then((res) => (res?.id ? res : null));
19 | }
20 |
21 | function currentSanityUserStore(projectId: string) {
22 | const data = writable(null);
23 | const error = writable();
24 |
25 | onMount(() => {
26 | const aborter = getAborter();
27 | getCurrentUser(projectId, aborter)
28 | .then(data.set)
29 | .catch((err: Error) => err.name !== 'AbortError' && error.set(err));
30 |
31 | return () => {
32 | aborter.abort();
33 | };
34 | });
35 |
36 | const loading = derived([data, error], ([$data, $error]) => !$data && !$error);
37 |
38 | return { data, error, loading };
39 | }
40 |
41 | // function useCurrentUser(projectId: string) {
42 | // const [data, setUser] = useState()
43 | // const [error, setError] = useState()
44 |
45 | // useEffect(() => {
46 | // const aborter = getAborter()
47 | // getCurrentUser(projectId, aborter)
48 | // .then(setUser)
49 | // .catch((err: Error) => err.name !== 'AbortError' && setError(err))
50 |
51 | // return () => {
52 | // aborter.abort()
53 | // }
54 | // }, [projectId])
55 |
56 | // return {data, error, loading: data !== null || !error}
57 | // }
58 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/sveltekit/previewSubscriptionStore.ts:
--------------------------------------------------------------------------------
1 | import type { GroqStore, Subscription } from '@sanity/groq-store';
2 | import { type Aborter, getAborter } from './aborter';
3 | import type { Params, ProjectConfig, SubscriptionOptions } from './types';
4 | import { get, writable } from 'svelte/store';
5 | import { getCurrentUser } from './currentUser';
6 | import { onMount } from 'svelte';
7 |
8 | const EMPTY_PARAMS = {};
9 |
10 | export function createPreviewSubscriptionStore({
11 | projectId,
12 | dataset,
13 | token,
14 | EventSource,
15 | documentLimit = 3000,
16 | }: ProjectConfig & { documentLimit?: number }) {
17 | // Only construct/setup the store when `getStore()` is called
18 | let store: Promise;
19 |
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | return function previewSubscriptionStore(query: string, options: SubscriptionOptions = {}) {
22 | const { params = EMPTY_PARAMS, initialData, enabled } = options;
23 |
24 | return querySubscription({
25 | getStore,
26 | projectId,
27 | query,
28 | params,
29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
30 | initialData: initialData as any,
31 | enabled: enabled ? typeof window !== 'undefined' : false,
32 | token,
33 | });
34 | };
35 |
36 | function getStore(abort: Aborter) {
37 | if (!store) {
38 | store = import('@sanity/groq-store').then(({ groqStore }) => {
39 | // Skip creating the groq store if we've been unmounted to save memory and reduce gc pressure
40 | if (abort.signal.aborted) {
41 | const error = new Error('Cancelling groq store creation');
42 | // This ensures we can skip it in the catch block same way
43 | error.name = 'AbortError';
44 | return Promise.reject(error);
45 | }
46 |
47 | return groqStore({
48 | projectId,
49 | dataset,
50 | documentLimit,
51 | token,
52 | EventSource,
53 | listen: true,
54 | overlayDrafts: true,
55 | subscriptionThrottleMs: 10,
56 | });
57 | });
58 | }
59 | return store;
60 | }
61 | }
62 |
63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
64 | function querySubscription(options: {
65 | getStore: (abort: Aborter) => Promise;
66 | projectId: string;
67 | query: string;
68 | params: Params;
69 | initialData: R;
70 | enabled: boolean;
71 | token?: string;
72 | }) {
73 | const { getStore, projectId, query, initialData, enabled = false, token } = options;
74 | const error = writable();
75 | const loading = writable(false);
76 | const data = writable();
77 | const params = writable(options.params);
78 |
79 | onMount(() => {
80 | if (!enabled) {
81 | return;
82 | }
83 |
84 | loading.set(true);
85 |
86 | const aborter = getAborter();
87 | let subscription: Subscription | undefined;
88 |
89 | getCurrentUser(projectId, aborter, token)
90 | .then((user) => {
91 | if (user) {
92 | return;
93 | }
94 |
95 | // eslint-disable-next-line no-console
96 | console.warn('Not authenticated - preview not available');
97 | throw new Error('Not authenticated - preview not available');
98 | })
99 | .then(() => getStore(aborter))
100 | .then((store) => {
101 | subscription = store.subscribe(query, get(params), (err, result) => {
102 | if (err) {
103 | error.set(err);
104 | } else {
105 | data.set(result);
106 | }
107 | });
108 | })
109 | .catch((err: Error) => (err.name === 'AbortError' ? null : error.set(err)))
110 | .finally(() => loading.set(false));
111 |
112 | return () => {
113 | if (subscription) {
114 | subscription.unsubscribe();
115 | }
116 |
117 | aborter.abort();
118 | };
119 | });
120 |
121 | return {
122 | data: typeof get(data) === 'undefined' ? writable(initialData) : data,
123 | error,
124 | loading,
125 | };
126 | }
127 |
--------------------------------------------------------------------------------
/src/lib/config/sanity/sveltekit/types.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@sanity/groq-store/dist/typings/types';
2 |
3 | export type GroqStoreEventSource = Config['EventSource'];
4 |
5 | export interface ProjectConfig {
6 | projectId: string;
7 | dataset: string;
8 | token?: string;
9 | /** Must be provided when token is used in browser, as native EventSource does not support auth-headers. */
10 | EventSource?: GroqStoreEventSource;
11 | }
12 |
13 | export interface CurrentUser {
14 | id: string;
15 | name: string;
16 | profileImage?: string;
17 | }
18 |
19 | export type Params = Record;
20 |
21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
22 | export interface SubscriptionOptions {
23 | enabled?: boolean;
24 | params?: Params;
25 | initialData?: R;
26 | }
27 |
--------------------------------------------------------------------------------
/src/lib/types/index.ts:
--------------------------------------------------------------------------------
1 | import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
2 |
3 | export interface Post {
4 | _id: string;
5 | name: string;
6 | title: string;
7 | date: string;
8 | postContent: string;
9 | coverImage: SanityImageSource;
10 | slug: string
11 | author: {
12 | name: string;
13 | picture: SanityImageSource;
14 | }
15 | }
--------------------------------------------------------------------------------
/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './preview-cookies';
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/lib/utils/preview-cookies.ts:
--------------------------------------------------------------------------------
1 | import { isDev } from '$lib/config/environment';
2 | import type { Cookies } from '@sveltejs/kit';
3 |
4 | const cookieName = '__preview_mode';
5 |
6 | /**
7 | * Preview mode cookie name.
8 | *
9 | * @param cookies The cookies object from the request
10 | * @returns The cookies object with the preview cookie set
11 | */
12 | export const setPreviewCookie = (cookies: Cookies) =>
13 | cookies.set(cookieName, 'true', {
14 | httpOnly: true,
15 | path: '/',
16 | sameSite: 'strict',
17 | secure: !isDev,
18 | });
19 |
20 | /**
21 | * Get the preview mode cookie value.
22 | *
23 | * @param cookies The cookies object from the request
24 | * @returns The preview mode cookie value
25 | */
26 | export const getPreviewCookie = (cookies: Cookies) => cookies.get(cookieName);
27 |
28 | /**
29 | * Remove the preview mode cookie.
30 | *
31 | * @param cookies The cookies object from the request
32 | * @returns The cookies object with the preview cookie removed
33 | */
34 | export const clearPreviewCookie = (cookies: Cookies) => {
35 | cookies.set(cookieName, 'true', {
36 | httpOnly: true,
37 | path: '/',
38 | sameSite: 'strict',
39 | secure: !isDev,
40 | expires: new Date(0),
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/src/lib/utils/sanity-studio.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
37 |
--------------------------------------------------------------------------------
/src/routes/(app)/+layout.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/routes/(app)/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { getSanityServerClient, overlayDrafts } from '$lib/config/sanity/client';
2 | import { allPostsQuery } from '$lib/config/sanity/queries';
3 | import { error } from '@sveltejs/kit';
4 | import type { PageServerLoad } from './$types';
5 |
6 | // export const prerender = 'auto';
7 | export const load: PageServerLoad = async ({ parent, params }) => {
8 | const posts = await getSanityServerClient(false).fetch(allPostsQuery);
9 |
10 | if (!posts) {
11 | throw error(500, 'Posts not found');
12 | }
13 |
14 | return {
15 | posts
16 | };
17 | };
18 |
--------------------------------------------------------------------------------
/src/routes/(app)/+page.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 | Sanity Virtual Meetup
12 |
13 |
14 |
15 |
16 |
17 |
30 |
Sanity Virtual Meetup
31 |
32 |
33 | Welcome to the virtual meetup for the Sanity community. This is my talk on how to build a SvelteKit app with
34 | Sanity V3.
35 |
36 |
37 |
38 |
39 | {#if posts && posts.length > 0}
40 |
93 | {:else}
94 |
95 |
96 | No posts found
97 |
98 |
99 | {/if}
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/src/routes/(app)/posts/[slug]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { getSanityServerClient, overlayDrafts } from '$lib/config/sanity/client';
2 | import { postQuery } from '$lib/config/sanity/queries';
3 | import type { Post } from '$lib/types';
4 | import { error } from '@sveltejs/kit';
5 | import type { PageServerLoad } from './$types';
6 |
7 | export const load: PageServerLoad = async ({ parent, params }) => {
8 | const { previewMode } = await parent();
9 |
10 | const { post, morePosts } = await getSanityServerClient(previewMode).fetch<{
11 | post: Post;
12 | morePosts: Post[];
13 | }>(postQuery, {
14 | slug: params.slug,
15 | });
16 |
17 | if (!post) {
18 | throw error(404, 'Post not found');
19 | }
20 |
21 | return {
22 | previewMode,
23 | slug: post?.slug || params.slug,
24 | initialData: {
25 | post,
26 | morePosts: overlayDrafts(morePosts),
27 | },
28 | };
29 | };
30 |
--------------------------------------------------------------------------------
/src/routes/(app)/posts/[slug]/+page.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 | {$postData?.post?.title || "Post"}
19 |
20 |
21 | {#if $postData?.post}
22 |
23 |
24 |
25 |
28 |
29 |
30 |
{$postData.post.title}
31 |
32 | {#if $postData.post.author}
33 |
34 |
Image
35 |
.crop("focalpoint").width(256).height(256).url()})
40 |
41 | {/if}
42 |
43 |
{$postData.post.author.name}
44 |
45 |
48 |
49 |
50 |
51 |
52 | {#if $postData.post.coverImage}
53 |
.crop("focalpoint").width(1344).height(736).url()})
58 | {/if}
59 |
60 | {$postData.post.postContent}
61 |
62 |
63 |
64 |
65 | {/if}
66 |
--------------------------------------------------------------------------------
/src/routes/(studio)/studio/[...catchall]/+page.server.ts:
--------------------------------------------------------------------------------
1 | export const ssr = false;
--------------------------------------------------------------------------------
/src/routes/(studio)/studio/[...catchall]/+page@(studio).svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/routes/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import type { LayoutServerLoad } from './$types';
2 |
3 | export const load: LayoutServerLoad = async ({ locals: { previewMode }, url }) => {
4 | const isPreview = previewMode && url.searchParams.get('isPreview') === 'true';
5 |
6 | return {
7 | previewModeEmbed: isPreview,
8 | previewMode,
9 | };
10 | };
11 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 | {#if showPreviewBanner}
19 |
20 | {/if}
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/routes/api/exit-preview/+server.ts:
--------------------------------------------------------------------------------
1 | import type { RequestHandler } from './$types';
2 | import { clearPreviewCookie } from '$lib/utils';
3 | import { redirect } from '@sveltejs/kit';
4 |
5 | export const GET: RequestHandler = async ({ request, cookies, url }) => {
6 | const referer = request.headers.get('referer');
7 |
8 | clearPreviewCookie(cookies);
9 |
10 | throw redirect(302, referer || url.origin || '/');
11 | };
12 |
--------------------------------------------------------------------------------
/src/routes/api/preview/+server.ts:
--------------------------------------------------------------------------------
1 | import type { RequestHandler } from './$types';
2 | import { env } from '$env/dynamic/private';
3 | import { error, redirect } from '@sveltejs/kit';
4 | import { getSanityServerClient } from '$lib/config/sanity/client';
5 | import { postBySlugQuery } from '$lib/config/sanity/queries';
6 | import { setPreviewCookie } from '$lib/utils';
7 |
8 | export const GET: RequestHandler = async ({ url, cookies, setHeaders }) => {
9 | const allParams = url.searchParams;
10 | const secret = env.VITE_SANITY_PREVIEW_SECRET;
11 | const incomingSecret = allParams.get('secret');
12 | const type = allParams.get('type');
13 | const slug = allParams.get('slug');
14 |
15 | // Check the secret.
16 | if (secret !== incomingSecret) {
17 | throw error(401, 'Invalid secret');
18 | }
19 |
20 | // Check if we have a type and slug parameter.
21 | if(!slug || !type) {
22 | throw error(401, 'Missing slug or type');
23 | }
24 |
25 | // Default redirect.
26 | let redirectSlug = '/';
27 | let isPreviewing = false;
28 |
29 | // Our query may vary depending on the type.
30 | if (type === 'post') {
31 | const post = await getSanityServerClient(true).fetch(postBySlugQuery, {
32 | slug,
33 | });
34 |
35 | if (!post || !post.slug) {
36 | throw error(401, 'No post found');
37 | }
38 |
39 | isPreviewing = true;
40 |
41 | // Set the redirect slug and append the isPreview query
42 | // param, so that the app knows it's a Sanity preview.
43 | redirectSlug = `/posts/${post.slug}?isPreview=true`;
44 | }
45 |
46 | // Set the preview cookie.
47 | if(isPreviewing) {
48 | setPreviewCookie(cookies);
49 | }
50 |
51 | // Since this endpoint is called from the Sanity Studio on
52 | // every content change, we'll make sure not to cache it.
53 | setHeaders({
54 | 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
55 | });
56 |
57 | // We don't redirect to url.searchParams.get("slug") as that exposes us to open redirect vulnerabilities,
58 | throw redirect(302, redirectSlug);
59 | };
60 |
--------------------------------------------------------------------------------
/static/avatar.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/multiplehats/sveltekit-sanity-v3/e8b72a2542fdcf5172d7231cf5e86d099cea68a3/static/avatar.jpg
--------------------------------------------------------------------------------
/static/dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/multiplehats/sveltekit-sanity-v3/e8b72a2542fdcf5172d7231cf5e86d099cea68a3/static/favicon.png
--------------------------------------------------------------------------------
/static/light.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 | import preprocess from 'svelte-preprocess';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://github.com/sveltejs/svelte-preprocess
7 | // for more information about preprocessors
8 | preprocess: preprocess(),
9 |
10 | kit: {
11 | adapter: adapter(),
12 | },
13 | };
14 |
15 | export default config;
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "jsx": "react",
11 | "sourceMap": true,
12 | "strict": true
13 | },
14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
15 | //
16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
17 | // from the referenced tsconfig.json - TypeScript does not merge them in
18 | "include": [
19 | "./.svelte-kit/ambient.d.ts",
20 | "./.svelte-kit/types/**/$types.d.ts",
21 | "./vite.config.ts",
22 | "./src/**/*.js",
23 | "./src/**/*.ts",
24 | "./src/**/*.svelte",
25 | "./src/**/*.js",
26 | "./src/**/*.ts",
27 | "./src/**/*.tsx",
28 | "./src/**/*.svelte",
29 | "./tests/**/*.js",
30 | "./tests/**/*.ts",
31 | "./tests/**/*.svelte"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import WindiCSS from 'vite-plugin-windicss';
3 |
4 | /** @type {import('vite').UserConfig} */
5 | const config = {
6 | plugins: [sveltekit(), WindiCSS()],
7 | optimizeDeps: {
8 | include: ['sanity']
9 | }
10 | };
11 |
12 | export default config;
13 |
--------------------------------------------------------------------------------
/windi.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'windicss/helpers';
2 | import typography from 'windicss/plugin/typography';
3 | import aspectRatio from 'windicss/plugin/aspect-ratio';
4 |
5 | export default defineConfig({
6 | darkMode: 'class',
7 | extract: {
8 | include: ['src/**/*.{html,svx,svelte}'],
9 | },
10 | plugins: [
11 | typography({
12 | dark: true,
13 | }),
14 | aspectRatio,
15 | ],
16 | theme: {
17 | container: {
18 | center: true,
19 | padding: {
20 | DEFAULT: '1rem',
21 | sm: '2rem',
22 | lg: '4rem',
23 | xl: '5rem',
24 | '2xl': '6rem',
25 | },
26 | },
27 | extend: {
28 | colors: {
29 | discord: '#5865F2',
30 | dark: {
31 | 50: 'hsl(240, 4%, 29%)',
32 | 100: 'hsl(240, 7%, 24%)',
33 | 200: 'hsl(240, 10%, 20%)',
34 | 300: 'hsl(240, 13%, 18%)',
35 | 400: 'hsl(240, 13%, 13%)',
36 | 500: 'hsl(240, 15%, 12%)',
37 | 600: 'hsl(240, 18%, 11%)',
38 | 700: 'hsl(240, 21%, 11%)',
39 | 800: 'hsl(240, 24%, 9%)',
40 | 900: 'hsl(240, 27%, 6%)',
41 | },
42 | gray: {
43 | 50: 'hsl(240, 6%, 98%)',
44 | 100: 'hsl(240, 6%, 95%)',
45 | 200: 'hsl(240, 6%, 87%)',
46 | 300: 'hsl(240, 6%, 78%)',
47 | 400: 'hsl(240, 6%, 65%)',
48 | 500: 'hsl(240, 6%, 55%)',
49 | 600: 'hsl(240, 7%, 45%)',
50 | 700: 'hsl(240, 8%, 35%)',
51 | 800: 'hsl(240, 11%, 22%)',
52 | 900: 'hsl(240, 18%, 15%)',
53 | },
54 | primary: {
55 | 50: 'hsl(240, 70%, 95%)',
56 | 100: 'hsl(240, 70%, 85%)',
57 | 200: 'hsl(240, 70%, 75%)',
58 | 300: 'hsl(240, 70%, 65%)',
59 | 400: 'hsl(240, 60%, 52%)',
60 | 500: 'hsl(240, 55%, 43%)',
61 | 600: 'hsl(240, 50%, 32%)',
62 | 700: 'hsl(240, 45%, 28%)',
63 | 800: 'hsl(240, 40%, 16%)',
64 | 900: 'hsl(240, 43%, 9%)',
65 | },
66 | },
67 | },
68 | },
69 | });
70 |
--------------------------------------------------------------------------------