With all the basics in place, let's blow out our blog front end into something more visually impressive.
For the remaining courses in this track, a much richer front end that requests and renders more content from your three schema types will be helpful. In this lesson, you'll build the blog into something more interesting.
To format date strings returned from Sanity documents, install Day.js.
npm install dayjs
category
and author
references.import { defineQuery } from 'next-sanity'
export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]|order(publishedAt desc)[0...12]{ _id, title, slug, body, mainImage, publishedAt, "categories": coalesce( categories[]->{ _id, slug, title }, [] ), author->{ name, image }}`)
export const POSTS_SLUGS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]{ "slug": slug.current}`)
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{ _id, title, body, mainImage, publishedAt, "categories": coalesce( categories[]->{ _id, slug, title }, [] ), author->{ name, image }}`)
npm run typegen
As some content will be rendered on both the post index and the individual post routes, abstracting these elements into components helps keep code somewhat DRY (don't repeat yourself).
You may like to adapt the Tailwind CSS class names to your liking.
/src/components
— in your Next.js application for storing components. These aren't stored in /app
since that directory is primarily for generating routes.Author
componentimport { POST_QUERYResult } from '@/sanity/types'import { urlFor } from '@/sanity/lib/image'import Image from 'next/image'
type AuthorProps = { author: NonNullable<POST_QUERYResult>['author']}
export function Author({ author }: AuthorProps) { return author?.image || author?.name ? ( <div className="flex items-center gap-2"> {author?.image ? ( <Image src={urlFor(author.image).width(80).height(80).url()} width={80} height={80} alt={author.name || ''} className="bg-pink-50 size-10 shadow-inner rounded-full" /> ) : null} {author?.name ? ( <p className="text-base text-slate-700">{author.name}</p> ) : null} </div> ) : null}
Categories
componentimport { POST_QUERYResult } from '@/sanity/types'
type CategoriesProps = { categories: NonNullable<POST_QUERYResult>['categories']}
export function Categories({ categories }: CategoriesProps) { return categories.map((category) => ( <span key={category._id} className="bg-cyan-50 rounded-full px-2 py-1 leading-none whitespace-nowrap text-sm font-semibold text-cyan-700" > {category.title} </span> ))}
PublishedAt
componentimport { POST_QUERYResult } from '@/sanity/types'import dayjs from 'dayjs'
type PublishedAtProps = { publishedAt: NonNullable<POST_QUERYResult>['publishedAt']}
export function PublishedAt({ publishedAt }: PublishedAtProps) { return publishedAt ? ( <p className="text-base text-slate-700"> {dayjs(publishedAt).format('D MMMM YYYY')} </p> ) : null}
Title
component for rendering a page title in a <h1>
import { PropsWithChildren } from 'react'
export function Title(props: PropsWithChildren) { return ( <h1 className="text-2xl md:text-4xl lg:text-6xl font-semibold text-slate-800 text-pretty max-w-3xl"> {props.children} </h1> )}
Post
component for rendering the above components on a single post pageimport { Author } from "@/components/Author";import { Categories } from "@/components/Categories";import { components } from "@/sanity/portableTextComponents";import { PortableText } from "next-sanity";import { POST_QUERYResult } from "@/sanity/types";import { PublishedAt } from "@/components/PublishedAt";import { Title } from "@/components/Title";import { urlFor } from "@/sanity/lib/image";import Image from "next/image";
export function Post(props: NonNullable<POST_QUERYResult>) { const { title, author, mainImage, body, publishedAt, categories } = props;
return ( <article className="grid lg:grid-cols-12 gap-y-12"> <header className="lg:col-span-12 flex flex-col gap-4 items-start"> <div className="flex gap-4 items-center"> <Categories categories={categories} /> <PublishedAt publishedAt={publishedAt} /> </div> <Title>{title}</Title> <Author author={author} /> </header> {mainImage ? ( <figure className="lg:col-span-4 flex flex-col gap-2 items-start"> <Image src={urlFor(mainImage).width(400).height(400).url()} width={400} height={400} alt="" /> </figure> ) : null} {body ? ( <div className="lg:col-span-7 lg:col-start-6 prose lg:prose-lg"> <PortableText value={body} components={components} /> </div> ) : null} </article> );}
PostCard
component for rendering the above components on the post index pageimport { Author } from '@/components/Author'import { Categories } from '@/components/Categories'import { POSTS_QUERYResult } from '@/sanity/types'import { PublishedAt } from '@/components/PublishedAt'import { urlFor } from '@/sanity/lib/image'import Image from 'next/image'import Link from 'next/link'
export function PostCard(props: POSTS_QUERYResult[0]) { const { title, author, mainImage, publishedAt, categories } = props
return ( <Link className="group" href={`/posts/${props.slug!.current}`}> <article className="flex flex-col-reverse gap-4 md:grid md:grid-cols-12 md:gap-0"> <div className="md:col-span-2 md:pt-1"> <Categories categories={categories} /> </div> <div className="md:col-span-5 md:w-full"> <h2 className="text-2xl text-pretty font-semibold text-slate-800 group-hover:text-pink-600 transition-colors relative"> <span className="relative z-[1]">{title}</span> <span className="bg-pink-50 z-0 absolute inset-0 rounded-lg opacity-0 transition-all group-hover:opacity-100 group-hover:scale-y-110 group-hover:scale-x-105 scale-75" /> </h2> <div className="flex items-center mt-2 md:mt-6 gap-x-6"> <Author author={author} /> <PublishedAt publishedAt={publishedAt} /> </div> </div> <div className="md:col-start-9 md:col-span-4 rounded-lg overflow-hidden flex"> {mainImage ? ( <Image src={urlFor(mainImage).width(400).height(200).url()} width={400} height={200} alt={mainImage.alt || title || ''} /> ) : null} </div> </article> </Link> )}
Header
component for the top nav of the siteimport Link from 'next/link'
export function Header() { return ( <div className="from-pink-50 to-white bg-gradient-to-b p-6"> <header className="bg-white/80 shadow-md flex items-center justify-between p-6 rounded-lg container mx-auto shadow-pink-50"> <Link className="text-pink-700 md:text-xl font-bold tracking-tight" href="/" > Layer Caker </Link> <ul className="flex items-center gap-4 font-semibold text-slate-700"> <li> <Link className="hover:text-pink-500 transition-colors" href="/posts" > Posts </Link> </li> <li> <Link className="hover:text-pink-500 transition-colors" href="/studio" > Sanity Studio </Link> </li> </ul> </header> </div> )}
Now that you have many small components, it's time to import them into your routes to complete the design.
import '@/app/globals.css'import { Header } from '@/components/Header'
export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode}>) { return ( <html lang="en"> <body className="bg-white min-h-screen"> <Header /> {children} </body> </html> )}
Title
componentimport { Title } from '@/components/Title'
export default async function Page() { return ( <section className="container mx-auto grid grid-cols-1 gap-6 p-12"> <Title>Layer Caker Home Page</Title> </section> )}
PostCard
componentimport { client } from '@/sanity/lib/client'import { POSTS_QUERY } from '@/sanity/lib/queries'import { PostCard } from '@/components/PostCard'import { Title } from '@/components/Title'
const options = { next: { revalidate: 60 } }
export default async function Page() { const posts = await client.fetch(POSTS_QUERY, {}, options)
return ( <main className="container mx-auto grid grid-cols-1 gap-6 p-12"> <Title>Post Index</Title> <div className="flex flex-col gap-24 py-12"> {posts.map((post) => ( <PostCard key={post._id} {...post} /> ))} </div> </main> )}
Post
componentimport { client } from '@/sanity/lib/client'import { POST_QUERY } from '@/sanity/lib/queries'import { Post } from '@/components/Post'import { notFound } from 'next/navigation'
type PostIndexProps = { params: { slug: string } }
const options = { next: { revalidate: 60 } }
export default async function Page({ params }: PostIndexProps) { const post = await client.fetch(POST_QUERY, params, options)
if (!post) { notFound() }
return ( <main className="container mx-auto grid grid-cols-1 gap-6 p-12"> <Post {...post} /> </main> )}
Click around the site now. You should have a richer site-wide header, post index, and individual post pages.
You're also in a much better position for the remaining lessons in this track. Let's test what you've learned in the final lesson.