Skip to content

Commit

Permalink
docs(examples): use vercel integration in cms-sanity
Browse files Browse the repository at this point in the history
  • Loading branch information
stipsan committed Aug 11, 2022
1 parent a9b415b commit c5f5357
Show file tree
Hide file tree
Showing 35 changed files with 663 additions and 246 deletions.
4 changes: 1 addition & 3 deletions examples/cms-sanity/.env.local.example
@@ -1,5 +1,3 @@
NEXT_PUBLIC_SANITY_PROJECT_ID=
NEXT_PUBLIC_SANITY_DATASET=
SANITY_API_TOKEN=
SANITY_PREVIEW_SECRET=
SANITY_STUDIO_REVALIDATE_SECRET=
SANITY_API_READ_TOKEN=
6 changes: 6 additions & 0 deletions examples/cms-sanity/.gitignore
Expand Up @@ -2,6 +2,7 @@

# dependencies
/node_modules
/studio/node_modules
/.pnp
.pnp.js

Expand All @@ -14,6 +15,7 @@

# production
/build
/studio/dist

# misc
.DS_Store
Expand All @@ -34,3 +36,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# Env files created by scripts for working locally
.env
studio/.env.development
382 changes: 278 additions & 104 deletions examples/cms-sanity/README.md

Large diffs are not rendered by default.

13 changes: 9 additions & 4 deletions examples/cms-sanity/components/avatar.js
@@ -1,14 +1,19 @@
import Image from 'next/image'
import Image from 'next/future/image'
import { urlForImage } from '../lib/sanity'

export default function Avatar({ name, picture }) {
return (
<div className="flex items-center">
<div className="w-12 h-12 relative mr-4">
<div className="relative w-12 h-12 mr-4">
<Image
src={urlForImage(picture).height(96).width(96).fit('crop').url()}
layout="fill"
src={
picture?.asset?._ref
? urlForImage(picture).height(96).width(96).fit('crop').url()
: 'https://source.unsplash.com/96x96/?face'
}
className="rounded-full"
height={96}
width={96}
alt={name}
/>
</div>
Expand Down
9 changes: 6 additions & 3 deletions examples/cms-sanity/components/cover-image.js
@@ -1,21 +1,24 @@
import cn from 'classnames'
import Image from 'next/image'
import Image from 'next/future/image'
import Link from 'next/link'
import { urlForImage } from '../lib/sanity'

export default function CoverImage({ title, slug, image: source }) {
const image = source ? (
export default function CoverImage({ title, slug, image: source, priority }) {
const image = source?.asset?._ref ? (
<div
className={cn('shadow-small', {
'hover:shadow-medium transition-shadow duration-200': slug,
})}
>
<Image
className="w-full h-auto"
layout="responsive"
width={2000}
height={1000}
alt={`Cover Image for ${title}`}
src={urlForImage(source).height(1000).width(2000).url()}
sizes="100vw"
priority={priority}
/>
</div>
) : (
Expand Down
2 changes: 2 additions & 0 deletions examples/cms-sanity/components/date.js
@@ -1,6 +1,8 @@
import { parseISO, format } from 'date-fns'

export default function Date({ dateString }) {
if (!dateString) return null

const date = parseISO(dateString)
return <time dateTime={dateString}>{format(date, 'LLLL d, yyyy')}</time>
}
12 changes: 6 additions & 6 deletions examples/cms-sanity/components/hero-post.js
Expand Up @@ -14,22 +14,22 @@ export default function HeroPost({
return (
<section>
<div className="mb-8 md:mb-16">
<CoverImage slug={slug} title={title} image={coverImage} />
<CoverImage slug={slug} title={title} image={coverImage} priority />
</div>
<div className="md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8 mb-20 md:mb-28">
<div className="mb-20 md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8 md:mb-28">
<div>
<h3 className="mb-4 text-4xl lg:text-6xl leading-tight">
<h3 className="mb-4 text-4xl leading-tight lg:text-6xl">
<Link href={`/posts/${slug}`}>
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="mb-4 md:mb-0 text-lg">
<div className="mb-4 text-lg md:mb-0">
<Date dateString={date} />
</div>
</div>
<div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
<p className="mb-4 text-lg leading-relaxed">{excerpt}</p>
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
</div>
</section>
Expand Down
8 changes: 4 additions & 4 deletions examples/cms-sanity/components/post-header.js
Expand Up @@ -8,14 +8,14 @@ export default function PostHeader({ title, coverImage, date, author }) {
<>
<PostTitle>{title}</PostTitle>
<div className="hidden md:block md:mb-12">
<Avatar name={author.name} picture={author.picture} />
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
<div className="mb-8 md:mb-16 sm:mx-0">
<CoverImage title={title} image={coverImage} />
<CoverImage title={title} image={coverImage} priority />
</div>
<div className="max-w-2xl mx-auto">
<div className="block md:hidden mb-6">
<Avatar name={author.name} picture={author.picture} />
<div className="block mb-6 md:hidden">
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
<div className="mb-6 text-lg">
<Date dateString={date} />
Expand Down
8 changes: 4 additions & 4 deletions examples/cms-sanity/components/post-preview.js
Expand Up @@ -16,16 +16,16 @@ export default function PostPreview({
<div className="mb-5">
<CoverImage slug={slug} title={title} image={coverImage} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<h3 className="mb-3 text-3xl leading-snug">
<Link href={`/posts/${slug}`}>
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="text-lg mb-4">
<div className="mb-4 text-lg">
<Date dateString={date} />
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
<p className="mb-4 text-lg leading-relaxed">{excerpt}</p>
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
)
}
5 changes: 3 additions & 2 deletions examples/cms-sanity/lib/config.js
Expand Up @@ -2,13 +2,14 @@ export const sanityConfig = {
// Find your project ID and dataset in `sanity.json` in your studio project
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
useCdn: process.env.NODE_ENV !== 'production',
useCdn:
typeof document !== 'undefined' && process.env.NODE_ENV === 'production',
// useCdn == true gives fast, cheap responses using a globally distributed cache.
// When in production the Sanity API is only queried on build-time, and on-demand when responding to webhooks.
// Thus the data need to be fresh and API response time is less important.
// When in development/working locally, it's more important to keep costs down as hot reloading can incurr a lot of API calls
// And every page load calls getStaticProps.
// To get the lowest latency, lowest cost, and latest data, use the Instant Preview mode
apiVersion: '2021-03-25',
apiVersion: '2022-03-13',
// see https://www.sanity.io/docs/api-versioning for how versioning works
}
5 changes: 4 additions & 1 deletion examples/cms-sanity/lib/sanity.server.js
Expand Up @@ -11,7 +11,10 @@ export const sanityClient = createClient(sanityConfig)
export const previewClient = createClient({
...sanityConfig,
useCdn: false,
token: process.env.SANITY_API_TOKEN,
// Fallback to using the WRITE token until https://www.sanity.io/docs/vercel-integration starts shipping a READ token.
// As this client only exists on the server and the token is never shared with the browser, we ddon't risk escalating permissions to untrustworthy users
token:
process.env.SANITY_API_READ_TOKEN || process.env.SANITY_API_WRITE_TOKEN,
})

export const getClient = (preview) => (preview ? previewClient : sanityClient)
Expand Down
7 changes: 6 additions & 1 deletion examples/cms-sanity/next.config.js
@@ -1,5 +1,10 @@
module.exports = {
experimental: {
images: {
allowFutureImage: true,
},
},
images: {
domains: ['cdn.sanity.io'],
domains: ['cdn.sanity.io', 'source.unsplash.com'],
},
}
20 changes: 11 additions & 9 deletions examples/cms-sanity/package.json
Expand Up @@ -3,22 +3,24 @@
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
"start": "next start",
"studio:dev": "npm --prefix studio run start",
"studio:deploy": "npx vercel env pull && npm --prefix studio run deploy"
},
"dependencies": {
"@portabletext/react": "^1.0.3",
"@portabletext/react": "^1.0.6",
"@sanity/image-url": "^1.0.1",
"@sanity/webhook": "^1.0.2",
"classnames": "2.3.1",
"date-fns": "2.28.0",
"@sanity/webhook": "^2.0.0",
"classnames": "^2.3.1",
"date-fns": "^2.29.1",
"next": "latest",
"next-sanity": "0.5.0",
"next-sanity": "^0.6.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"autoprefixer": "10.4.2",
"postcss": "8.4.7",
"tailwindcss": "^3.0.23"
"autoprefixer": "^10.4.8",
"postcss": "^8.4.14",
"tailwindcss": "^3.1.7"
}
}
33 changes: 21 additions & 12 deletions examples/cms-sanity/pages/api/preview.js
@@ -1,14 +1,27 @@
import { postBySlugQuery } from '../../lib/queries'
import { previewClient } from '../../lib/sanity.server'

function redirectToPreview(res, Location) {
// Enable Preview Mode by setting the cookies
res.setPreviewData({})
// Redirect to a preview capable route
res.writeHead(307, { Location })
res.end()
}

export default async function preview(req, res) {
// Check the secret and next parameters
// This secret should only be known to this API route and the CMS
if (
req.query.secret !== process.env.SANITY_PREVIEW_SECRET ||
!req.query.slug
) {
return res.status(401).json({ message: 'Invalid token' })
const secret = process.env.SANITY_STUDIO_PREVIEW_SECRET
// Only require a secret when in production
if (!secret && process.env.NODE_ENV === 'production') {
throw new TypeError(`Missing SANITY_STUDIO_PREVIEW_SECRET`)
}
// Check the secret if it's provided, enables running preview mode locally before the env var is setup
if (secret && req.query.secret !== secret) {
return res.status(401).json({ message: 'Invalid secret' })
}
// If no slug is provided open preview mode on the frontpage
if (!req.query.slug) {
return redirectToPreview(res, '/')
}

// Check if the post with the given `slug` exists
Expand All @@ -21,11 +34,7 @@ export default async function preview(req, res) {
return res.status(401).json({ message: 'Invalid slug' })
}

// Enable Preview Mode by setting the cookies
res.setPreviewData({})

// Redirect to the path from the fetched post
// We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
res.writeHead(307, { Location: `/posts/${post.slug}` })
res.end()
redirectToPreview(res, `/posts/${post.slug}`)
}
41 changes: 33 additions & 8 deletions examples/cms-sanity/pages/api/revalidate.js
@@ -1,11 +1,18 @@
import { isValidRequest } from '@sanity/webhook'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
import { sanityClient } from '../../lib/sanity.server'

const AUTHOR_UPDATED_QUERY = `
// Next.js will by default parse the body, which can lead to invalid signatures
export const config = {
api: {
bodyParser: false,
},
}

const AUTHOR_UPDATED_QUERY = /* groq */ `
*[_type == "author" && _id == $id] {
"slug": *[_type == "post" && references(^._id)].slug.current
}["slug"][]`
const POST_UPDATED_QUERY = `*[_type == "post" && _id == $id].slug.current`
const POST_UPDATED_QUERY = /* groq */ `*[_type == "post" && _id == $id].slug.current`

const getQueryForType = (type) => {
switch (type) {
Expand All @@ -21,14 +28,32 @@ const getQueryForType = (type) => {
const log = (msg, error) =>
console[error ? 'error' : 'log'](`[revalidate] ${msg}`)

async function readBody(readable) {
const chunks = []
for await (const chunk of readable) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
}
return Buffer.concat(chunks).toString('utf8')
}

export default async function revalidate(req, res) {
if (!isValidRequest(req, process.env.SANITY_STUDIO_REVALIDATE_SECRET)) {
const invalidRequest = 'Invalid request'
log(invalidRequest, true)
return res.status(401).json({ message: invalidRequest })
const signature = req.headers[SIGNATURE_HEADER_NAME]
const body = await readBody(req) // Read the body into a string
if (
!isValidSignature(
body,
signature,
process.env.SANITY_REVALIDATE_SECRET?.trim()
)
) {
const invalidSignature = 'Invalid signature'
log(invalidSignature, true)
res.status(401).json({ success: false, message: invalidSignature })
return
}

const { _id: id, _type } = req.body
const jsonBody = JSON.parse(body)
const { _id: id, _type } = jsonBody
if (typeof id !== 'string' || !id) {
const invalidId = 'Invalid _id'
log(invalidId, true)
Expand Down
12 changes: 9 additions & 3 deletions examples/cms-sanity/pages/index.js
Expand Up @@ -6,11 +6,15 @@ import Intro from '../components/intro'
import Layout from '../components/layout'
import { CMS_NAME } from '../lib/constants'
import { indexQuery } from '../lib/queries'
import { usePreviewSubscription } from '../lib/sanity'
import { getClient, overlayDrafts } from '../lib/sanity.server'

export default function Index({ allPosts, preview }) {
const heroPost = allPosts[0]
const morePosts = allPosts.slice(1)
export default function Index({ allPosts: initialAllPosts, preview }) {
const { data: allPosts } = usePreviewSubscription(indexQuery, {
initialData: initialAllPosts,
enabled: preview,
})
const [heroPost, ...morePosts] = allPosts || []
return (
<>
<Layout preview={preview}>
Expand Down Expand Up @@ -40,5 +44,7 @@ export async function getStaticProps({ preview = false }) {
const allPosts = overlayDrafts(await getClient(preview).fetch(indexQuery))
return {
props: { allPosts, preview },
// If webhooks isn't setup then attempt to re-generate in 1 minute intervals
revalidate: process.env.SANITY_REVALIDATE_SECRET ? undefined : 60,
}
}
4 changes: 3 additions & 1 deletion examples/cms-sanity/pages/posts/[slug].js
Expand Up @@ -43,7 +43,7 @@ export default function Post({ data = {}, preview }) {
<title>
{post.title} | Next.js Blog Example with {CMS_NAME}
</title>
{post.coverImage && (
{post.coverImage?.asset?._ref && (
<meta
key="ogImage"
property="og:image"
Expand Down Expand Up @@ -85,6 +85,8 @@ export async function getStaticProps({ params, preview = false }) {
morePosts: overlayDrafts(morePosts),
},
},
// If webhooks isn't setup then attempt to re-generate in 1 minute intervals
revalidate: process.env.SANITY_REVALIDATE_SECRET ? undefined : 60,
}
}

Expand Down

0 comments on commit c5f5357

Please sign in to comment.