CoursesBuild landing pages with Next.jsRender page builder blocks
Track
Work-ready Next.js

Build landing pages with Next.js

Lesson
5

Render page builder blocks

Setup the unique components for each individual "block" to render on the page.
Log in to mark your progress for each Lesson and Task

The example components in this lesson have been given deliberately simple designs. Feel free to redesign them with much more flair.

You'll notice also the props for each component has been typed from the PAGE_QUERYResult generated from Sanity TypeGen. The type itself looks quite gnarly, but it will be constantly updated as you make future changes to your schema types and queries.

Create a component to render the Hero block
src/components/blocks/Hero.tsx
import { PortableText } from "next-sanity";
import Image from "next/image";
import { Title } from "@/components/Title";
import { urlFor } from "@/sanity/lib/image";
import { PAGE_QUERYResult } from "@/sanity/types";
type HeroProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "hero" }
>;
export function Hero({ title, text, image }: HeroProps) {
return (
<section className="isolate w-full aspect-[2/1] py-16 relative overflow-hidden">
<div className="relative flex flex-col justify-center items-center gap-8 h-full z-20">
{title ? (
<h1 className="text-2xl md:text-4xl lg:text-6xl font-semibold text-white text-pretty max-w-3xl">
{title}
</h1>
) : null}
<div className="prose-lg lg:prose-xl prose-invert flex items-center">
{text ? <PortableText value={text} /> : null}
</div>
</div>
<div className="absolute inset-0 bg-pink-500 opacity-50 z-10" />
{image ? (
<Image
className="absolute inset-0 object-cover blur-sm"
src={urlFor(image).width(1600).height(800).url()}
width={1600}
height={800}
alt=""
/>
) : null}
</section>
);
}
Create a component to render the FAQs block
src/components/blocks/FAQs.tsx
import { PAGE_QUERYResult } from "@/sanity/types";
import { PortableText } from "next-sanity";
type FAQsProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "faqs" }
>;
export function FAQs({ _key, title, faqs }: FAQsProps) {
return (
<section className="container mx-auto flex flex-col gap-8 py-16">
{title ? (
<h2 className="text-xl mx-auto md:text-2xl lg:text-5xl font-semibold text-slate-800 text-pretty max-w-3xl">
{title}
</h2>
) : null}
{Array.isArray(faqs) ? (
<div className="max-w-2xl mx-auto border-b border-pink-200">
{faqs.map((faq) => (
<details
key={faq._id}
className="group [&[open]]:bg-pink-50 transition-colors duration-100 px-4 border-t border-pink-200"
name={_key}
>
<summary className="text-xl font-semibold text-slate-800 list-none cursor-pointer py-4 flex items-center justify-between">
{faq.title}
<span className="transform origin-center rotate-90 group-open:-rotate-90 transition-transform duration-200">
&larr;
</span>
</summary>
<div className="pb-4">
{faq.body ? <PortableText value={faq.body} /> : null}
</div>
</details>
))}
</div>
) : null}
</section>
);
}
Create a component to render the Features block
src/components/blocks/Features.tsx
import { PAGE_QUERYResult } from "@/sanity/types";
type FeaturesProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "features" }
>;
export function Features({ features, title }: FeaturesProps) {
return (
<section className="container mx-auto flex flex-col gap-8 py-16">
{title ? (
<h2 className="text-xl mx-auto md:text-2xl lg:text-5xl font-semibold text-slate-800 text-pretty max-w-3xl">
{title}
</h2>
) : null}
{Array.isArray(features) ? (
<div className="grid grid-cols-3 gap-8">
{features.map((feature) => (
<div key={feature._key} className="flex flex-col gap-4">
<h3 className="text-xl font-semibold text-slate-800">
{feature.title}
</h3>
<p className="text-lg text-slate-600">{feature.text}</p>
</div>
))}
</div>
) : null}
</section>
);
}
Create a component to render the Split Image block
src/components/blocks/SplitImage.tsx
import Image from "next/image";
import { urlFor } from "@/sanity/lib/image";
import { PAGE_QUERYResult } from "@/sanity/types";
import { stegaClean } from "next-sanity";
type SplitImageProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "splitImage" }
>;
export function SplitImage({ title, image, orientation }: SplitImageProps) {
return (
<section
className="container mx-auto flex gap-8 py-16 data-[orientation='imageRight']:flex-row-reverse"
data-orientation={stegaClean(orientation) || "imageLeft"}
>
{image ? (
<Image
className="rounded-xl w-2/3 h-auto"
src={urlFor(image).width(800).height(600).url()}
width={800}
height={600}
alt=""
/>
) : null}
<div className="w-1/3 flex items-center">
{title ? (
<h2 className="text-3xl mx-auto md:text-5xl lg:text-8xl font-light text-pink-500 text-pretty max-w-3xl">
{title}
</h2>
) : null}
</div>
</section>
);
}

Now we have components for each block, we need to render them in order.

Each array item has a distinct _type attribute, which you can switch over to render the correct component.

Each item also contains a unique (to the array) _key value, which can be passed to React as a key prop—required by React for performant and consistent rendering of an array.

We have also passed the remaining props to the block component using the spread operator.

Create the PageBuilder component to render all the content of the page
src/components/PageBuilder.tsx
import { Hero } from "@/components/blocks/Hero";
import { Features } from "@/components/blocks/Features";
import { SplitImage } from "@/components/blocks/SplitImage";
import { FAQs } from "@/components/blocks/FAQs";
import { PAGE_QUERYResult } from "@/sanity/types";
type PageBuilderProps = {
content: NonNullable<PAGE_QUERYResult>["content"];
};
export function PageBuilder({ content }: PageBuilderProps) {
if (!Array.isArray(content)) {
return null;
}
return (
<main>
{content.map((block) => {
switch (block._type) {
case "hero":
return <Hero key={block._key} {...block} />;
case "features":
return <Features key={block._key} {...block} />;
case "splitImage":
return <SplitImage key={block._key} {...block} />;
case "faqs":
return <FAQs key={block._key} {...block} />;
default:
// This is a fallback for when we don't have a block type
return <div key={block._key}>Block not found: {block._type}</div>;
}
})}
</main>
);
}
Update the dynamic page route to use the PageBuilder component
src/app/(frontend)/[slug]/page.tsx
import { PageBuilder } from "@/components/PageBuilder";
import { sanityFetch } from "@/sanity/lib/live";
import { PAGE_QUERY } from "@/sanity/lib/queries";
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { data: page } = await sanityFetch({
query: PAGE_QUERY,
params: await params,
});
return page?.content ? <PageBuilder content={page.content} /> : null;
}

You should now be able to create page documents, use all of the blocks from the Page Builder array we have created, and preview changes as you author them.

For now you have click-to-edit functionality in Presentation. Before going any further, let's use everything we've built so far to create the application's home page.

You have 6 uncompleted tasks in this lesson
0 of 6