├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ ├── lock.yml │ ├── ci.yml │ └── prettier.yml ├── public ├── Inter-Bold.woff ├── break_iterator.wasm ├── favicon │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── browserconfig.xml │ └── site.webmanifest ├── sanity.svg ├── next.svg └── og.svg ├── intro-template ├── cover.png └── index.tsx ├── components ├── SectionSeparator.tsx ├── BlogContainer.tsx ├── BlogHeader.module.css ├── PostTitle.tsx ├── PostDate.tsx ├── BlogLayout.tsx ├── PreviewProvider.tsx ├── PreviewIndexPage.tsx ├── AuthorAvatar.tsx ├── PostBody.tsx ├── MoreStories.tsx ├── PreviewPostPage.tsx ├── SanityImage.tsx ├── PostPageHead.tsx ├── AlertBanner.tsx ├── PostHeader.tsx ├── PostPreview.tsx ├── BlogMeta.tsx ├── CoverImage.tsx ├── IndexPageHead.tsx ├── BlogHeader.tsx ├── HeroPost.tsx ├── IndexPage.tsx ├── PostPage.tsx └── OpenGraphImage.tsx ├── postcss.config.js ├── pages ├── api │ ├── preview-mode │ │ ├── disable.ts │ │ └── enable.ts │ ├── og.tsx │ └── revalidate.ts ├── _document.tsx ├── _app.tsx ├── index.tsx ├── posts │ └── [slug].tsx └── Sitemap.xml.tsx ├── .eslintrc.json ├── app ├── layout.tsx └── studio │ └── [[...index]] │ └── page.tsx ├── lib ├── sanity.image.ts ├── sanity.api.ts ├── demo.data.ts ├── sanity.queries.ts └── sanity.client.ts ├── netlify.toml ├── next-env.d.ts ├── plugins ├── previewPane │ ├── AuthorAvatarPreviewPane.tsx │ └── index.tsx ├── locate.ts └── settings.ts ├── sanity.cli.ts ├── next.config.ts ├── .gitignore ├── tsconfig.json ├── schemas ├── author.ts ├── settings │ ├── OpenGraphInput.tsx │ ├── OpenGraphPreview.tsx │ └── index.ts └── post.ts ├── .env.local.example ├── tailwind.css ├── package.json ├── sanity.config.ts └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sanity-io/ecosystem 2 | -------------------------------------------------------------------------------- /public/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/Inter-Bold.woff -------------------------------------------------------------------------------- /intro-template/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/intro-template/cover.png -------------------------------------------------------------------------------- /public/break_iterator.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/break_iterator.wasm -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /components/SectionSeparator.tsx: -------------------------------------------------------------------------------- 1 | export default function SectionSeparator() { 2 | return
3 | } 4 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/nextjs-blog-cms-sanity-v3/HEAD/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /components/BlogContainer.tsx: -------------------------------------------------------------------------------- 1 | export default function BlogContainer({ children }) { 2 | return
{children}
3 | } 4 | -------------------------------------------------------------------------------- /components/BlogHeader.module.css: -------------------------------------------------------------------------------- 1 | .portableText a { 2 | @apply underline transition-colors duration-200; 3 | &:hover { 4 | color: var(--color-success); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | module.exports = { 4 | plugins: { 5 | '@tailwindcss/postcss': {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /pages/api/preview-mode/disable.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 4 | res.clearPreviewData() 5 | res.redirect('/') 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next", 3 | "plugins": ["simple-import-sort"], 4 | "rules": { 5 | "simple-import-sort/imports": "warn", 6 | "simple-import-sort/exports": "warn", 7 | "react-hooks/exhaustive-deps": "error" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Check the readme: https://github.com/sanity-io/renovate-config#readme", 3 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 4 | "extends": ["github>sanity-io/renovate-config:starter-template"] 5 | } 6 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '../tailwind.css' 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 | 10 | {children} 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/PostTitle.tsx: -------------------------------------------------------------------------------- 1 | export default function PostTitle({ children }) { 2 | return ( 3 |

4 | {children} 5 |

6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /components/PostDate.tsx: -------------------------------------------------------------------------------- 1 | import { format, parseISO } from 'date-fns' 2 | 3 | export default function PostDate({ dateString }: { dateString: string }) { 4 | if (!dateString) return null 5 | 6 | const date = parseISO(dateString) 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /lib/sanity.image.ts: -------------------------------------------------------------------------------- 1 | import createImageUrlBuilder from '@sanity/image-url' 2 | import { dataset, projectId } from 'lib/sanity.api' 3 | 4 | const imageBuilder = createImageUrlBuilder({ projectId, dataset }) 5 | 6 | export const urlForImage = (source: any) => 7 | imageBuilder.image(source).auto('format').fit('max') 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [template] 2 | incoming-hooks = ["Sanity"] 3 | 4 | [template.environment] 5 | NEXT_PUBLIC_SANITY_PROJECT_ID="Your Sanity Project Id" 6 | NEXT_PUBLIC_SANITY_DATASET="Your Sanity Dataset" 7 | SANITY_API_WRITE_TOKEN="Your Sanity API Write Token" 8 | SANITY_API_READ_TOKEN="Your Sanity API Read Token" -------------------------------------------------------------------------------- /public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #000000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | 6 | // NOTE: This file should not be edited 7 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 8 | -------------------------------------------------------------------------------- /components/BlogLayout.tsx: -------------------------------------------------------------------------------- 1 | import AlertBanner from 'components/AlertBanner' 2 | 3 | export default function BlogLayout({ 4 | preview, 5 | loading, 6 | children, 7 | }: { 8 | preview: boolean 9 | loading?: boolean 10 | children: React.ReactNode 11 | }) { 12 | return ( 13 |
14 | 15 |
{children}
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Next.js", 3 | "short_name": "Next.js", 4 | "icons": [ 5 | { 6 | "src": "/favicon/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/favicon/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#000000", 17 | "background_color": "#000000", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /plugins/previewPane/AuthorAvatarPreviewPane.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Flex } from '@sanity/ui' 2 | import AuthorAvatar from 'components/AuthorAvatar' 3 | import type { Author } from 'lib/sanity.queries' 4 | 5 | export default function AuthorAvatarPreviewPane(props: Author) { 6 | const { name, picture } = props 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lock Threads' 3 | 4 | on: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | action: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5 22 | with: 23 | issue-inactive-days: 0 24 | pr-inactive-days: 0 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v5 18 | - uses: actions/setup-node@v5 19 | with: 20 | node-version: lts/* 21 | - run: npm ci 22 | - run: npm run type-check 23 | - run: npm run lint -- --max-warnings 0 24 | -------------------------------------------------------------------------------- /sanity.cli.ts: -------------------------------------------------------------------------------- 1 | import { loadEnvConfig } from '@next/env' 2 | import { defineCliConfig } from 'sanity/cli' 3 | 4 | const dev = process.env.NODE_ENV !== 'production' 5 | loadEnvConfig(__dirname, dev, { info: () => null, error: console.error }) 6 | 7 | // @TODO report top-level await bug 8 | // Using a dynamic import here as `loadEnvConfig` needs to run before this file is loaded 9 | // const { projectId, dataset } = await import('lib/sanity.api') 10 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 11 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET 12 | 13 | export default defineCliConfig({ api: { projectId, dataset } }) 14 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next' 2 | 3 | const config: NextConfig = { 4 | reactStrictMode: true, 5 | images: { 6 | remotePatterns: [ 7 | { hostname: 'cdn.sanity.io' }, 8 | { hostname: 'picsum.photos' }, 9 | ], 10 | }, 11 | typescript: { 12 | // Set this to false if you want production builds to abort if there's type errors 13 | ignoreBuildErrors: process.env.VERCEL_ENV === 'production', 14 | }, 15 | eslint: { 16 | /// Set this to false if you want production builds to abort if there's lint errors 17 | ignoreDuringBuilds: process.env.VERCEL_ENV === 'production', 18 | }, 19 | } 20 | 21 | export default config 22 | -------------------------------------------------------------------------------- /app/studio/[[...index]]/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This route is responsible for the built-in authoring environment using Sanity Studio v3. 3 | * All routes under /studio will be handled by this file using Next.js' catch-all routes: 4 | * https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes 5 | * 6 | * You can learn more about the next-sanity package here: 7 | * https://github.com/sanity-io/next-sanity 8 | */ 9 | 10 | import { NextStudio } from 'next-sanity/studio' 11 | import config from 'sanity.config' 12 | 13 | export const dynamic = 'force-static' 14 | 15 | export { metadata, viewport } from 'next-sanity/studio' 16 | 17 | export default function StudioPage() { 18 | return 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /studio/node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | /studio/dist 19 | /public/studio/static 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | .vscode 25 | *.iml 26 | .idea 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # local env files 35 | .env*.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | 43 | # Env files created by scripts for working locally 44 | .env 45 | studio/.env.development 46 | -------------------------------------------------------------------------------- /components/PreviewProvider.tsx: -------------------------------------------------------------------------------- 1 | import { LiveQueryProvider } from '@sanity/preview-kit' 2 | import { getClient } from 'lib/sanity.client' 3 | import { useState } from 'react' 4 | 5 | export default function PreviewProvider({ 6 | children, 7 | perspective, 8 | token, 9 | }: { 10 | children: React.ReactNode 11 | perspective: string | null 12 | token: string 13 | }) { 14 | const [client] = useState(() => getClient().withConfig({ stega: true })) 15 | return ( 16 | 23 | {children} 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components/PreviewIndexPage.tsx: -------------------------------------------------------------------------------- 1 | import { useLiveQuery } from '@sanity/preview-kit' 2 | import IndexPage, { type IndexPageProps } from 'components/IndexPage' 3 | import { 4 | indexQuery, 5 | type Post, 6 | type Settings, 7 | settingsQuery, 8 | } from 'lib/sanity.queries' 9 | 10 | export default function PreviewIndexPage(props: IndexPageProps) { 11 | const [posts, loadingPosts] = useLiveQuery(props.posts, indexQuery) 12 | const [settings, loadingSettings] = useLiveQuery( 13 | props.settings, 14 | settingsQuery, 15 | ) 16 | 17 | return ( 18 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ES2017", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "incremental": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "strictNullChecks": false 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /components/AuthorAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { urlForImage } from 'lib/sanity.image' 2 | import type { Author } from 'lib/sanity.queries' 3 | import Image from 'next/image' 4 | 5 | export default function AuthorAvatar(props: Author) { 6 | const { name, picture } = props 7 | return ( 8 |
9 |
10 | {picture?.alt 21 |
22 |
{name}
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /schemas/author.ts: -------------------------------------------------------------------------------- 1 | import { UserIcon } from '@sanity/icons' 2 | import { defineField, defineType } from 'sanity' 3 | 4 | export default defineType({ 5 | name: 'author', 6 | title: 'Author', 7 | icon: UserIcon, 8 | type: 'document', 9 | fields: [ 10 | defineField({ 11 | name: 'name', 12 | title: 'Name', 13 | type: 'string', 14 | validation: (rule) => rule.required(), 15 | }), 16 | defineField({ 17 | name: 'picture', 18 | title: 'Picture', 19 | type: 'image', 20 | fields: [ 21 | { 22 | name: 'alt', 23 | type: 'string', 24 | title: 'Alternative text', 25 | description: 'Important for SEO and accessiblity.', 26 | }, 27 | ], 28 | options: { hotspot: true }, 29 | validation: (rule) => rule.required(), 30 | }), 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /components/PostBody.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This component uses Portable Text to render a post body. 3 | * 4 | * You can learn more about Portable Text on: 5 | * https://www.sanity.io/docs/block-content 6 | * https://github.com/portabletext/react-portabletext 7 | * https://portabletext.org/ 8 | * 9 | */ 10 | import { PortableText, type PortableTextReactComponents } from 'next-sanity' 11 | 12 | import { SanityImage } from './SanityImage' 13 | 14 | const myPortableTextComponents: Partial = { 15 | types: { 16 | image: ({ value }) => { 17 | return 18 | }, 19 | }, 20 | } 21 | 22 | export default function PostBody({ content }) { 23 | return ( 24 |
25 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/MoreStories.tsx: -------------------------------------------------------------------------------- 1 | import PostPreview from 'components/PostPreview' 2 | import type { Post } from 'lib/sanity.queries' 3 | 4 | export default function MoreStories({ posts }: { posts: Post[] }) { 5 | return ( 6 |
7 |

8 | More Stories 9 |

10 |
11 | {posts.map((post) => ( 12 | 21 | ))} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../tailwind.css' 2 | 3 | import { VisualEditing } from '@sanity/visual-editing/next-pages-router' 4 | import { AppProps } from 'next/app' 5 | import dynamic from 'next/dynamic' 6 | 7 | export interface SharedPageProps { 8 | previewMode: boolean 9 | previewPerspective: string | null 10 | token: string 11 | } 12 | 13 | const PreviewProvider = dynamic(() => import('components/PreviewProvider')) 14 | 15 | export default function App({ 16 | Component, 17 | pageProps, 18 | }: AppProps) { 19 | const { previewMode, previewPerspective, token } = pageProps 20 | return ( 21 | <> 22 | {previewMode ? ( 23 | 24 | 25 | 26 | ) : ( 27 | 28 | )} 29 | {previewMode && } 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/PreviewPostPage.tsx: -------------------------------------------------------------------------------- 1 | import { useLiveQuery } from '@sanity/preview-kit' 2 | import PostPage, { PostPageProps } from 'components/PostPage' 3 | import { 4 | type Post, 5 | postAndMoreStoriesQuery, 6 | Settings, 7 | settingsQuery, 8 | } from 'lib/sanity.queries' 9 | 10 | export default function PreviewPostPage(props: PostPageProps) { 11 | const [{ post: postPreview, morePosts }, loadingPost] = useLiveQuery<{ 12 | post: Post 13 | morePosts: Post[] 14 | }>( 15 | { post: props.post, morePosts: props.morePosts }, 16 | postAndMoreStoriesQuery, 17 | { slug: props.post.slug }, 18 | ) 19 | const [settings, loadingSettings] = useLiveQuery( 20 | props.settings, 21 | settingsQuery, 22 | ) 23 | 24 | return ( 25 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /components/SanityImage.tsx: -------------------------------------------------------------------------------- 1 | import type { SanityImageSource } from '@sanity/image-url/lib/types/types' 2 | import { getSanityImageConfig } from 'lib/sanity.client' 3 | import Image from 'next/image' 4 | import { useNextSanityImage } from 'next-sanity-image' 5 | 6 | interface Props { 7 | asset: SanityImageSource 8 | alt: string 9 | caption?: string 10 | } 11 | 12 | export const SanityImage = (props: Props) => { 13 | const { asset, alt, caption } = props 14 | const imageProps = useNextSanityImage(getSanityImageConfig(), asset) 15 | 16 | if (!imageProps) return null 17 | 18 | return ( 19 |
20 | {alt} 25 | {caption && ( 26 |
27 | {caption} 28 |
29 | )} 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Defaults, used by ./intro-template and can be deleted if the component is removed 2 | NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER="sanity-io" 3 | NEXT_PUBLIC_VERCEL_GIT_PROVIDER="github" 4 | NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG="nextjs-blog-cms-sanity-v3" 5 | 6 | # Required, find them on https://manage.sanity.io 7 | NEXT_PUBLIC_SANITY_PROJECT_ID= 8 | NEXT_PUBLIC_SANITY_DATASET= 9 | # see https://www.sanity.io/docs/api-versioning for how versioning works 10 | NEXT_PUBLIC_SANITY_API_VERSION="2022-11-15" 11 | SANITY_API_READ_TOKEN= 12 | # Optional, useful if you plan to add API functions that can write to your dataset 13 | SANITY_API_WRITE_TOKEN= 14 | 15 | # Optional, can be used to change the Studio title in the navbar and differentiate between production and staging environments for your editors 16 | NEXT_PUBLIC_SANITY_PROJECT_TITLE="Next.js Blog with Sanity.io" 17 | 18 | # Optional, check the comments in pages/api/revalidate.ts for instructions on how to set it up 19 | SANITY_REVALIDATE_SECRET= 20 | -------------------------------------------------------------------------------- /components/PostPageHead.tsx: -------------------------------------------------------------------------------- 1 | import BlogMeta from 'components/BlogMeta' 2 | import * as demo from 'lib/demo.data' 3 | import { urlForImage } from 'lib/sanity.image' 4 | import { Post, Settings } from 'lib/sanity.queries' 5 | import Head from 'next/head' 6 | import { stegaClean } from 'next-sanity' 7 | 8 | export interface PostPageHeadProps { 9 | settings: Settings 10 | post: Post 11 | } 12 | 13 | export default function PostPageHead({ settings, post }: PostPageHeadProps) { 14 | const title = settings.title ?? demo.title 15 | return ( 16 | 17 | 18 | {stegaClean(post.title ? `${post.title} | ${title}` : title)} 19 | 20 | 21 | {post.coverImage?.asset?._ref && ( 22 | 30 | )} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /schemas/settings/OpenGraphInput.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton, Stack } from '@sanity/ui' 2 | import { height, width } from 'components/OpenGraphImage' 3 | import React, { lazy, Suspense, useDeferredValue } from 'react' 4 | import { type ObjectInputProps } from 'sanity' 5 | import styled from 'styled-components' 6 | 7 | const OpenGraphPreview = lazy(() => import('./OpenGraphPreview')) 8 | 9 | const RatioSkeleton = styled(Skeleton)` 10 | aspect-ratio: ${width} / ${height}; 11 | height: 100%; 12 | width: 100%; 13 | ` 14 | 15 | const fallback = 16 | 17 | export default function OpenGraphInput(props: ObjectInputProps) { 18 | const value = useDeferredValue(props.value) 19 | return ( 20 | 21 | 22 | {value ? ( 23 | 24 | ) : ( 25 | fallback 26 | )} 27 | 28 | {props.renderDefault(props)} 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/AlertBanner.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-html-link-for-pages */ 2 | import Container from 'components/BlogContainer' 3 | import { useSyncExternalStore } from 'react' 4 | 5 | const subscribe = () => () => {} 6 | 7 | export default function Alert({ 8 | preview, 9 | loading, 10 | }: { 11 | preview?: boolean 12 | loading?: boolean 13 | }) { 14 | const shouldShow = useSyncExternalStore( 15 | subscribe, 16 | () => window.top === window, 17 | () => false, 18 | ) 19 | 20 | if (!shouldShow || !preview) return null 21 | 22 | return ( 23 |
28 | 29 |
30 | {'Previewing drafts. '} 31 | 35 | Back to published 36 | 37 |
38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /components/PostHeader.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from 'components/AuthorAvatar' 2 | import CoverImage from 'components/CoverImage' 3 | import Date from 'components/PostDate' 4 | import PostTitle from 'components/PostTitle' 5 | import type { Post } from 'lib/sanity.queries' 6 | 7 | export default function PostHeader( 8 | props: Pick, 9 | ) { 10 | const { title, coverImage, date, author, slug } = props 11 | return ( 12 | <> 13 | {title} 14 |
15 | {author && } 16 |
17 |
18 | 19 |
20 |
21 |
22 | {author && } 23 |
24 |
25 | 26 |
27 |
28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /components/PostPreview.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from 'components/AuthorAvatar' 2 | import CoverImage from 'components/CoverImage' 3 | import Date from 'components/PostDate' 4 | import type { Post } from 'lib/sanity.queries' 5 | import Link from 'next/link' 6 | 7 | export default function PostPreview({ 8 | title, 9 | coverImage, 10 | date, 11 | excerpt, 12 | author, 13 | slug, 14 | }: Omit) { 15 | return ( 16 |
17 |
18 | 24 |
25 |

26 | 27 | {title} 28 | 29 |

30 |
31 | 32 |
33 | {excerpt && ( 34 |

{excerpt}

35 | )} 36 | {author && } 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/BlogMeta.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * All the shared stuff that goes into on `(blog)` routes, can be be imported by `head.tsx` files in the /app dir or wrapped in a component in the /pages dir. 3 | */ 4 | 5 | export default function BlogMeta() { 6 | return ( 7 | <> 8 | 9 | 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /tailwind.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin "@tailwindcss/typography"; 3 | 4 | @theme { 5 | --color-accent-1: #fafafa; 6 | --color-accent-2: #eaeaea; 7 | --color-accent-7: #333; 8 | --color-success: #0070f3; 9 | --color-cyan: #79ffe1; 10 | --color-blue-500: #2276fc; 11 | --color-yellow-100: #fef7da; 12 | 13 | --tracking-tighter: -0.04em; 14 | 15 | --leading-tight: 1.2; 16 | 17 | --text-5xl: 2.5rem; 18 | --text-6xl: 2.75rem; 19 | --text-7xl: 4.5rem; 20 | --text-8xl: 6.25rem; 21 | 22 | --shadow-small: 0 5px 10px rgba(0, 0, 0, 0.12); 23 | --shadow-medium: 0 8px 30px rgba(0, 0, 0, 0.12); 24 | } 25 | 26 | /* 27 | The default border color has changed to `currentColor` in Tailwind CSS v4, 28 | so we've added these compatibility styles to make sure everything still 29 | looks the same as it did with Tailwind CSS v3. 30 | 31 | If we ever want to remove these styles, we need to add an explicit border 32 | color utility to any element that depends on these defaults. 33 | */ 34 | @layer base { 35 | *, 36 | ::after, 37 | ::before, 38 | ::backdrop, 39 | ::file-selector-button { 40 | border-color: var(--color-gray-200, currentColor); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pages/api/preview-mode/enable.ts: -------------------------------------------------------------------------------- 1 | import { validatePreviewUrl } from '@sanity/preview-url-secret' 2 | import { apiVersion, dataset, projectId } from 'lib/sanity.api' 3 | import type { NextApiRequest, NextApiResponse } from 'next' 4 | import { createClient } from 'next-sanity' 5 | 6 | const token = process.env.SANITY_API_READ_TOKEN 7 | if (!token) { 8 | throw new Error( 9 | 'A secret is provided but there is no `SANITY_API_READ_TOKEN` environment variable setup.', 10 | ) 11 | } 12 | const client = createClient({ 13 | projectId, 14 | dataset, 15 | apiVersion, 16 | useCdn: false, 17 | token, 18 | }) 19 | 20 | export default async function handler( 21 | req: NextApiRequest, 22 | res: NextApiResponse, 23 | ) { 24 | const { 25 | isValid, 26 | redirectTo = '/', 27 | studioPreviewPerspective, 28 | } = await validatePreviewUrl(client, req.url) 29 | if (!isValid) { 30 | return new Response('Invalid secret', { status: 401 }) 31 | } 32 | 33 | // Enable Preview Mode by setting the cookies, and current selected perspective as preview data that can be retrieved in getStaticProps 34 | res.setPreviewData(studioPreviewPerspective) 35 | 36 | res.redirect(redirectTo) 37 | } 38 | -------------------------------------------------------------------------------- /components/CoverImage.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import { urlForImage } from 'lib/sanity.image' 3 | import Image from 'next/image' 4 | import Link from 'next/link' 5 | 6 | interface CoverImageProps { 7 | title: string 8 | slug?: string 9 | image: any 10 | priority?: boolean 11 | } 12 | 13 | export default function CoverImage(props: CoverImageProps) { 14 | const { title, slug, image: source, priority } = props 15 | const image = source?.asset?._ref ? ( 16 |
21 | 30 |
31 | ) : ( 32 |
33 | ) 34 | 35 | return ( 36 |
37 | {slug ? ( 38 | 39 | {image} 40 | 41 | ) : ( 42 | image 43 | )} 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/IndexPageHead.tsx: -------------------------------------------------------------------------------- 1 | import BlogMeta from 'components/BlogMeta' 2 | import * as demo from 'lib/demo.data' 3 | import { Settings } from 'lib/sanity.queries' 4 | import Head from 'next/head' 5 | import { stegaClean, toPlainText } from 'next-sanity' 6 | 7 | export interface IndexPageHeadProps { 8 | settings: Settings 9 | } 10 | 11 | export default function IndexPageHead({ settings }: IndexPageHeadProps) { 12 | const { 13 | title = demo.title, 14 | description = demo.description, 15 | ogImage = {}, 16 | } = settings 17 | const ogImageTitle = ogImage?.title || demo.ogImageTitle 18 | 19 | return ( 20 | 21 | {stegaClean(title)} 22 | 23 | 28 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /lib/sanity.api.ts: -------------------------------------------------------------------------------- 1 | export const useCdn = false 2 | 3 | /** 4 | * As this file is reused in several other files, try to keep it lean and small. 5 | * Importing other npm packages here could lead to needlessly increasing the client bundle size, or end up in a server-only function that don't need it. 6 | */ 7 | 8 | export const dataset = assertValue( 9 | process.env.NEXT_PUBLIC_SANITY_DATASET, 10 | 'Missing environment variable: NEXT_PUBLIC_SANITY_DATASET', 11 | ) 12 | 13 | export const projectId = assertValue( 14 | process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 15 | 'Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID', 16 | ) 17 | 18 | export const readToken = process.env.SANITY_API_READ_TOKEN || '' 19 | 20 | // see https://www.sanity.io/docs/api-versioning for how versioning works 21 | export const apiVersion = 22 | process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2025-08-07' 23 | 24 | // Used to generate URLs for previewing your content 25 | export const PREVIEW_MODE_ROUTE = '/api/preview-mode/enable' 26 | 27 | /** 28 | * Used to configure edit intent links, for Presentation Mode, as well as to configure where the Studio is mounted in the router. 29 | */ 30 | export const studioUrl = '/studio' 31 | 32 | function assertValue(v: T | undefined, errorMessage: string): T { 33 | if (v === undefined) { 34 | throw new Error(errorMessage) 35 | } 36 | 37 | return v 38 | } 39 | -------------------------------------------------------------------------------- /pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | import { createClient } from '@sanity/client' 2 | import { ImageResponse } from '@vercel/og' 3 | import { apiVersion, dataset, projectId } from 'lib/sanity.api' 4 | import type { NextRequest, NextResponse } from 'next/server' 5 | 6 | export const config = { runtime: 'edge' } 7 | 8 | import { height, OpenGraphImage, width } from 'components/OpenGraphImage' 9 | import * as demo from 'lib/demo.data' 10 | import { Settings, settingsQuery } from 'lib/sanity.queries' 11 | 12 | export default async function og(req: NextRequest, res: NextResponse) { 13 | const font = fetch(new URL('public/Inter-Bold.woff', import.meta.url)).then( 14 | (res) => res.arrayBuffer(), 15 | ) 16 | const { searchParams } = new URL(req.url) 17 | 18 | let title = searchParams.get('title') 19 | if (!title) { 20 | const client = createClient({ 21 | projectId, 22 | dataset, 23 | apiVersion, 24 | useCdn: false, 25 | }) 26 | const settings = (await client.fetch(settingsQuery)) || {} 27 | title = settings?.ogImage?.title 28 | } 29 | 30 | return new ImageResponse( 31 | , 32 | { 33 | width, 34 | height, 35 | fonts: [ 36 | { 37 | name: 'Inter', 38 | data: await font, 39 | style: 'normal', 40 | weight: 700, 41 | }, 42 | ], 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /lib/demo.data.ts: -------------------------------------------------------------------------------- 1 | // All the demo data that used as fallbacks when there's nothing in the dataset yet 2 | 3 | export const title = 'Blog.' 4 | 5 | export const description = [ 6 | { 7 | _key: '9f1a629887fd', 8 | _type: 'block', 9 | children: [ 10 | { 11 | _key: '4a58edd077880', 12 | _type: 'span', 13 | marks: [], 14 | text: 'A statically generated blog example using ', 15 | }, 16 | { 17 | _key: '4a58edd077881', 18 | _type: 'span', 19 | marks: ['ec5b66c9b1e0'], 20 | text: 'Next.js', 21 | }, 22 | { 23 | _key: '4a58edd077882', 24 | _type: 'span', 25 | marks: [], 26 | text: ' and ', 27 | }, 28 | { 29 | _key: '4a58edd077883', 30 | _type: 'span', 31 | marks: ['1f8991913ea8'], 32 | text: 'Sanity', 33 | }, 34 | { 35 | _key: '4a58edd077884', 36 | _type: 'span', 37 | marks: [], 38 | text: '.', 39 | }, 40 | ], 41 | markDefs: [ 42 | { 43 | _key: 'ec5b66c9b1e0', 44 | _type: 'link', 45 | href: 'https://nextjs.org/', 46 | }, 47 | { 48 | _key: '1f8991913ea8', 49 | _type: 'link', 50 | href: 'https://sanity.io/', 51 | }, 52 | ], 53 | style: 'normal', 54 | }, 55 | ] 56 | 57 | export const ogImageTitle = 'A Next.js Blog with a Native Authoring Experience' 58 | -------------------------------------------------------------------------------- /components/BlogHeader.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { PortableText } from 'next-sanity' 3 | 4 | import styles from './BlogHeader.module.css' 5 | 6 | export default function BlogHeader({ 7 | title, 8 | description, 9 | level, 10 | }: { 11 | title: string 12 | description?: any[] 13 | level: 1 | 2 14 | }) { 15 | switch (level) { 16 | case 1: 17 | return ( 18 |
19 |

20 | {title} 21 |

22 |

25 | 26 |

27 |
28 | ) 29 | 30 | case 2: 31 | return ( 32 |
33 |

34 | 35 | {title} 36 | 37 |

38 |
39 | ) 40 | 41 | default: 42 | throw new Error( 43 | `Invalid level: ${ 44 | JSON.stringify(level) || typeof level 45 | }, only 1 or 2 are allowed`, 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/sanity.queries.ts: -------------------------------------------------------------------------------- 1 | import groq from 'groq' 2 | 3 | const postFields = groq` 4 | _id, 5 | title, 6 | date, 7 | _updatedAt, 8 | excerpt, 9 | coverImage, 10 | "slug": slug.current, 11 | "author": author->{name, picture}, 12 | ` 13 | 14 | export const settingsQuery = groq`*[_type == "settings"][0]` 15 | 16 | export const indexQuery = groq` 17 | *[_type == "post"] | order(date desc, _updatedAt desc) { 18 | ${postFields} 19 | }` 20 | 21 | export const postAndMoreStoriesQuery = groq` 22 | { 23 | "post": *[_type == "post" && slug.current == $slug] | order(_updatedAt desc) [0] { 24 | content, 25 | ${postFields} 26 | }, 27 | "morePosts": *[_type == "post" && slug.current != $slug] | order(date desc, _updatedAt desc) [0...2] { 28 | content, 29 | ${postFields} 30 | } 31 | }` 32 | 33 | export const postSlugsQuery = groq` 34 | *[_type == "post" && defined(slug.current)][].slug.current 35 | ` 36 | 37 | export const postBySlugQuery = groq` 38 | *[_type == "post" && slug.current == $slug][0] { 39 | ${postFields} 40 | } 41 | ` 42 | 43 | export interface Author { 44 | name?: string 45 | picture?: any 46 | } 47 | 48 | export interface Post { 49 | _id: string 50 | title?: string 51 | coverImage?: any 52 | date?: string 53 | _updatedAt?: string 54 | excerpt?: string 55 | author?: Author 56 | slug?: string 57 | content?: any 58 | } 59 | 60 | export interface Settings { 61 | title?: string 62 | description?: any[] 63 | ogImage?: { 64 | title?: string 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /components/HeroPost.tsx: -------------------------------------------------------------------------------- 1 | import AuthorAvatar from 'components/AuthorAvatar' 2 | import CoverImage from 'components/CoverImage' 3 | import Date from 'components/PostDate' 4 | import type { Post } from 'lib/sanity.queries' 5 | import Link from 'next/link' 6 | 7 | export default function HeroPost( 8 | props: Pick< 9 | Post, 10 | 'title' | 'coverImage' | 'date' | 'excerpt' | 'author' | 'slug' 11 | >, 12 | ) { 13 | const { title, coverImage, date, excerpt, author, slug } = props 14 | return ( 15 |
16 |
17 | 18 |
19 |
20 |
21 |

22 | 23 | {title || 'Untitled'} 24 | 25 |

26 |
27 | 28 |
29 |
30 |
31 | {excerpt && ( 32 |

33 | {excerpt} 34 |

35 | )} 36 | {author && ( 37 | 38 | )} 39 |
40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import IndexPage from 'components/IndexPage' 2 | import PreviewIndexPage from 'components/PreviewIndexPage' 3 | import { readToken } from 'lib/sanity.api' 4 | import { getAllPosts, getClient, getSettings } from 'lib/sanity.client' 5 | import { Post, Settings } from 'lib/sanity.queries' 6 | import { GetStaticProps } from 'next' 7 | import type { SharedPageProps } from 'pages/_app' 8 | 9 | interface PageProps extends SharedPageProps { 10 | posts: Post[] 11 | settings: Settings 12 | } 13 | 14 | interface Query { 15 | [key: string]: string 16 | } 17 | 18 | export default function Page(props: PageProps) { 19 | const { posts, settings, previewMode } = props 20 | 21 | if (previewMode) { 22 | return 23 | } 24 | 25 | return ( 26 |
27 | 28 |
29 | ) 30 | } 31 | 32 | export const getStaticProps: GetStaticProps = async (ctx) => { 33 | const { preview: previewMode = false, previewData } = ctx 34 | const client = getClient( 35 | previewMode ? { token: readToken, perspective: previewData } : undefined, 36 | ) 37 | 38 | const [settings, posts = []] = await Promise.all([ 39 | getSettings(client), 40 | getAllPosts(client), 41 | ]) 42 | 43 | return { 44 | props: { 45 | posts, 46 | settings, 47 | previewMode, 48 | previewPerspective: typeof previewData === 'string' ? previewData : null, 49 | token: previewMode ? readToken : '', 50 | }, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /components/IndexPage.tsx: -------------------------------------------------------------------------------- 1 | import Container from 'components/BlogContainer' 2 | import BlogHeader from 'components/BlogHeader' 3 | import Layout from 'components/BlogLayout' 4 | import HeroPost from 'components/HeroPost' 5 | import IndexPageHead from 'components/IndexPageHead' 6 | import MoreStories from 'components/MoreStories' 7 | import IntroTemplate from 'intro-template' 8 | import * as demo from 'lib/demo.data' 9 | import type { Post, Settings } from 'lib/sanity.queries' 10 | import { Suspense } from 'react' 11 | 12 | export interface IndexPageProps { 13 | preview?: boolean 14 | loading?: boolean 15 | posts: Post[] 16 | settings: Settings 17 | } 18 | 19 | export default function IndexPage(props: IndexPageProps) { 20 | const { preview, loading, posts, settings } = props 21 | const [heroPost, ...morePosts] = posts || [] 22 | const { title = demo.title, description = demo.description } = settings || {} 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 | {heroPost && ( 32 | 40 | )} 41 | {morePosts.length > 0 && } 42 | 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Prettier 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | run: 18 | name: Can the code be prettier? 🤔 19 | runs-on: ubuntu-latest 20 | # workflow_dispatch always lets you select the branch ref, even though in this case we only ever want to run the action on `main` this we need an if check 21 | if: ${{ github.ref_name == 'main' }} 22 | steps: 23 | - uses: actions/checkout@v5 24 | - uses: actions/setup-node@v5 25 | with: 26 | node-version: lts/* 27 | - run: npm ci --ignore-scripts --only-dev 28 | - uses: actions/cache@v4 29 | with: 30 | path: node_modules/.cache/prettier/.prettier-cache 31 | key: prettier-${{ hashFiles('package-lock.json') }}-${{ hashFiles('.gitignore') }} 32 | - run: npm run format 33 | - run: git restore .github/workflows 34 | - uses: actions/create-github-app-token@v2 35 | id: generate-token 36 | with: 37 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 38 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 39 | - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 40 | with: 41 | body: I ran `npm run format` 🧑‍💻 42 | branch: actions/prettier-if-needed 43 | commit-message: 'chore(prettier): 🤖 ✨' 44 | labels: 🤖 bot 45 | sign-commits: true 46 | title: 'chore(prettier): 🤖 ✨' 47 | token: ${{ steps.generate-token.outputs.token }} 48 | -------------------------------------------------------------------------------- /schemas/settings/OpenGraphPreview.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@sanity/ui' 2 | import { height, OpenGraphImage, width } from 'components/OpenGraphImage' 3 | import { createIntlSegmenterPolyfill } from 'intl-segmenter-polyfill' 4 | import type { Settings } from 'lib/sanity.queries' 5 | import { use, useState } from 'react' 6 | import satori, { type SatoriOptions } from 'satori' 7 | import styled from 'styled-components' 8 | 9 | async function init(): Promise { 10 | if (!globalThis?.Intl?.Segmenter) { 11 | console.debug('Polyfilling Intl.Segmenter') 12 | //@ts-expect-error 13 | globalThis.Intl = globalThis.Intl || {} 14 | //@ts-expect-error 15 | globalThis.Intl.Segmenter = await createIntlSegmenterPolyfill( 16 | fetch(new URL('public/break_iterator.wasm', import.meta.url)), 17 | ) 18 | } 19 | 20 | const fontData = await fetch( 21 | new URL('public/Inter-Bold.woff', import.meta.url), 22 | ).then((res) => res.arrayBuffer()) 23 | 24 | return [{ name: 'Inter', data: fontData, style: 'normal', weight: 700 }] 25 | } 26 | 27 | // preload fonts and polyfill 28 | const fontsPromise = init() 29 | 30 | const OpenGraphSvg = styled(Card).attrs({ 31 | radius: 3, 32 | shadow: 1, 33 | overflow: 'hidden', 34 | })` 35 | display: flex; 36 | align-items: center; 37 | justify-content: center; 38 | 39 | svg { 40 | display: block; 41 | object-fit: cover; 42 | aspect-ratio: ${width} / ${height}; 43 | object-position: center; 44 | height: 100%; 45 | width: 100%; 46 | } 47 | ` 48 | 49 | export default function OpenGraphPreview(props: Settings['ogImage']) { 50 | const fonts = use(fontsPromise) 51 | 52 | const [promise] = useState(() => 53 | satori(, { 54 | width, 55 | height, 56 | fonts, 57 | }), 58 | ) 59 | 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-blog-cms-sanity-v3", 3 | "private": true, 4 | "scripts": { 5 | "build": "next build && sanity manifest extract --path public/studio/static", 6 | "dev": "next --turbopack", 7 | "format": "npx prettier --write . --ignore-path .gitignore --cache", 8 | "lint": "next lint . --ignore-path .gitignore", 9 | "lint:fix": "npm run format && npm run lint -- --fix", 10 | "start": "next start", 11 | "type-check": "tsc --noEmit" 12 | }, 13 | "prettier": { 14 | "semi": false, 15 | "singleQuote": true 16 | }, 17 | "dependencies": { 18 | "@sanity/client": "7.12.0", 19 | "@sanity/icons": "3.7.4", 20 | "@sanity/image-url": "1.2.0", 21 | "@sanity/preview-kit": "^6.1.3", 22 | "@sanity/preview-url-secret": "2.1.15", 23 | "@sanity/vision": "4.10.3", 24 | "@sanity/visual-editing": "3.2.3", 25 | "@sanity/webhook": "4.0.4", 26 | "@vercel/og": "0.8.5", 27 | "classnames": "2.5.1", 28 | "date-fns": "4.1.0", 29 | "groq": "4.10.3", 30 | "intl-segmenter-polyfill": "0.4.4", 31 | "next": "15.5.9", 32 | "next-sanity": "11.5.5", 33 | "next-sanity-image": "6.2.0", 34 | "react": "19.2.1", 35 | "react-dom": "19.2.1", 36 | "sanity": "4.10.3", 37 | "sanity-plugin-asset-source-unsplash": "4.0.1", 38 | "sanity-plugin-iframe-pane": "4.0.0", 39 | "styled-components": "6.1.19" 40 | }, 41 | "devDependencies": { 42 | "@tailwindcss/postcss": "4.1.14", 43 | "@tailwindcss/typography": "0.5.19", 44 | "@types/react": "19.2.2", 45 | "eslint": "8.57.1", 46 | "eslint-config-next": "15.5.6", 47 | "eslint-plugin-simple-import-sort": "12.1.1", 48 | "postcss": "8.5.6", 49 | "prettier": "3.6.2", 50 | "prettier-plugin-packagejson": "2.5.19", 51 | "prettier-plugin-tailwindcss": "0.7.0", 52 | "tailwindcss": "4.1.14", 53 | "typescript": "5.9.3" 54 | }, 55 | "engines": { 56 | "node": ">=20.19" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pages/posts/[slug].tsx: -------------------------------------------------------------------------------- 1 | import PostPage from 'components/PostPage' 2 | import PreviewPostPage from 'components/PreviewPostPage' 3 | import { readToken } from 'lib/sanity.api' 4 | import { 5 | getAllPostsSlugs, 6 | getClient, 7 | getPostAndMoreStories, 8 | getSettings, 9 | } from 'lib/sanity.client' 10 | import { Post, Settings } from 'lib/sanity.queries' 11 | import { GetStaticProps } from 'next' 12 | import type { SharedPageProps } from 'pages/_app' 13 | 14 | interface PageProps extends SharedPageProps { 15 | post: Post 16 | morePosts: Post[] 17 | settings?: Settings 18 | } 19 | 20 | interface Query { 21 | [key: string]: string 22 | } 23 | 24 | export default function ProjectSlugRoute(props: PageProps) { 25 | const { settings, post, morePosts, previewMode } = props 26 | 27 | if (previewMode) { 28 | return ( 29 | 30 | ) 31 | } 32 | 33 | return 34 | } 35 | 36 | export const getStaticProps: GetStaticProps = async (ctx) => { 37 | const { preview: previewMode = false, previewData, params = {} } = ctx 38 | const client = getClient( 39 | previewMode ? { token: readToken, perspective: previewData } : undefined, 40 | ) 41 | 42 | const [settings, { post, morePosts }] = await Promise.all([ 43 | getSettings(client), 44 | getPostAndMoreStories(client, params.slug), 45 | ]) 46 | 47 | if (!post) { 48 | return { notFound: true } 49 | } 50 | 51 | return { 52 | props: { 53 | post, 54 | morePosts, 55 | settings, 56 | previewMode, 57 | previewPerspective: typeof previewData === 'string' ? previewData : null, 58 | token: previewMode ? readToken : '', 59 | }, 60 | } 61 | } 62 | 63 | export const getStaticPaths = async () => { 64 | const slugs = await getAllPostsSlugs() 65 | 66 | return { 67 | paths: slugs?.map(({ slug }) => `/posts/${slug}`) || [], 68 | fallback: 'blocking', 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /plugins/locate.ts: -------------------------------------------------------------------------------- 1 | import { map, Observable } from 'rxjs' 2 | import { 3 | DocumentLocationResolver, 4 | DocumentLocationsState, 5 | } from 'sanity/presentation' 6 | 7 | export const locate: DocumentLocationResolver = (params, context) => { 8 | if (params.type === 'settings') { 9 | return { 10 | message: 'This document is used on all pages', 11 | tone: 'caution', 12 | } satisfies DocumentLocationsState 13 | } 14 | 15 | if (params.type === 'post') { 16 | // Listen to the query and fetch the draft and published document 17 | const doc$ = context.documentStore.listenQuery( 18 | `*[_id == $id && defined(slug.current)][0]{slug,title}`, 19 | params, 20 | { perspective: 'drafts' }, 21 | ) as Observable<{ 22 | slug: { current: string } 23 | title: string | null 24 | } | null> 25 | 26 | return doc$.pipe( 27 | map((doc) => { 28 | return { 29 | locations: [ 30 | { 31 | title: doc?.title || 'Untitled', 32 | href: `/posts/${doc?.slug?.current}`, 33 | }, 34 | { 35 | title: 'Home', 36 | href: `/`, 37 | }, 38 | ], 39 | } 40 | }), 41 | ) 42 | } 43 | 44 | if (params.type === 'author') { 45 | // Fetch all posts that reference the viewed author, if the post has a slug defined 46 | const doc$ = context.documentStore.listenQuery( 47 | `*[_type == "post" && references($id) && defined(slug.current)]{slug,title}`, 48 | params, 49 | { perspective: 'drafts' }, 50 | ) as Observable< 51 | { 52 | slug: { current: string } 53 | title: string | null 54 | }[] 55 | > 56 | 57 | return doc$.pipe( 58 | map((docs) => { 59 | return { 60 | locations: docs?.map((doc) => ({ 61 | title: doc?.title || 'Untitled', 62 | href: `/posts/${doc?.slug?.current}`, 63 | })), 64 | } 65 | }), 66 | ) 67 | } 68 | 69 | return null 70 | } 71 | -------------------------------------------------------------------------------- /components/PostPage.tsx: -------------------------------------------------------------------------------- 1 | import Container from 'components/BlogContainer' 2 | import BlogHeader from 'components/BlogHeader' 3 | import Layout from 'components/BlogLayout' 4 | import MoreStories from 'components/MoreStories' 5 | import PostBody from 'components/PostBody' 6 | import PostHeader from 'components/PostHeader' 7 | import PostPageHead from 'components/PostPageHead' 8 | import PostTitle from 'components/PostTitle' 9 | import SectionSeparator from 'components/SectionSeparator' 10 | import * as demo from 'lib/demo.data' 11 | import type { Post, Settings } from 'lib/sanity.queries' 12 | import Error from 'next/error' 13 | 14 | export interface PostPageProps { 15 | preview?: boolean 16 | loading?: boolean 17 | post: Post 18 | morePosts: Post[] 19 | settings: Settings 20 | } 21 | 22 | const NO_POSTS: Post[] = [] 23 | 24 | export default function PostPage(props: PostPageProps) { 25 | const { preview, loading, morePosts = NO_POSTS, post, settings } = props 26 | const { title = demo.title } = settings || {} 27 | 28 | const slug = post?.slug 29 | 30 | if (!slug && !preview) { 31 | return 32 | } 33 | 34 | return ( 35 | <> 36 | 37 | 38 | 39 | 40 | 41 | {preview && !post ? ( 42 | Loading… 43 | ) : ( 44 | <> 45 |
46 | 52 | 53 |
54 | 55 | {morePosts?.length > 0 && } 56 | 57 | )} 58 |
59 |
60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /sanity.config.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | /** 3 | * This config is used to set up Sanity Studio that's mounted on the `/pages/studio/[[...index]].tsx` route 4 | */ 5 | 6 | import { visionTool } from '@sanity/vision' 7 | import { 8 | apiVersion, 9 | dataset, 10 | PREVIEW_MODE_ROUTE, 11 | projectId, 12 | } from 'lib/sanity.api' 13 | import { locate } from 'plugins/locate' 14 | import { previewDocumentNode } from 'plugins/previewPane' 15 | import { settingsPlugin, settingsStructure } from 'plugins/settings' 16 | import { defineConfig } from 'sanity' 17 | import { presentationTool } from 'sanity/presentation' 18 | import { structureTool } from 'sanity/structure' 19 | import { unsplashImageAsset } from 'sanity-plugin-asset-source-unsplash' 20 | import authorType from 'schemas/author' 21 | import postType from 'schemas/post' 22 | import settingsType from 'schemas/settings' 23 | 24 | const title = 25 | process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE || 'Next.js Blog with Sanity.io' 26 | 27 | export default defineConfig({ 28 | basePath: '/studio', 29 | projectId, 30 | dataset, 31 | title, 32 | schema: { 33 | // If you want more content types, you can add them to this array 34 | types: [authorType, postType, settingsType], 35 | }, 36 | plugins: [ 37 | structureTool({ 38 | structure: settingsStructure(settingsType), 39 | // `defaultDocumentNode` is responsible for adding a “Preview” tab to the document pane 40 | defaultDocumentNode: previewDocumentNode(), 41 | }), 42 | presentationTool({ 43 | locate, 44 | previewUrl: { previewMode: { enable: PREVIEW_MODE_ROUTE } }, 45 | }), 46 | // Configures the global "new document" button, and document actions, to suit the Settings document singleton 47 | settingsPlugin({ type: settingsType.name }), 48 | // Add an image asset source for Unsplash 49 | unsplashImageAsset(), 50 | // Vision lets you query your content with GROQ in the studio 51 | // https://www.sanity.io/docs/the-vision-plugin 52 | process.env.NODE_ENV !== 'production' && 53 | visionTool({ defaultApiVersion: apiVersion }), 54 | ], 55 | }) 56 | -------------------------------------------------------------------------------- /plugins/settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This plugin contains all the logic for setting up the `Settings` singleton 3 | */ 4 | 5 | import { definePlugin, type DocumentDefinition } from 'sanity' 6 | import type { StructureResolver } from 'sanity/structure' 7 | 8 | export const settingsPlugin = definePlugin<{ type: string }>(({ type }) => { 9 | return { 10 | name: 'settings', 11 | document: { 12 | // Hide 'Settings' from new document options 13 | // https://user-images.githubusercontent.com/81981/195728798-e0c6cf7e-d442-4e58-af3a-8cd99d7fcc28.png 14 | newDocumentOptions: (prev, { creationContext }) => { 15 | if (creationContext.type === 'global') { 16 | return prev.filter((templateItem) => templateItem.templateId !== type) 17 | } 18 | 19 | return prev 20 | }, 21 | // Removes the "duplicate" action on the "settings" singleton 22 | actions: (prev, { schemaType }) => { 23 | if (schemaType === type) { 24 | return prev.filter(({ action }) => action !== 'duplicate') 25 | } 26 | 27 | return prev 28 | }, 29 | }, 30 | } 31 | }) 32 | 33 | // The StructureResolver is how we're changing the DeskTool structure to linking to a single "Settings" document, instead of rendering "settings" in a list 34 | // like how "Post" and "Author" is handled. 35 | export const settingsStructure = ( 36 | typeDef: DocumentDefinition, 37 | ): StructureResolver => { 38 | return (S) => { 39 | // The `Settings` root list item 40 | const settingsListItem = // A singleton not using `documentListItem`, eg no built-in preview 41 | S.listItem() 42 | .title(typeDef.title) 43 | .icon(typeDef.icon) 44 | .child( 45 | S.editor() 46 | .id(typeDef.name) 47 | .schemaType(typeDef.name) 48 | .documentId(typeDef.name), 49 | ) 50 | 51 | // The default root list items (except custom ones) 52 | const defaultListItems = S.documentTypeListItems().filter( 53 | (listItem) => listItem.getId() !== typeDef.name, 54 | ) 55 | 56 | return S.list() 57 | .title('Content') 58 | .items([settingsListItem, S.divider(), ...defaultListItems]) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /plugins/previewPane/index.tsx: -------------------------------------------------------------------------------- 1 | // This plugin is responsible for adding a “Preview” tab to the document pane 2 | // You can add any React component to `S.view.component` and it will be rendered in the pane 3 | // and have access to content in the form in real-time. 4 | // It's part of the Studio's “Structure Builder API” and is documented here: 5 | // https://www.sanity.io/docs/structure-builder-reference 6 | 7 | import { PREVIEW_MODE_ROUTE } from 'lib/sanity.api' 8 | import type { DefaultDocumentNodeResolver } from 'sanity/structure' 9 | import { Iframe, IframeOptions } from 'sanity-plugin-iframe-pane' 10 | import authorType from 'schemas/author' 11 | import postType from 'schemas/post' 12 | 13 | import AuthorAvatarPreviewPane from './AuthorAvatarPreviewPane' 14 | 15 | const iframeOptions = { 16 | url: { 17 | origin: 'same-origin', 18 | preview: (document) => { 19 | if (!document) { 20 | return new Error('Missing document') 21 | } 22 | switch (document._type) { 23 | case 'post': 24 | return (document as any)?.slug?.current 25 | ? `/posts/${(document as any).slug.current}` 26 | : new Error('Missing slug') 27 | default: 28 | return new Error(`Unknown document type: ${document?._type}`) 29 | } 30 | }, 31 | draftMode: PREVIEW_MODE_ROUTE, 32 | }, 33 | reload: { button: true }, 34 | } satisfies IframeOptions 35 | 36 | export const previewDocumentNode = (): DefaultDocumentNodeResolver => { 37 | return (S, { schemaType }) => { 38 | switch (schemaType) { 39 | case authorType.name: 40 | return S.document().views([ 41 | S.view.form(), 42 | S.view 43 | .component(({ document }) => ( 44 | 48 | )) 49 | .title('Preview'), 50 | ]) 51 | 52 | case postType.name: 53 | return S.document().views([ 54 | S.view.form(), 55 | S.view.component(Iframe).options(iframeOptions).title('Preview'), 56 | ]) 57 | default: 58 | return null 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/sanity.client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | apiVersion, 3 | dataset, 4 | projectId, 5 | studioUrl, 6 | useCdn, 7 | } from 'lib/sanity.api' 8 | import { 9 | indexQuery, 10 | type Post, 11 | postAndMoreStoriesQuery, 12 | postBySlugQuery, 13 | postSlugsQuery, 14 | type Settings, 15 | settingsQuery, 16 | } from 'lib/sanity.queries' 17 | import type { PreviewData } from 'next' 18 | import { createClient, type SanityClient } from 'next-sanity' 19 | 20 | export function getClient(preview?: { 21 | token: string 22 | perspective: PreviewData 23 | }): SanityClient { 24 | const client = createClient({ 25 | projectId, 26 | dataset, 27 | apiVersion, 28 | useCdn, 29 | perspective: 'published', 30 | stega: { enabled: preview?.token ? true : false, studioUrl }, 31 | }) 32 | if (preview) { 33 | if (!preview.token) { 34 | throw new Error('You must provide a token to preview drafts') 35 | } 36 | return client.withConfig({ 37 | token: preview.token, 38 | useCdn: false, 39 | ignoreBrowserTokenWarning: true, 40 | perspective: 41 | typeof preview.perspective === 'string' 42 | ? preview.perspective.split(',') 43 | : 'drafts', 44 | }) 45 | } 46 | return client 47 | } 48 | 49 | export const getSanityImageConfig = () => getClient() 50 | 51 | export async function getSettings(client: SanityClient): Promise { 52 | return (await client.fetch(settingsQuery)) || {} 53 | } 54 | 55 | export async function getAllPosts(client: SanityClient): Promise { 56 | return (await client.fetch(indexQuery)) || [] 57 | } 58 | 59 | export async function getAllPostsSlugs(): Promise[]> { 60 | const client = getClient() 61 | const slugs = (await client.fetch(postSlugsQuery)) || [] 62 | return slugs.map((slug) => ({ slug })) 63 | } 64 | 65 | export async function getPostBySlug( 66 | client: SanityClient, 67 | slug: string, 68 | ): Promise { 69 | return (await client.fetch(postBySlugQuery, { slug })) || ({} as any) 70 | } 71 | 72 | export async function getPostAndMoreStories( 73 | client: SanityClient, 74 | slug: string, 75 | ): Promise<{ post: Post; morePosts: Post[] }> { 76 | return await client.fetch(postAndMoreStoriesQuery, { slug }) 77 | } 78 | -------------------------------------------------------------------------------- /schemas/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { CogIcon } from '@sanity/icons' 2 | import * as demo from 'lib/demo.data' 3 | import { defineArrayMember, defineField, defineType } from 'sanity' 4 | 5 | import OpenGraphInput from './OpenGraphInput' 6 | 7 | export default defineType({ 8 | name: 'settings', 9 | title: 'Settings', 10 | type: 'document', 11 | icon: CogIcon, 12 | preview: { select: { title: 'title', subtitle: 'description' } }, 13 | // Uncomment below to have edits publish automatically as you type 14 | // liveEdit: true, 15 | fields: [ 16 | defineField({ 17 | name: 'title', 18 | description: 'This field is the title of your blog.', 19 | title: 'Title', 20 | type: 'string', 21 | initialValue: demo.title, 22 | validation: (rule) => rule.required(), 23 | }), 24 | defineField({ 25 | name: 'description', 26 | description: 27 | 'Used both for the description tag for SEO, and the blog subheader.', 28 | title: 'Description', 29 | type: 'array', 30 | initialValue: demo.description, 31 | of: [ 32 | defineArrayMember({ 33 | type: 'block', 34 | options: {}, 35 | styles: [], 36 | lists: [], 37 | marks: { 38 | decorators: [], 39 | annotations: [ 40 | defineField({ 41 | type: 'object', 42 | name: 'link', 43 | fields: [ 44 | { 45 | type: 'string', 46 | name: 'href', 47 | title: 'URL', 48 | validation: (rule) => rule.required(), 49 | }, 50 | ], 51 | }), 52 | ], 53 | }, 54 | }), 55 | ], 56 | validation: (rule) => rule.max(155).required(), 57 | }), 58 | defineField({ 59 | name: 'ogImage', 60 | title: 'Open Graph Image', 61 | description: 62 | 'Used for social media previews when linking to the index page.', 63 | type: 'object', 64 | components: { 65 | input: OpenGraphInput as any, 66 | }, 67 | fields: [ 68 | defineField({ 69 | name: 'title', 70 | title: 'Title', 71 | type: 'string', 72 | initialValue: demo.ogImageTitle, 73 | }), 74 | ], 75 | }), 76 | ], 77 | }) 78 | -------------------------------------------------------------------------------- /pages/Sitemap.xml.tsx: -------------------------------------------------------------------------------- 1 | import { getAllPosts, getClient } from 'lib/sanity.client' 2 | 3 | type SitemapLocation = { 4 | url: string 5 | changefreq?: 6 | | 'always' 7 | | 'hourly' 8 | | 'daily' 9 | | 'weekly' 10 | | 'monthly' 11 | | 'yearly' 12 | | 'never' 13 | priority: number 14 | lastmod?: Date 15 | } 16 | 17 | // Use this to manually add routes to the sitemap 18 | const defaultUrls: SitemapLocation[] = [ 19 | { 20 | url: '/', 21 | changefreq: 'daily', 22 | priority: 1, 23 | lastmod: new Date(), // or custom date: '2023-06-12T00:00:00.000Z', 24 | }, 25 | // { url: '/about', priority: 0.5 }, 26 | // { url: '/blog', changefreq: 'weekly', priority: 0.7 }, 27 | ] 28 | 29 | const createSitemap = (locations: SitemapLocation[]) => { 30 | const baseUrl = process.env.NEXT_PUBLIC_URL // Make sure to configure this 31 | return ` 32 | 33 | ${locations 34 | .map((location) => { 35 | return ` 36 | ${baseUrl}${location.url} 37 | ${location.priority} 38 | ${ 39 | location.lastmod 40 | ? `${location.lastmod.toISOString()}` 41 | : '' 42 | } 43 | ` 44 | }) 45 | .join('')} 46 | 47 | ` 48 | } 49 | 50 | export default function SiteMap() { 51 | // getServerSideProps will do the heavy lifting 52 | } 53 | 54 | export async function getServerSideProps({ res }) { 55 | const client = getClient() 56 | 57 | // Get list of Post urls 58 | const [posts = []] = await Promise.all([getAllPosts(client)]) 59 | const postUrls: SitemapLocation[] = posts 60 | .filter(({ slug = '' }) => slug) 61 | .map((post) => { 62 | return { 63 | url: `/posts/${post.slug}`, 64 | priority: 0.5, 65 | lastmod: new Date(post._updatedAt), 66 | } 67 | }) 68 | 69 | // ... get more routes here 70 | 71 | // Return the default urls, combined with dynamic urls above 72 | const locations = [...defaultUrls, ...postUrls] 73 | 74 | // Set response to XML 75 | res.setHeader('Content-Type', 'text/xml') 76 | res.write(createSitemap(locations)) 77 | res.end() 78 | 79 | return { 80 | props: {}, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /public/sanity.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schemas/post.ts: -------------------------------------------------------------------------------- 1 | import { BookIcon } from '@sanity/icons' 2 | import { format, parseISO } from 'date-fns' 3 | import { defineField, defineType } from 'sanity' 4 | 5 | import authorType from './author' 6 | 7 | /** 8 | * This file is the schema definition for a post. 9 | * 10 | * Here you'll be able to edit the different fields that appear when you 11 | * create or edit a post in the studio. 12 | * 13 | * Here you can see the different schema types that are available: 14 | 15 | https://www.sanity.io/docs/schema-types 16 | 17 | */ 18 | 19 | export default defineType({ 20 | name: 'post', 21 | title: 'Post', 22 | icon: BookIcon, 23 | type: 'document', 24 | fields: [ 25 | defineField({ 26 | name: 'title', 27 | title: 'Title', 28 | type: 'string', 29 | validation: (rule) => rule.required(), 30 | }), 31 | defineField({ 32 | name: 'slug', 33 | title: 'Slug', 34 | type: 'slug', 35 | options: { 36 | source: 'title', 37 | maxLength: 96, 38 | isUnique: (value, context) => context.defaultIsUnique(value, context), 39 | }, 40 | validation: (rule) => rule.required(), 41 | }), 42 | defineField({ 43 | name: 'content', 44 | title: 'Content', 45 | type: 'array', 46 | of: [ 47 | { type: 'block' }, 48 | { 49 | type: 'image', 50 | options: { 51 | hotspot: true, 52 | }, 53 | fields: [ 54 | { 55 | name: 'caption', 56 | type: 'string', 57 | title: 'Image caption', 58 | description: 'Caption displayed below the image.', 59 | }, 60 | { 61 | name: 'alt', 62 | type: 'string', 63 | title: 'Alternative text', 64 | description: 'Important for SEO and accessiblity.', 65 | }, 66 | ], 67 | }, 68 | ], 69 | }), 70 | defineField({ 71 | name: 'excerpt', 72 | title: 'Excerpt', 73 | type: 'text', 74 | }), 75 | defineField({ 76 | name: 'coverImage', 77 | title: 'Cover Image', 78 | type: 'image', 79 | options: { 80 | hotspot: true, 81 | }, 82 | }), 83 | defineField({ 84 | name: 'date', 85 | title: 'Date', 86 | type: 'datetime', 87 | initialValue: () => new Date().toISOString(), 88 | }), 89 | defineField({ 90 | name: 'author', 91 | title: 'Author', 92 | type: 'reference', 93 | to: [{ type: authorType.name }], 94 | }), 95 | ], 96 | preview: { 97 | select: { 98 | title: 'title', 99 | author: 'author.name', 100 | date: 'date', 101 | media: 'coverImage', 102 | }, 103 | prepare({ title, media, author, date }) { 104 | const subtitles = [ 105 | author && `by ${author}`, 106 | date && `on ${format(parseISO(date), 'LLL d, yyyy')}`, 107 | ].filter(Boolean) 108 | 109 | return { title, media, subtitle: subtitles.join(' ') } 110 | }, 111 | }, 112 | }) 113 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/api/revalidate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This code is responsible for revalidating the cache when a post or author is updated. 3 | * 4 | * It is set up to receive a validated GROQ-powered Webhook from Sanity.io: 5 | * https://www.sanity.io/docs/webhooks 6 | * 7 | * 1. Go to the API section of your Sanity project on sanity.io/manage or run `npx sanity hook create` 8 | * 2. Click "Create webhook" 9 | * 3. Set the URL to https://YOUR_NEXTJS_SITE_URL/api/revalidate 10 | * 4. Dataset: Choose desired dataset or leave at default "all datasets" 11 | * 5. Trigger on: "Create", "Update", and "Delete" 12 | * 6. Filter: _type == "post" || _type == "author" || _type == "settings" 13 | * 7. Projection: Leave empty 14 | * 8. Status: Enable webhook 15 | * 9. HTTP method: POST 16 | * 10. HTTP Headers: Leave empty 17 | * 11. API version: v2021-03-25 18 | * 12. Include drafts: No 19 | * 13. Secret: Set to the same value as SANITY_REVALIDATE_SECRET (create a random secret if you haven't yet) 20 | * 14. Save the cofiguration 21 | * 15. Add the secret to Vercel: `npx vercel env add SANITY_REVALIDATE_SECRET` 22 | * 16. Redeploy with `npx vercel --prod` to apply the new environment variable 23 | */ 24 | 25 | import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook' 26 | import { apiVersion, dataset, projectId } from 'lib/sanity.api' 27 | import type { NextApiRequest, NextApiResponse } from 'next' 28 | import { 29 | createClient, 30 | groq, 31 | type SanityClient, 32 | type SanityDocument, 33 | } from 'next-sanity' 34 | import type { ParsedBody } from 'next-sanity/webhook' 35 | 36 | export const config = { 37 | api: { 38 | /** 39 | * Next.js will by default parse the body, which can lead to invalid signatures. 40 | */ 41 | bodyParser: false, 42 | }, 43 | } 44 | 45 | export default async function revalidate( 46 | req: NextApiRequest, 47 | res: NextApiResponse, 48 | ) { 49 | try { 50 | const { body, isValidSignature } = await parseBody( 51 | req, 52 | process.env.SANITY_REVALIDATE_SECRET, 53 | ) 54 | if (!isValidSignature) { 55 | const message = 'Invalid signature' 56 | console.log(message) 57 | return res.status(401).send(message) 58 | } 59 | 60 | if (typeof body?._id !== 'string' || !body?._id) { 61 | const invalidId = 'Invalid _id' 62 | console.error(invalidId, { body }) 63 | return res.status(400).send(invalidId) 64 | } 65 | 66 | const staleRoutes = await queryStaleRoutes(body as any) 67 | await Promise.all(staleRoutes.map((route) => res.revalidate(route))) 68 | 69 | const updatedRoutes = `Updated routes: ${staleRoutes.join(', ')}` 70 | console.log(updatedRoutes) 71 | return res.status(200).send(updatedRoutes) 72 | } catch (err) { 73 | console.error(err) 74 | return res.status(500).send(err.message) 75 | } 76 | } 77 | 78 | async function parseBody( 79 | req: NextApiRequest, 80 | secret?: string, 81 | waitForContentLakeEventualConsistency: boolean = true, 82 | ): Promise> { 83 | let signature = req.headers[SIGNATURE_HEADER_NAME] 84 | if (Array.isArray(signature)) { 85 | signature = signature[0] 86 | } 87 | if (!signature) { 88 | console.error('Missing signature header') 89 | return { body: null, isValidSignature: null } 90 | } 91 | 92 | if (req.readableEnded) { 93 | throw new Error( 94 | `Request already ended and the POST body can't be read. Have you setup \`export {config} from 'next-sanity/webhook' in your webhook API handler?\``, 95 | ) 96 | } 97 | 98 | const body = await readBody(req) 99 | const validSignature = secret 100 | ? await isValidSignature(body, signature, secret.trim()) 101 | : null 102 | 103 | if (validSignature !== false && waitForContentLakeEventualConsistency) { 104 | await new Promise((resolve) => setTimeout(resolve, 1000)) 105 | } 106 | 107 | return { 108 | body: body.trim() ? JSON.parse(body) : null, 109 | isValidSignature: validSignature, 110 | } 111 | } 112 | 113 | async function readBody(readable: NextApiRequest): Promise { 114 | const chunks = [] 115 | for await (const chunk of readable) { 116 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) 117 | } 118 | return Buffer.concat(chunks).toString('utf8') 119 | } 120 | 121 | type StaleRoute = '/' | `/posts/${string}` 122 | 123 | async function queryStaleRoutes( 124 | body: Pick< 125 | ParsedBody['body'], 126 | '_type' | '_id' | 'date' | 'slug' 127 | >, 128 | ): Promise { 129 | const client = createClient({ projectId, dataset, apiVersion, useCdn: false }) 130 | 131 | // Handle possible deletions 132 | if (body._type === 'post') { 133 | const exists = await client.fetch(groq`*[_id == $id][0]`, { id: body._id }) 134 | if (!exists) { 135 | const staleRoutes: StaleRoute[] = ['/'] 136 | if ((body.slug as any)?.current) { 137 | staleRoutes.push(`/posts/${(body.slug as any).current}`) 138 | } 139 | // Assume that the post document was deleted. Query the datetime used to sort "More stories" to determine if the post was in the list. 140 | const moreStories = await client.fetch( 141 | groq`count( 142 | *[_type == "post"] | order(date desc, _updatedAt desc) [0...3] [dateTime(date) > dateTime($date)] 143 | )`, 144 | { date: body.date }, 145 | ) 146 | // If there's less than 3 posts with a newer date, we need to revalidate everything 147 | if (moreStories < 3) { 148 | return [...new Set([...(await queryAllRoutes(client)), ...staleRoutes])] 149 | } 150 | return staleRoutes 151 | } 152 | } 153 | 154 | switch (body._type) { 155 | case 'author': 156 | return await queryStaleAuthorRoutes(client, body._id) 157 | case 'post': 158 | return await queryStalePostRoutes(client, body._id) 159 | case 'settings': 160 | return await queryAllRoutes(client) 161 | default: 162 | throw new TypeError(`Unknown type: ${body._type}`) 163 | } 164 | } 165 | 166 | async function _queryAllRoutes(client: SanityClient): Promise { 167 | return await client.fetch(groq`*[_type == "post"].slug.current`) 168 | } 169 | 170 | async function queryAllRoutes(client: SanityClient): Promise { 171 | const slugs = await _queryAllRoutes(client) 172 | 173 | return ['/', ...slugs.map((slug) => `/posts/${slug}` as StaleRoute)] 174 | } 175 | 176 | async function mergeWithMoreStories( 177 | client, 178 | slugs: string[], 179 | ): Promise { 180 | const moreStories = await client.fetch( 181 | groq`*[_type == "post"] | order(date desc, _updatedAt desc) [0...3].slug.current`, 182 | ) 183 | if (slugs.some((slug) => moreStories.includes(slug))) { 184 | const allSlugs = await _queryAllRoutes(client) 185 | return [...new Set([...slugs, ...allSlugs])] 186 | } 187 | 188 | return slugs 189 | } 190 | 191 | async function queryStaleAuthorRoutes( 192 | client: SanityClient, 193 | id: string, 194 | ): Promise { 195 | let slugs = await client.fetch( 196 | groq`*[_type == "author" && _id == $id] { 197 | "slug": *[_type == "post" && references(^._id)].slug.current 198 | }["slug"][]`, 199 | { id }, 200 | ) 201 | 202 | if (slugs.length > 0) { 203 | slugs = await mergeWithMoreStories(client, slugs) 204 | return ['/', ...slugs.map((slug) => `/posts/${slug}`)] 205 | } 206 | 207 | return [] 208 | } 209 | 210 | async function queryStalePostRoutes( 211 | client: SanityClient, 212 | id: string, 213 | ): Promise { 214 | let slugs = await client.fetch( 215 | groq`*[_type == "post" && _id == $id].slug.current`, 216 | { id }, 217 | ) 218 | 219 | slugs = await mergeWithMoreStories(client, slugs) 220 | 221 | return ['/', ...slugs.map((slug) => `/posts/${slug}`)] 222 | } 223 | -------------------------------------------------------------------------------- /intro-template/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import { useSyncExternalStore } from 'react' 4 | 5 | import cover from './cover.png' 6 | 7 | const subscribe = () => () => {} 8 | 9 | export default function IntroTemplate() { 10 | const mounted = useSyncExternalStore( 11 | subscribe, 12 | () => true, 13 | () => false, 14 | ) 15 | const studioURL = mounted ? `${window.location.origin}/studio` : null 16 | const createPostURL = mounted 17 | ? `${window.location.origin}/studio/intent/create/template=post;type=post/` 18 | : null 19 | const isLocalHost = mounted ? window.location.hostname === 'localhost' : false 20 | const hasUTMtags = mounted ? window.location.search.includes('utm') : false 21 | 22 | const hasEnvFile = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 23 | const hasRepoEnvVars = 24 | process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER && 25 | process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER && 26 | process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG 27 | const repoURL = `https://${process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER}.com/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER}/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG}` 28 | const removeBlockURL = hasRepoEnvVars 29 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER}.com/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER}/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG}/blob/main/README.md#how-can-i-remove-the-next-steps-block-from-my-blog` 30 | : `https://github.com/sanity-io/nextjs-blog-cms-sanity-v3#how-can-i-remove-the-next-steps-block-from-my-blog` 31 | 32 | if (hasUTMtags || !studioURL) { 33 | return 34 | } 35 | 36 | return ( 37 |
38 |
39 |
40 | An illustration of a browser window, a terminal window, the Sanity.io logo and the NextJS logo 44 |
45 | 46 |
47 |
48 | 49 |
50 |

51 | Next steps 52 |

53 | 54 | {!hasEnvFile && ( 55 |
59 | {`It looks like you haven't set up the local environment variables.`} 60 |

61 | 69 | {`Here's how to set them up locally`} 70 | 71 |

72 |
73 | )} 74 | 75 |
    76 | 80 |
    81 | Create content with Sanity Studio 82 |
    83 |
    84 | Your Sanity Studio is deployed at 85 | 89 | {studioURL} 90 | 91 |
    92 | 93 |
    94 | 98 | Go to Sanity Studio 99 | 100 |
    101 |
102 | } 103 | /> 104 | 105 | 109 |
110 | Modify and deploy the project 111 |
112 | 113 | {isLocalHost ? ( 114 |
115 | Start editing your content structure by changing the post 116 | schema in 117 |
118 |
schemas/post.ts
119 |
120 |
121 | ) : ( 122 | <> 123 |
124 | Your code can be found at 125 | 131 | {repoURL} 132 | 133 |
134 | 135 | 145 | 146 | )} 147 |
148 | } 149 | /> 150 | 151 | 155 |
156 | Learn more and get help 157 |
158 |
    159 |
  • 160 | 164 |
  • 165 |
  • 166 | 170 |
  • 171 |
  • 172 | 176 |
  • 177 |
178 |
179 | } 180 | /> 181 | 182 |
183 | 184 |
185 |
186 | 187 | 188 | ) 189 | } 190 | 191 | function Box({ 192 | circleTitle, 193 | element, 194 | }: { 195 | circleTitle: string 196 | element: React.JSX.Element 197 | }) { 198 | return ( 199 |
  • 200 |
    201 |
    202 | {circleTitle} 203 |
    204 |
    205 | {element} 206 |
  • 207 | ) 208 | } 209 | 210 | function BlueLink({ href, text }: { href: string; text: string }) { 211 | return ( 212 | 218 | {text} 219 | 220 | ) 221 | } 222 | 223 | const RemoveBlock = ({ url }) => ( 224 | 230 | How to remove this block? 231 | 232 | ) 233 | 234 | function getGitProvider() { 235 | switch (process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER) { 236 | case 'gitlab': 237 | return 'GitLab' 238 | case 'bitbucket': 239 | return 'Bitbucket' 240 | default: 241 | return 'GitHub' 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Next.js Blog with a Native Authoring Experience 2 | 3 | This starter is a statically generated blog that uses [Next.js][nextjs] for the frontend and [Sanity][sanity-homepage] to handle its content. It comes with a native Sanity Studio that offers features like real-time collaboration and visual editing with live updates using [Presentation][presentation]. 4 | 5 | The Studio connects to Sanity Content Lake, which gives you hosted content APIs with a flexible query language, on-demand image transformations, powerful patching, and more. You can use this starter to kick-start a blog or learn these technologies. 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] 8 | 9 | > [!NOTE] 10 | > This starter uses the Next.js [Pages Router](https://nextjs.org/docs/pages). [An App Router example is also available.](https://github.com/vercel/next.js/tree/canary/examples/cms-sanity#readme) 11 | 12 | ## Features 13 | 14 | - A performant, static blog with editable posts, authors, and site settings 15 | - A native and customizable authoring environment, accessible on `yourblog.com/studio` 16 | - Real-time and collaborative content editing with fine-grained revision history 17 | - Side-by-side instant content preview that works across your whole site 18 | - Support for block content and the most advanced custom fields capability in the industry 19 | - Webhook-triggered Incremental Static Revalidation; no need to wait for a rebuild to publish new content 20 | - Free and boosted Sanity project with unlimited admin users, free content updates, and pay-as-you-go for API overages 21 | - A project with starter-friendly and not too heavy-handed TypeScript and Tailwind.css 22 | 23 | ## Table of Contents 24 | 25 | - [Features](#features) 26 | - [Table of Contents](#table-of-contents) 27 | - [Project Overview](#project-overview) 28 | - [Important files and folders](#important-files-and-folders) 29 | - [Configuration](#configuration) 30 | - [Step 1. Set up the environment](#step-1-set-up-the-environment) 31 | - [Step 2. Set up the project locally](#step-2-set-up-the-project-locally) 32 | - [Step 3. Run Next.js locally in development mode](#step-3-run-nextjs-locally-in-development-mode) 33 | - [Step 4. Deploy to production](#step-4-deploy-to-production) 34 | - [Questions and Answers](#questions-and-answers) 35 | - [It doesn't work! Where can I get help?](#it-doesnt-work-where-can-i-get-help) 36 | - [How can I remove the "Next steps" block from my blog?](#how-can-i-remove-the-next-steps-block-from-my-blog) 37 | - [How can I set up Incremental Static Revalidation?](#how-can-i-set-up-incremental-static-revalidation) 38 | - [Next steps](#next-steps) 39 | 40 | ## Project Overview 41 | 42 | | [Blog](https://nextjs-blog.sanity.build) | [Studio](https://nextjs-blog.sanity.build/studio) | 43 | | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | 44 | | ![Blog](https://github.com/sanity-io/nextjs-blog-cms-sanity-v3/assets/81981/adc1a90e-644e-456a-b630-ac44e4636e24) | ![Sanity Studio](https://github.com/sanity-io/nextjs-blog-cms-sanity-v3/assets/81981/93a39af1-a806-45ca-8648-0cc7e2295eea) | 45 | 46 | ### Important files and folders 47 | 48 | | File(s) | Description | 49 | | ------------------------------------------- | -------------------------------------------------------- | 50 | | `sanity.config.ts` |  Config file for Sanity Studio | 51 | | `sanity.cli.ts` |  Config file for Sanity CLI | 52 | | `/pages/api/preview-mode/enable.ts` |  Serverless route for triggering Draft mode | 53 | | `/app/studio/[[...index]]/page.tsx` |  Where Sanity Studio is mounted | 54 | | `/pages/api/revalidate.ts` |  Serverless route for triggering ISR | 55 | | `/schemas` |  Where Sanity Studio gets its content types from | 56 | | `/plugins` |  Where the advanced Sanity Studio customization is setup | 57 | | `/lib/sanity.api.ts`,`/lib/sanity.image.ts` | Configuration for the Sanity Content Lake client | 58 | | `/components/PreviewProvider.tsx` | Configuration for the live Preview Mode | 59 | 60 | ## Configuration 61 | 62 | ### Step 1. Set up the environment 63 | 64 | Use the Deploy Button below. It will let you deploy the starter using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-sanity-example) as well as connect it to your Sanity Content Lake using [the Sanity Vercel Integration][integration]. 65 | 66 | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] 67 | 68 | ### Step 2. Set up the project locally 69 | 70 | [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) that was created for you on your GitHub account. Once cloned, run the following command from the project's root directory: 71 | 72 | ```bash 73 | npx vercel link 74 | ``` 75 | 76 | Download the environment variables needed to connect Next.js and the Studio to your Sanity project: 77 | 78 | ```bash 79 | npx vercel env pull 80 | ``` 81 | 82 | ### Step 3. Run Next.js locally in development mode 83 | 84 | ```bash 85 | npm install && npm run dev 86 | ``` 87 | 88 | When you run this development server, the changes you make in your frontend and studio configuration will be applied live using hot reloading. 89 | 90 | Your blog should be up and running on [http://localhost:3000][localhost-3000]! You can create and edit content on [http://localhost:3000/studio][localhost-3000-studio]. 91 | 92 | ### Step 4. Deploy to production 93 | 94 | To deploy your changes to production you use `git`: 95 | 96 | ```bash 97 | git add . 98 | git commit 99 | git push 100 | ``` 101 | 102 | Alternatively, you can deploy without a `git` hosting provider using the Vercel CLI: 103 | 104 | ```bash 105 | npx vercel --prod 106 | ``` 107 | 108 | ## Questions and Answers 109 | 110 | ### It doesn't work! Where can I get help? 111 | 112 | In case of any issues or questions, you can post: 113 | 114 | - [GitHub Discussions for Next.js][vercel-github] 115 | - [Sanity's GitHub Discussions][sanity-github] 116 | - [Sanity's Community Slack][sanity-community] 117 | 118 | ### How can I remove the "Next steps" block from my blog? 119 | 120 | You can remove it by deleting the `IntroTemplate` component in `/components/IndexPage.tsx`. 121 | 122 | ### How can I set up Incremental Static Revalidation? 123 | 124 | Go to the serverless function code in `/pages/api/revalidate.ts`. In the code comments, you'll find instructions for how to set up [ISR][vercel-isr]. 125 | 126 | ## Next steps 127 | 128 | - [Join our Slack community to ask questions and get help][sanity-community] 129 | - [How to edit my content structure?][sanity-schema-types] 130 | - [How to query content?][sanity-groq] 131 | - [What is content modelling?][sanity-content-modelling] 132 | 133 | [vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsanity-io%2Fnextjs-blog-cms-sanity-v3&repository-name=blog-nextjs-sanity&project-name=blog-nextjs-sanity&demo-title=Blog%20with%20Built-in%20Content%20Editing&demo-description=A%20Sanity-powered%20blog%20with%20built-in%20content%20editing%20%26%20instant%20previews&demo-url=https%3A%2F%2Fnextjs-blog.sanity.build%2F%3Futm_source%3Dvercel%26utm_medium%3Dreferral&demo-image=https%3A%2F%2Fuser-images.githubusercontent.com%2F81981%2F197501516-c7c8092d-0305-4abe-afb7-1e896ef7b90a.png&integration-ids=oac_hb2LITYajhRQ0i4QznmKH7gx&external-id=nextjs;template=nextjs-blog-cms-sanity-v3 134 | [integration]: https://www.sanity.io/docs/vercel-integration?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 135 | [`.env.local.example`]: .env.local.example 136 | [nextjs]: https://github.com/vercel/next.js 137 | [sanity-create]: https://www.sanity.io/get-started/create-project?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 138 | [sanity-deployment]: https://www.sanity.io/docs/deployment?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 139 | [sanity-homepage]: https://www.sanity.io?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 140 | [sanity-community]: https://slack.sanity.io/ 141 | [sanity-schema-types]: https://www.sanity.io/docs/schema-types?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 142 | [sanity-github]: https://github.com/sanity-io/sanity/discussions 143 | [sanity-groq]: https://www.sanity.io/docs/groq?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 144 | [sanity-content-modelling]: https://www.sanity.io/docs/content-modelling?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 145 | [sanity-webhooks]: https://www.sanity.io/docs/webhooks?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 146 | [localhost-3000]: http://localhost:3000 147 | [localhost-3000-studio]: http://localhost:3000/studio 148 | [vercel-isr]: https://nextjs.org/blog/next-12-1#on-demand-incremental-static-regeneration-beta 149 | [vercel]: https://vercel.com 150 | [vercel-github]: https://github.com/vercel/next.js/discussions 151 | [app-dir]: https://beta.nextjs.org/docs/routing/fundamentals#the-app-directory 152 | [presentation]: https://www.sanity.io/docs/presentation 153 | -------------------------------------------------------------------------------- /public/og.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /components/OpenGraphImage.tsx: -------------------------------------------------------------------------------- 1 | // Renders the Open Graph image used on the home page 2 | 3 | export const width = 1200 4 | export const height = 630 5 | 6 | export function OpenGraphImage(props: { title: string }) { 7 | const { title } = props 8 | return ( 9 |
    26 |
    31 |
    38 | 45 | 53 | 54 | 60 | 66 | 72 | 78 | 85 | 91 | 97 | 103 | 104 | 105 | 112 | 119 | 126 | 133 | 140 | 146 | 152 | 158 | 164 | 171 | 177 | 184 | 190 | 197 | 204 | 211 | 212 | 213 |
    214 |
    227 | {title} 228 |
    229 |
    234 |
    235 | ) 236 | } 237 | --------------------------------------------------------------------------------