CoursesContent-driven web application foundationsBuild up the blog
Track
Work-ready Next.js

Content-driven web application foundations

Lesson
12

Build up the blog

Log in to watch a video walkthrough of this lesson
Log in
Video thumbnail
With all the basics in place, let's blow out our blog front end into something more visually impressive.
Log in to mark your progress for each Lesson and Task

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.

Run the following to install Day.js
npm install dayjs
Update your queries to request more content, including resolving category and author references.
src/sanity/lib/queries.ts
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
}
}`)
Run Typegen to update your query Types
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.

Create a new directory — /src/components — in your Next.js application for storing components. These aren't stored in /app since that directory is primarily for generating routes.
Create an Author component
src/components/Author.tsx
import { 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
}
Create a Categories component
src/components/Categories.tsx
import { 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>
))
}
Create a PublishedAt component
src/components/PublishedAt.tsx
import { 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
}
Create a Title component for rendering a page title in a <h1>
src/components/Title.tsx
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>
)
}
Create a Post component for rendering the above components on a single post page
src/components/Post.tsx
import { 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>
);
}
Create a PostCard component for rendering the above components on the post index page
src/components/PostCard.tsx
import { 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>
)
}
Create a Header component for the top nav of the site
src/components/Header.tsx
import 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.

Update the root layout to display the site-wide navigation
src/app/(frontend)/layout.tsx
import { Header } from "@/components/Header";
import { SanityLive } from "@/sanity/lib/live";
export default function FrontendLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<section className="bg-white min-h-screen">
<Header />
{children}
<SanityLive />
</section>
);
}
Update the root page to use the Title component
src/app/(frontend)/page.tsx
import { 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>
)
}
Update the post index page to use the PostCard component
src/app/(frontend)/posts/page.tsx
import { 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>
)
}
Update the individual post route to use the Post component
src/app/(frontend)/posts/[slug]/page.tsx
import { 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.

You have 14 uncompleted tasks in this lesson
0 of 14