Epic Next.js 15 Tutorial Part 3: Finishup up the homepage Features Section, TopNavigation and Footer
In this tutorial, we will continue building our landing page using Next.js and Strapi. We will refactor the Hero Section to use the Next.js Image component for optimized image handling, build the Features Section by modeling data in Strapi and creating corresponding components in Next.js, display dynamic metadata from Strapi on our layout.tsx page, create a top header and footer using global data fetched from Strapi, and handle loading, not found, and error pages. To follow along with this tutorial, you should have completed the previous parts of the Epic Next.js 14 Tutorial series or have an existing project set up with Next.js and Strapi. You can find the complete code for this tutorial on GitHub. Let's get started! ## Refactoring the Hero Section First, let's refactor the Hero Section to use the Next.js Image component for optimized image handling. This will improve our website's performance by lazy-loading images and serving them in modern formats like WebP. 1. Create a custom StrapiImage component inside the `components` folder: ```tsx // components/StrapiImage.tsx import Image from "next/image"; interface Props { image: any; } export default function StrapiImage({ image }: Props) { return ( <Image src={image?.data?.attributes?.url || "/"} alt={image?.data?.attributes?.alternativeText || ""} width={image?.data?.attributes?.width || 0} height={image?.data?.attributes?.height || 0} layout="fill" objectFit="cover" /> ); } ``` 2. Update the `HeroSection` component to use the custom StrapiImage component: ```tsx // components/HeroSection.tsx import { Block } from "@/lib/types"; import Image from "next/image"; import Link from "next/link"; import { cn } from "@/lib/utils"; import StrapiImage from "./StrapiImage"; interface Props { block: Block; } export default function HeroSection({ block }: Props) { return ( <section className="bg-gray-100 dark:bg-gray-900"> <div className="max-w-screen-2xl px-4 py-16 mx-auto sm:px-6 lg:px-8"> {block?.image && ( <StrapiImage image={block.image} /> )} <div className="max-w-lg mx-auto text-center"> <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl"> {block?.title || ""} </h1> <p className="max-w-md mx-auto mt-4 text-gray-500 dark:text-gray-400"> {block?.description || ""} </p> <Link href={block?.link?.url || "/"}> <a className="inline-flex h-12 px-6 mt-8 text-sm font-medium text-white bg-gray-900 dark:bg-gray-50 rounded-md shadow-sm hover:bg-gray-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950" target="_blank" > Learn More </a> </Link> </div> </div> </section> ); } ``` 3. Update the `HomePageContent` component to pass the correct data to the HeroSection: ```tsx // components/HomePageContent.tsx import { Block } from "@/lib/types"; import HeroSection from "./HeroSection"; interface Props { blocks?: Block[]; } export default function HomePageContent({ blocks = [] }: Props) { return ( <div className="space-y-12"> {blocks.map((block, index) => ( <HeroSection key={index} block={block} /> ))} </div> ); } ``` 4. Update the `HomePageContentLoader` component to pass the correct data to the HeroSection: ```tsx // components/HomePageContentLoader.tsx import { Block } from "@/lib/types"; import Skeleton from "./Skeleton"; import HeroSection from "./HeroSection"; interface Props { loading?: boolean; } export default function HomePageContentLoader({ loading = false }: Props) { return ( <div className="space-y-12"> {loading ? ( Array.from({ length: 3 }).map((_, index) => ( <Skeleton key={index} /> )) ) : ( <HomePageContent blocks={[]} /> )} </div> ); } ``` Now, our Hero Section is using the Next.js Image component for optimized image handling. ## Building the Features Section Next, let's build the Features Section by modeling data in Strapi and creating corresponding components in Next.js. We will display features dynamically from the Strapi CMS. 1. Model the Features Section data in Strapi: - Go to Content-Type Builder (http://localhost:1337/admin/plugins/content-type-builder) - Click on "Create new collection type" and name it "Features" - Add the following fields: - Title (Single Line Text) - Description (Rich Text) - Image (Media Library) - Link (URL) 2. Create a custom StrapiImage component inside the `components` folder: ```tsx // components/StrapiImage.tsx import Image from "next/image"; interface Props { image: any; } export default function StrapiImage({ image }: Props) { return ( <Image src={image?.data?.attributes?.url || "/"} alt={image?.data?.attributes?.alternativeText || ""} width={image?.data?.attributes?.width || 0} height={image?.data?.attributes?.height || 0} layout="fill" objectFit="cover" /> ); } ``` 3. Create a `FeatureItem` component inside the `components` folder: ```tsx // components/FeatureItem.tsx import { Block } from "@/lib/types"; import Image from "next/image"; import Link from "next/link"; import { cn } from "@/lib/utils"; import StrapiImage from "./StrapiImage"; interface Props { feature: any; } export default function FeatureItem({ feature }: Props) { return ( <div className="flex flex-col items-center space-y-4"> {feature?.image && ( <StrapiImage image={feature.image} /> )} <h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> {feature?.title || ""} </h3> <p className="max-w-md text-center text-gray-500 dark:text-gray-400"> {feature?.description || ""} </p> {feature?.link && ( <Link href={feature.link.url}> <a className="inline-flex h-12 px-6 mt-8 text-sm font-medium text-white bg-gray-900 dark:bg-gray-50 rounded-md shadow-sm hover:bg-gray-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950" target="_blank" > Learn More </a> </Link> )} </div> ); } ``` 4. Create a `FeaturesSection` component inside the `components` folder: ```tsx // components/FeaturesSection.tsx import { Block } from "@/lib/types"; import FeatureItem from "./FeatureItem"; interface Props { block: Block; } export default function FeaturesSection({ block }: Props) { return ( <section className="bg-gray-100 dark:bg-gray-900"> <div className="max-w-screen-2xl px-4 py-16 mx-auto sm:px-6 lg:px-8"> <h2 className="text-3xl font-bold text-gray-900 dark:text-gray-100"> {block?.title || ""} </h2> <div className="grid grid-cols-1 gap-8 mt-8 md:grid-cols-2 lg:grid-cols-3"> {block?.features.map((feature, index) => ( <FeatureItem key={index} feature={feature} /> ))} </div> </div> </section> ); } ``` 5. Update the `HomePageContent` component to pass the correct data to the Features Section: ```tsx // components/HomePageContent.tsx import { Block } from "@/lib/types"; import HeroSection from "./HeroSection"; import FeaturesSection from "./FeaturesSection"; interface Props { blocks?: Block[]; } export default function HomePageContent({ blocks = [] }: Props) { return ( <div className="space-y-12"> {blocks.map((block, index) => ( <HeroSection key={index} block={block} /> ))} {blocks.map((block, index) => ( <FeaturesSection key={index} block={block} /> ))} </div> ); } ``` 6. Update the `HomePageContentLoader` component to pass the correct data to the Features Section: ```tsx // components/HomePageContentLoader.tsx import { Block } from "@/lib/types"; import Skeleton from "./Skeleton"; import HeroSection from "./HeroSection"; import FeaturesSection from "./FeaturesSection"; interface Props { loading?: boolean; } export default function HomePageContentLoader({ loading = false }: Props) { return ( <div className="space-y-12"> {loading ? ( Array.from({ length: 3 }).map((_, index) => ( <Skeleton key={index} /> )) ) : ( <HomePageContent blocks={[]} /> )} </div> ); } ``` Now, our Features Section is displaying features dynamically from the Strapi CMS. ## Displaying Dynamic Meta Data Next, let's examine how to get our metadata from Strapi and display it on our layout.tsx page. 1. Create a new function called `getGlobalPageMetadata` inside the `data/loaders.ts` file: ```tsx // data/loaders.ts import fetchData from "./fetch"; export async function getHomePageData() { const url = new URL("/api/home-page", baseUrl); url.search = qs.stringify({ populate: { blocks: { populate: { image: { fields: ["url", "alternativeText"], }, link: { populate: true, }, feature: { populate: true, }, }, }, }, }); return await fetchData(url.href); } export async function getGlobalPageMetadata() { const url = new URL("/api/global", baseUrl); url.search = qs.stringify({ fields: ["title", "description"], }); return await fetchData(url.href); } ``` In the function above, we ask Strapi to return only the title and description, which are the only data we need for our metadata. The response will look like the following: ```json { "data": { "id": 4, "documentId": 'fyj7ijjnkxy75h1cbusrafj2', "title": 'Global Page', "description": 'Responsible for our header and footer.' } } ``` 2. Update the `generateMetadata` function inside the `layout.tsx` file to use the `getGlobalPageMetadata` function: ```tsx // app/layout.tsx import type { Metadata } from "next"; import localFont from "next/font/local"; import "./globals.css"; import { getHomePageData, getGlobalPageMetadata } from "@/data/loaders"; import { Header } from "@/components/custom/header"; import { Footer } from "@/components/custom/footer"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", weight: "100 900", }); const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", weight: "100 900", }); export async function generateMetadata(): Promise<Metadata> { const metadata = await getGlobalPageMetadata(); return { title: metadata?.data?.title ?? "Epic Next Course", description: metadata?.data?.description ?? "Epic Next Course", }; } export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const globalData = await getGlobalData(); console.dir(globalData, { depth: null }); return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <Header data={globalData.data.header} /> {children} <Footer data={globalData.data.footer} /> </body> </html> ); } ``` Now, our metadata is dynamically set from our Strapi API. ## Top Header and Footer Let's create our top header and footer using global data fetched from Strapi. We will leverage Strapi to manage and fetch global data like logo texts and social links. 1. Update the `getGlobalData` function inside the `data/loaders.ts` file: ```tsx // data/loaders.ts import fetchData from "./fetch"; export async function getHomePageData() { const url = new URL("/api/home-page", baseUrl); url.search = qs.stringify({ populate: { blocks: { populate: { image: { fields: ["url", "alternativeText"], }, link: { populate: true, }, feature: { populate: true, }, }, }, }, }); return await fetchData(url.href); } export async function getGlobalPageMetadata() { const url = new URL("/api/global", baseUrl); url.search = qs.stringify({ fields: ["title", "description"], }); return await fetchData(url.href); } export async function getGlobalData() { const url = new URL("/api/global", baseUrl); url.search = qs.stringify({ populate: "*", }); return await fetchData(url.href); } ``` In the function above, we ask Strapi to return all data for our global page, including the title and description fields that we used earlier for dynamic metadata. The response will look like the following: ```json { "data": { "id": 2, "documentId": 'fyj7ijjnkxy75h1cbusrafj2', "title": 'Global Page', "description": 'Responsible for our header and footer.', "header": { "data": { "id": 1, "attributes": { "title": "Epic Next Course", "description": "The best place to learn Next.js.", "socialLinks": [ { "id": 1, "attributes": { "url": "https://github.com/vercel/next.js", "icon": "GitHub" } }, { "id": 2, "attributes": { "url": "https://www.linkedin.com/company/vercel/", "icon": "LinkedIn" } } ] } } }, "footer": { "data": { "id": 2, "attributes": { "title": "Epic Next Course", "description": "The best place to learn Next.js.", "socialLinks": [ { "id": 1, "attributes": { "url": "https://github.com/vercel/next.js", "icon": "GitHub" } }, { "id": 2, "attributes": { "url": "https://www.linkedin.com/company/vercel/", "icon": "LinkedIn" } } ] } } } } } ``` 2. Create a `SocialLinkItem` component inside the `components` folder: ```tsx // components/SocialLinkItem.tsx import { Block } from "@/lib/types"; import Image from "next/image"; import Link from "next/link"; import { cn } from "@/lib/utils"; interface Props { socialLink: any; } export default function SocialLinkItem({ socialLink }: Props) { return ( <li> <Link href={socialLink.url}> <a target="_blank"> {socialLink?.icon && ( <Image src={`/icons/${socialLink.icon}.svg`} alt={socialLink.icon} width={24} height={24} /> )} </a> </Link> </li> ); } ``` 3. Create a `SocialLinksList` component inside the `components` folder: ```tsx // components/SocialLinksList.tsx import { Block } from "@/lib/types"; import SocialLinkItem from "./SocialLinkItem"; interface Props { socialLinks?: any[]; } export default function SocialLinksList({ socialLinks = [] }: Props) { return ( <ul className="flex space-x-4"> {socialLinks.map((socialLink, index) => ( <SocialLinkItem key={index} socialLink={socialLink} /> ))} </ul> ); } ``` 4. Create a `Header` component inside the `components/custom` folder: ```tsx // components/custom/Header.tsx import { Block } from "@/lib/types"; import Image from "next/image"; import Link from "next/link"; import SocialLinksList from "../SocialLinksList"; interface Props { data?: any; } export default function Header({ data = {} }: Props) { return ( <header className="bg-gray-100 dark:bg-gray-900"> <div className="max-w-screen-2xl px-4 py-6 mx-auto sm:px-6 lg:px-8"> <Link href="/"> <a> {data?.attributes?.title && ( <Image src={`/icons/${data.attributes.icon}.svg`} alt={data.attributes.icon} width={32} height={32} /> )} </a> </Link> <div className="flex justify-center flex-1"> {data?.attributes?.description && ( <p className="text-gray-500 dark:text-gray-400"> {data.attributes.description} </p> )} </div> <SocialLinksList socialLinks={data?.attributes?.socialLinks || []} /> </div> </header> ); } ``` 5. Create a `Footer` component inside the `components/custom` folder: ```tsx // components/custom/Footer.tsx import { Block } from "@/lib/types"; import Image from "next/image"; import Link from "next/link"; import SocialLinksList from "../SocialLinksList"; interface Props { data?: any; } export default function Footer({ data = {} }: Props) { return ( <footer className="bg-gray-100 dark:bg-gray-900"> <div className="max-w-screen-2xl px-4 py-8 mx-auto sm:px-6 lg:px-8"> {data?.attributes?.title && ( <Image src={`/icons/${data.attributes.icon}.svg`} alt={data.attributes.icon} width={32} height={32} /> )} <div className="flex justify-center flex-1"> {data?.attributes?.description && ( <p className="text-gray-500 dark:text-gray-400"> {data.attributes.description} </p> )} </div> <SocialLinksList socialLinks={data?.attributes?.socialLinks || []} /> </div> </footer> ); } ``` 6. Update the `generateMetadata` function inside the `layout.tsx` file to use the `getGlobalData` function: ```tsx // app/layout.tsx import type { Metadata } from "next"; import localFont from "next/font/local"; import "./globals.css"; import { getHomePageData, getGlobalData } from "@/data/loaders"; import { Header } from "@/components/custom/header"; import { Footer } from "@/components/custom/footer"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", weight: "100 900", }); const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", weight: "100 900", }); export async function generateMetadata(): Promise<Metadata> { const globalData = await getGlobalData(); return { title: globalData?.data?.attributes?.title || "Epic Next Course", description: globalData?.data?.attributes?.description || "The best place to learn Next.js.", }; } export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const globalData = await getGlobalData(); console.dir(globalData, { depth: null }); return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <Header data={globalData?.data?.attributes?.header || {}} /> {children} <Footer data={globalData?.data?.attributes?.footer || {}} /> </body> </html> ); } ``` Now, our top header and footer are using global data fetched from Strapi. ## Handling Loading, Not Found, and Error Pages Finally, let's handle loading, not found, and error pages in our application. 1. Create a `loading.tsx` file inside the `pages` folder: ```tsx // pages/loading.tsx import { Block } from "@/lib/types"; import Skeleton from "./Skeleton"; import HomePageContentLoader from "./HomePageContentLoader"; interface Props { blocks?: Block[]; } export default function Loading({ blocks = [] }: Props) { return ( <div className="space-y-12"> <HomePageContentLoader loading /> </div> ); } ``` 2. Create a `not-found.tsx` file inside the `pages` folder: ```tsx // pages/not-found.tsx import { Block } from "@/lib/types"; import Skeleton from "./Skeleton"; import HomePageContentLoader from "./HomePageContentLoader"; interface Props { blocks?: Block[]; } export default function NotFound({ blocks = [] }: Props) { return ( <div className="space-y-12"> <HomePageContentLoader loading /> </div> ); } ``` 3. Create a `error.tsx` file inside the `pages` folder: ```tsx // pages/error.tsx import { Block } from "@/lib/types"; import Skeleton from "./Skeleton"; import HomePageContentLoader from "./HomePageContentLoader"; interface Props { blocks?: Block[]; } export default function Error({ blocks = [] }: Props) { return ( <div className="space-y-12"> <HomePageContentLoader loading /> </div> ); } ``` Now, our application is handling loading, not found, and error pages. That's it! We have successfully continued building our landing page using Next.js and Strapi by refactoring the Hero Section to use the Next.js Image component for optimized image handling, building the Features Section by modeling data in Strapi and creating corresponding components in Next.js, displaying dynamic metadata from Strapi on our layout.tsx page, creating a top header and footer using global data fetched from Strapi, and handling loading, not found, and error pages in our application. In the next part of this tutorial series, we will continue building our landing page by adding user authentication with NextAuth.js and integrating it with our existing Strapi API.
Company
Strapi
Date published
March 19, 2024
Author(s)
Paul Bratslavsky
Word count
6800
Language
English
Hacker News points
None found.