CMS가 없는 블로그는 끝없는 좌절과 시간 낭비로 이어질 수 있습니다. Sanity.io는 전체 프로세스를 단순화하여 콘텐츠에 집중할 수 있도록 해줍니다.
오늘 기사에서는 Sanity CMS와 Next.js 14를 사용하여 블로그를 구축하는 방법을 알아봅니다.
이 가이드가 끝나면 모든 기능을 갖추고 쉽게 관리할 수 있는 블로그를 만들고 실행할 수 있게 됩니다.
이 기사를 따라가려면 다음 기술이 필요합니다.
Sanity는 콘텐츠 관리를 간단하고 효율적으로 만들어주는 헤드리스 CMS입니다. Sanity Studio의 직관적인 대시보드를 사용하면 원하는 방식으로 콘텐츠를 쉽게 생성, 편집, 구성할 수 있습니다.
Sanity는 유연한 API와 웹훅 지원도 제공하므로 콘텐츠가 전달되는 방법과 위치를 완벽하게 제어할 수 있습니다. 웹사이트, 모바일 앱 또는 기타 플랫폼에 관계없이 Sanity는 귀하의 콘텐츠가 항상 최신 상태이고 액세스 가능하도록 보장합니다.
우리는 Sanity를 Next.js 프로젝트에 통합하고 있습니다. 따라서 먼저 next.js 프로젝트를 설정해야 합니다.
next.js 프로젝트를 생성하려면 아래 명령을 실행하세요.
npx create-next-app@latest
터미널의 지침에 따라 이름을 선택한 후 기본 제안을 사용할 수 있습니다.
이렇게 하면 기본 Next.js 프로젝트가 생성됩니다.
이제 코드 편집기에서 프로젝트를 열어보겠습니다.
cd sanity-blog code .
이제 dev 명령을 실행하여 Localhost:3000에서 프로젝트를 엽니다
npm run dev
Sanity Studio는 콘텐츠를 관리하는 대시보드입니다.
Studio를 독립적으로 구축하고 배포할 수 있습니다. 하지만 우리는 Next.js 프로젝트에 Studio를 포함시킬 것입니다. 유지관리와 사용이 쉽습니다.
그래서 Sanity 프로젝트를 생성한 다음 이를 Next.js 프로젝트에 통합하겠습니다.
Sanity 프로젝트를 초기화하려면 이 명령을 실행하세요.
npm create sanity@latest
이 명령을 실행하면 Sanity에 로그인하라는 메시지가 표시됩니다. 이미 계정이 있는 경우 제공업체를 선택하고 계정에 로그인하세요.
계정이 없다면 계정을 생성한 후 설치 명령을 다시 한 번 실행해 보세요.
이 명령을 실행하면 프로젝트 구성에 대한 여러 가지 질문이 표시됩니다.
기본 옵션을 사용하셔도 됩니다.
프로젝트 이름만 있으면 되고 나머지는 크게 중요하지 않습니다.
Looks like you already have a Sanity-account. Sweet! ✔ Fetching existing projects ? Select project to use Create new project ? Your project name: sanity-blog Your content will be stored in a dataset that can be public or private, depending on whether you want to query your content with or without authentication. The default dataset configuration has a public dataset named "production". ? Use the default dataset configuration? Yes ✔ Creating dataset ? Project output path: /home/amrin/Desktop/writing/sanity-blog ? Select project template Clean project with no predefined schema types ? Do you want to use TypeScript? Yes ✔ Bootstrapping files from template ✔ Resolving latest module versions ✔ Creating default project files ? Package manager to use for installing dependencies? npm Running 'npm install --legacy-peer-deps'
sanity studio를 Next.js 블로그에 통합하기 전에 이러한 종속성을 설치해야 합니다.
npm install sanity next-sanity --save
Sanity를 Next.js에 통합하려면 projectName과 projectID가 필요합니다. Sanity 대시보드에서 해당 정보를 확인할 수 있습니다.
sanity.io/manage로 이동하면 거기에 있는 모든 프로젝트를 볼 수 있습니다.
프로젝트 제목을 클릭하시면 자세한 내용을 보실 수 있습니다.
다음과 같은 내용이 표시됩니다.
계속해서 프로젝트 이름과 ProjectID를 복사하여 .env 파일에 추가하세요
NEXT_PUBLIC_SANITY_PROJECT_TITLE = ""; NEXT_PUBLIC_SANITY_PROJECT_ID = "";
이제 프로젝트 폴더의 루트에 구성 파일을 만듭니다. 그리고 이름을 sanity.config.ts
로 지정하세요.
import { defineConfig } from "sanity"; import {structureTool} from "sanity/structure"; import schemas from "@/sanity/schemas"; const config = defineConfig({ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID as string, title: process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE as string, dataset: "production", apiVersion: "2023-06-18", basePath: "/admin", plugins: [structureTool()], schema: { types: schemas }, }); export default config;
다음은 구성 파일 내용에 대한 간략한 개요입니다.
먼저 필요한 기능과 파일을 가져옵니다. 그런 다음 구성을 정의합니다. 구성에는 다양한 옵션이 제공됩니다:
projectId: 앞서 생성한 Sanity 프로젝트 ID입니다.
title: Sanity 프로젝트 제목
데이터 세트: Studio용 데이터 세트를 정의합니다.
basePath: Studio의 경로입니다. Studio에 액세스하기 위해 /admin 경로를 사용하고 있습니다. 원하는 경로를 선택하실 수 있습니다.
스키마: 콘텐츠의 스키마입니다. 스키마는 문서의 모양과 문서에 포함될 필드를 정의합니다. 게시물과 작성자, 카테고리 등에 대한 스키마가 있습니다.
아직 스키마가 없습니다. 잠시 후에 만들어 보겠습니다.
스튜디오를 설정하려면 먼저 경로가 필요합니다. src/app으로 이동한 다음 경로 그룹을 만들고 이름을 studio.로 지정하여 사이트와 스튜디오 경로를 구분하기 위해 그룹화합니다.
Inside the studio create an admin folder and inside that add a catch-all route.
└── (studio) ├── admin └── [[...index]] └── page.tsx
Include this code in the admin route. We are getting the sanity.config we created earlier and NextStudio from sanity Studio to initialize the Studio.
"use client"; import config from "../../../../../sanity.config"; import { NextStudio } from "next-sanity/studio"; export default function AdminPage() { return <NextStudio config={config} />; }
We are almost done setting up the studio.
Lastly, we need to write the schemas for the content. After that, we can take a look into the studio.
A Schema defines the structure of a document in the Studio. We define schema with properties.
Some of the properties are required and some are not.
The common properties are:
name: Name of the Schema. We will use this name to fetch the data.
title: Human readable title for the Schema. It will be visible in the Studio.
type: A valid document type.
fields: An array of all the properties of the document. If it’s a post schema the fields will have properties like Title, slug, body, meta description, etc. These properties will show up as input fields on the Studio.
Since we are building a blog we will have multiple Schemas such as:
To learn more about Sanity Schema Visit the documentation.
Create a folder named sanity inside the src directory.
Inside that create another folder named schemas and create index.ts and post.ts file
Here’s what the post Schema looks like.
const post = { name: "post", title: "Post", type: "document", fields: [ { name: "title", title: "Title", type: "string", validation: (Rule: any) => Rule.required(), }, { name: "metadata", title: "Metadata", type: "string", validation: (Rule: any) => Rule.required(), }, { name: "slug", title: "Slug", type: "slug", options: { source: "title", unique: true, slugify: (input: any) => { return input .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\w-]+/g, ""); }, }, validation: (Rule: any) => Rule.required().custom((fields: any) => { if ( fields?.current !== fields?.current?.toLowerCase() || fields?.current.split(" ").includes("") ) { return "Slug must be lowercase and not be included space"; } return true; }), }, { name: "tags", title: "Tags", type: "array", validation: (Rule: any) => Rule.required(), of: [ { type: "string", validation: (Rule: any) => Rule.custom((fields: any) => { if ( fields !== fields.toLowerCase() || fields.split(" ").includes("") ) { return "Tags must be lowercase and not be included space"; } return true; }), }, ], }, { name: "author", title: "Author", type: "reference", to: { type: "author" }, validation: (Rule: any) => Rule.required(), }, { name: "mainImage", title: "Main image", type: "image", options: { hotspot: true, }, // validation: (Rule: any) => Rule.required(), }, { name: "publishedAt", title: "Published at", type: "datetime", validation: (Rule: any) => Rule.required(), }, { name: "body", title: "Body", type: "blockContent", validation: (Rule: any) => Rule.required(), }, ], preview: { select: { title: "title", author: "author.name", media: "mainImage", }, prepare(selection: any) { const { author } = selection; return Object.assign({}, selection, { subtitle: author && `by ${author}`, }); }, }, }; export default post;
Copy the schema over to the post.ts file.
To save time we are not going to see the other schemas, you can get them from the repository.
Open up the index.ts file and add this code snippet.
import author from "./author"; import blockContent from "./blockContent"; import category from "./category"; import post from "./post"; const schemas = [post, author, category, blockContent]; export default schemas;
We are importing all the schema in this file and creating an array to load the schema on the studio.
Now you can add posts from the studio.
To create a new post, go to localhost:3000/admin you will see all the schemas there. Go ahead and create a few posts.
We integrated the Studio and created a few posts. Now we need a way to fetch those posts and render them on the home page.
We will use GROQ to do exactly that. GROQ is a query language designed to query large schema-less JSON data collection. With GROQ expressive filtering we can fetch data the way we want to use it. It can join and fetch from multiple documents.
To start using GROQ we need to create the config file and the queries.
Go ahead and create these files inside the sanity folder.
└── sanity ├── config │ └── client-config.ts ├── sanity-query.ts ├── sanity-utils.ts
Paste this code into the client-config.ts file.
import { ClientPerspective } from "next-sanity"; const config = { projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID as string, dataset: "production", apiVersion: "2023-03-09", useCdn: false, token: process.env.SANITY_API_KEY as string, perspective: 'published' as ClientPerspective, }; export default config;
This is the config for fetching the data with the GROQ query.
Here’s a quick break-down of the config options:
apiVersion: It’s the Sanity API version. You can use the current date.
useCDN: Used to disable edge cache. We are setting it to false as we will integrate webhook. It will deliver updated data.
token: Sanity API key. Required for webhook integration. (We will integrate webhook in the next section)
perspective: To determine the document status. If it’s set to published it will only query the published documents otherwise it will fetch all the document drafts and published.
Now we will write the queries. We are going to keep the Queries and the Fetch functions in separate files.
Here are the queries, copy these into the sanity-query.ts file.
import { groq } from "next-sanity"; const postData = `{ title, metadata, slug, tags, author->{ _id, name, slug, image, bio }, mainImage, publishedAt, body }`; export const postQuery = groq`*[_type == "post"] ${postData}`; export const postQueryBySlug = groq`*[_type == "post" && slug.current == $slug][0] ${postData}`; export const postQueryByTag = groq`*[_type == "post" && $slug in tags[]->slug.current] ${postData}`; export const postQueryByAuthor = groq`*[_type == "post" && author->slug.current == $slug] ${postData}`; export const postQueryByCategory = groq`*[_type == "post" && category->slug.current == $slug] ${postData}`;
At the top, is the postData object, which defines all the properties we want to fetch.
Then the actual queries. Each query has the query first then the postData object.
These queries are self-descriptive, but for clarity here’s a quick explanation for the postQueryBySlug:
_type: The name of the document.
slug.current: Checking the slug of each of the documents if it matches with $slug (We will pass $slug with the fetch function).
postData: Filtering out the data we want to get i.e. the title, mainImage, and description.
We will use these queries to fetch the data from Sanity Studio.
Copy these codes into the sanity-utils.ts file.
import ImageUrlBuilder from "@sanity/image-url"; import { createClient, type QueryParams } from "next-sanity"; import clientConfig from "./config/client-config"; import { postQuery, postQueryBySlug } from "./sanity-query"; import { Blog } from "@/types/blog"; export const client = createClient(clientConfig); export function imageBuilder(source: string) { return ImageUrlBuilder(clientConfig).image(source); } export async function sanityFetch<QueryResponse>({ query, qParams, tags, }: { query: string, qParams: QueryParams, tags: string[], }): Promise<QueryResponse> { return ( client.fetch < QueryResponse > (query, qParams, { cache: "force-cache", next: { tags }, }) ); } export const getPosts = async () => { const data: Blog[] = await sanityFetch({ query: postQuery, qParams: {}, tags: ["post", "author", "category"], }); return data; }; export const getPostBySlug = async (slug: string) => { const data: Blog = await sanityFetch({ query: postQueryBySlug, qParams: { slug }, tags: ["post", "author", "category"], }); return data; };
Here’s a quick overview of what’s going on here:
client: creating a Sanity client with the config we created earlier. It will be used to fetch the data with GROQ.
imageBuilder: To use the post image. The images are provided from sanity cdn and it requires all these configs.
sanityFetch: It’s the function to fetch data with cache. ( We could just use fetch too but we are configuring this now so that we can just add the webhook and we are good to go. )
Since we are using typescript we need to write the Type for the post. You can see we are using Blog type on the query functions.
Create a blog.ts file inside the types folder and copy this type:
import { PortableTextBlock } from "sanity"; export type Author = { name: string, image: string, bio?: string, slug: { current: string, }, _id?: number | string, _ref?: number | string, }; export type Blog = { _id: number, title: string, slug: any, metadata: string, body: PortableTextBlock[], mainImage: any, author: Author, tags: string[], publishedAt: string, };
All the types are normal, the PortableTextBlock is from sanity. It defines the type of the post body.
All the setup is done!
Let’s fetch the posts and render them on our Next.js project.
First, we will fetch all the posts and create the blog page. Then we will fetch the post by slug for the single post page.
Create the Blog component app/components/Blog/index.ts and add this code.
import { Blog } from "@/types/blog"; import Link from "next/link"; import React from "react"; const BlogItem = ({ blog }: { blog: Blog }) => { return ( <Link href={`/blog/${blog.slug.current}`} className="block p-5 bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 my-8" > <article> <h3 className="mb-1 text-2xl font-bold tracking-tight text-gray-700"> {blog.title} </h3> <p className="mb-3 font-normal text-sm text-gray-600"> {new Date(blog.publishedAt).toDateString()} </p> <p className="mb-3 font-normal text-gray-600"> {blog.metadata.slice(0, 140)}... </p> </article> </Link> ); }; export default BlogItem;
Remove all the styles and code from globals.css (keep the tailwind utils) file and page.tsx file
Now add this to the page.tsx file inside (site)
import { getPosts } from "@/sanity/sanity-utils"; import BlogItem from "@/components/Blog"; export default async function Home() { const posts = await getPosts(); return ( <div className="py-5"> {posts?.length > 0 ? ( posts.map((post: any) => <BlogItem key={post._id} blog={post} />) ) : ( <p>No posts found</p> )} </div> ); }
First import the getPosts function and BlogItem. Inside the Home component fetch the posts and render them.
We need a navbar to navigate between pages.
To save time I already created the Header file and loaded it inside the layout.tsx file.
Check out the source code and copy the Header component from there.
import Header from "@/components/Header"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode, }>) { return ( <> <Header /> <main className="max-w-[1000px] mx-auto px-10 md:px-24">{children}</main> </> ); }
This is how it looks:
Now we need to create a single post page so that we can read the post.
Create the single post page inside blog/[slug]/page.tsx and paste this code snippet.
import React from "react"; import { getPostBySlug } from "@/sanity/sanity-utils"; import RenderBodyContent from "@/components/Blog/RenderBodyContent"; const SingleBlogPage = async ({ params }: { params: any }) => { const post = await getPostBySlug(params.slug); return ( <article className="my-10"> <div className="mb-5"> <h1 className="text-3xl py-2">{post.title}</h1> <p className="pb-1"> <span className="font-medium">Published:</span> {new Date(post.publishedAt).toDateString()} <span className="font-medium pl-2">by </span> {post.author.name} </p> <p>{post.metadata}</p> </div> <article className="prose lg:prose-xl"> <RenderBodyContent post={post} /> </article> </article> ); }; export default SingleBlogPage;
First import getPostBySlug and RenderBodyContent (we will create it in a while).
Fetch the post by slug and render the post with RenderBodyContent.
It’s a custom component to render the post body.
Create RenderBodyContent.tsx file inside the components/Blog folder*.*
import config from "@/sanity/config/client-config"; import { Blog } from "@/types/blog"; import { PortableText } from "@portabletext/react"; import { getImageDimensions } from "@sanity/asset-utils"; import urlBuilder from "@sanity/image-url"; import Image from "next/image"; // lazy-loaded image component const ImageComponent = ({ value, isInline }: any) => { const { width, height } = getImageDimensions(value); return ( <div className="my-10 overflow-hidden rounded-[15px]"> <Image src={ urlBuilder(config) .image(value) .fit("max") .auto("format") .url() as string } width={width} height={height} alt={value.alt || "blog image"} loading="lazy" style={{ display: isInline ? "inline-block" : "block", aspectRatio: width / height, }} /> </div> ); }; const components = { types: { image: ImageComponent, }, }; const RenderBodyContent = ({ post }: { post: Blog }) => { return ( <> <PortableText value={post?.body as any} components={components} /> </> ); }; export default RenderBodyContent;
This component will handle special types differently. We are only handling Images here.
You can include code blocks, embeds, and many more. You can find more information on Sanity plugins on Sanity.
Here’s what it looks like.
To make the post look like this install the tailwind/typography plugin and load that inside the tailwind.config.ts file.
npm install @tailwindcss/typography
We will integrate Sanity webhook to fetch the data on time. Otherwise, you will have to deploy the site every time you write a post.
We already added revalidation on the fetch functions. Right now we need to generate the Keys and create the Webhook endpoint.
Generate the API key
Go to sanity.io/manage and navigate to API→Tokens then click on the Add API token button.
Give your API a name then choose Editor and save.
You will get an API key and save it on the env file
SANITY_API_KEY = "YOUR_API_KEY";
Create the Webhook endpoint
First, create the webhook endpoint app/api/revalidate/route.ts and add this code snippet.
import { revalidateTag } from "next/cache"; import { type NextRequest, NextResponse } from "next/server"; import { parseBody } from "next-sanity/webhook"; export async function POST(req: NextRequest) { try { const { body, isValidSignature } = await parseBody<{ _type: string; slug?: string | undefined; }>(req, process.env.NEXT_PUBLIC_SANITY_HOOK_SECRET); if (!isValidSignature) { return new Response("Invalid Signature", { status: 401 }); } if (!body?._type) { return new Response("Bad Request", { status: 400 }); } revalidateTag(body._type); return NextResponse.json({ status: 200, revalidated: true, now: Date.now(), body, }); } catch (error: any) { console.error(error); return new Response(error.message, { status: 500 }); } }
We are using tag-based revalidation in this webhook.
This endpoint will be called by the webhook every time you create, delete, or update a document from Sanity Studio.
Generate the Webhook Secret
Navigate to sanity.io/manage API→Webhooks. Click on the Create Webhook button.
You will get a modal with a form. Fill in the form with the following pieces of information:
Name: Name of the Webhook
Description: Short description of what the webhook does (This is an optional field).
URL: Set the URL to https://YOUR_SITE_URL/api/revalidate
Dataset: Choose your desired dataset or leave the default value.
Trigger on: Set the hook to trigger on "Create", "Update", and "Delete".
Filter: Leave this field blank.
Projections: Set the projections to {_type, "slug": slug.current}
Status: Check the enable webhook box.
HTTP Method: POST.
Leave HTTP headers, API version, and Draft as default.
Secret: Give your webhook a unique secret and copy it.
Hit save to create your webhook.
Save your webhook in the .env file under this variable name.
SANITY_HOOK_SECRET=YOUR_SECRET
Testing the webhook: Go ahead and change the content of an Article and publish it. After that hard reload your website you should see the changes in real time.
Note: You can test webhook from the live site or you can choose tools like ngrok to expose the localhost URL and use that to test it.
That’s it you built a blog with Sanity CMS. Congrats! ?
Even though this guide looks so long, it’s just the beginning. Sanity has more features and options, you can build cool things.
It’s impossible to cover everything in a single article.
I will suggest you to checkout these resources to learn more and improve your blog
Sanity Plugins
Source Code
Connect With Me
I hope you enjoyed the post, if you want to stay conntected with me checkout my socials.
Would love to talk to you!
Twitter/x
Github
Happy Coding.
위 내용은 Next.js 및 Sanity를 사용하여 최신 블로그 구축: 단계별 가이드의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!