├── .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 {format(date, 'LLLL d, yyyy')}
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 |
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 |
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 |
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 |
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 |
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 |
17 |
18 |
19 |
20 |
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 |
48 |
49 |
50 |
51 | Next steps
52 |
53 |
54 | {!hasEnvFile && (
55 |
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 |
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 | [][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 | |  |  |
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 | [][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 |
--------------------------------------------------------------------------------