In part 3 of our series, let's finish building out our home page. We will finish up our Hero Section, then move to our Features Section, and finally add our Top Navigation and Footer.
- Part 1: Learn Next.js by building a website
- Part 2: Building Out The Hero Section of the homepage
- Part 3: Finishup up the homepage Features Section, TopNavigation and Footer
- Part 4: How to handle login and Authentification in Next.js
- Part 5: Building out the Dashboard page and upload file using NextJS server actions
- Part 6: Get Video Transcript with OpenAI Function
- Part 7: Strapi CRUD permissions
- Part 8: Search & pagination in Nextjs
- Part 9: Backend deployment to Strapi Cloud
- Part 10: Frontend deployment to Vercel
Let's refactor our Hero Section to use the Next Image component.
Instead of using it directly, we will create a new component called StrapiImage to add a few additional quality live improvements.
Inside src/app/components/custom
, create a new file called strapi-image.tsx
and paste it into the following code.
1import Image from "next/image";
2
3import { getStrapiURL } from "@/lib/utils";
4
5interface IStrapiMediaProps {
6 src: string;
7 alt: string | null;
8 height?: number;
9 width?: number;
10 className?: string;
11 fill?: boolean;
12 priority?: boolean;
13}
14
15export function getStrapiMedia(url: string | null) {
16 const strapiURL = getStrapiURL();
17 if (url == null) return null;
18 if (url.startsWith("data:")) return url;
19 if (url.startsWith("http") || url.startsWith("//")) return url;
20 return `${strapiURL}${url}`;
21}
22
23export function StrapiImage({
24 src,
25 alt,
26 className,
27 ...rest
28}: Readonly<IStrapiMediaProps>) {
29 const imageUrl = getStrapiMedia(src);
30 if (!imageUrl) return null;
31 return (
32 <Image
33 src={imageUrl}
34 alt={alt ?? "No alternative text provided"}
35 className={className}
36 {...rest}
37 />
38 );
39}
getStrapiMedia(): This function is designed to process media URLs from the Strapi CMS. It accepts a URL as a string or null.
If the input url is null, the function returns null, which could be used in cases where an image or media file is optional.
If the input URL starts with "data:", it is returned as-is. This condition checks for data URLs, which are URLs that contain actual data (e.g., base64-encoded images) instead of linking to an external resource.
This is often used to embed small images directly in HTML or CSS to reduce the number of HTTP requests.
If the input URL starts with "http" or "//", it is also returned as-is. This covers absolute URLs, meaning the media is hosted outside the Strapi backend (possibly on another domain or CDN).
If none of the above conditions are met, the function assumes the url is a relative path to a resource on the Strapi backend.
We ara also importing the following helper function called getStrapiURL
; first, let's add it to our src/lib/utils.ts
file and then review what it does.
1export function getStrapiURL() {
2 return process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
3}
getStrapiURL():
This function returns the URL of the Strapi API. We are setting our environment name to NEXT_PUBLIC_
, which will be available in both the server and client components.
Note: only set public for none private items when using NEXT_PUBLIC_
, they will be seen by all. You can learn more in Next.js docs.
Now that we have our StrapiImage component let's use it in our Hero Section.
Navigate to src/app/components/custom/hero-section.tsx
and make the following changes.
First, import our newly created component.
1import { StrapiImage } from "@/components/custom/strapi-image";
Second, replace the img
tag with the following.
1<StrapiImage
2 alt={image.alternativeText ?? "no alternative text"}
3 className="absolute inset-0 object-cover w-full h-full aspect/16:9"
4 src={image.url}
5 height={1080}
6 width={1920}
7/>
The completed code should look like this:
1import Link from "next/link";
2import type { TImage, TLink } from "@/types";
3
4import { StrapiImage } from "./strapi-image";
5
6export interface IHeroSectionProps {
7 id: number;
8 documentId: string;
9 __component: string;
10 heading: string;
11 subHeading: string;
12 image: TImage;
13 link: TLink;
14}
15
16const styles = {
17 header: "relative h-[600px] overflow-hidden",
18 backgroundImage: "absolute inset-0 object-cover w-full h-full",
19 overlay:
20 "relative z-10 flex flex-col items-center justify-center h-full text-center text-white bg-black/50",
21 heading: "text-4xl font-bold md:text-5xl lg:text-6xl",
22 subheading: "mt-4 text-lg md:text-xl lg:text-2xl",
23 button:
24 "mt-8 inline-flex items-center justify-center px-6 py-3 text-base font-medium text-black bg-white rounded-md shadow hover:bg-gray-100 transition-colors",
25};
26
27export function HeroSection({ data }: { data: IHeroSectionProps }) {
28 if (!data) return null;
29
30 const { heading, subHeading, image, link } = data;
31
32 console.dir(data, { depth: null });
33 return (
34 <header className={styles.header}>
35 <StrapiImage
36 alt={image.alternativeText ?? "no alternative text"}
37 className="absolute inset-0 object-cover w-full h-full aspect/16:9"
38 src={image.url}
39 height={1080}
40 width={1920}
41 />
42 <div className={styles.overlay}>
43 <h1 className={styles.heading}>{heading}</h1>
44 <p className={styles.subheading}>{subHeading}</p>
45 <Link className={styles.button} href={link.href}>
46 {link.label}
47 </Link>
48 </div>
49 </header>
50 );
51}
When you restart the application, and... you will see the following error.
Clicking on the link in the error will take you here, which explains the steps to fix this.
Inside the root of your project, locate the next.config.ts
file and make the following change.
1import type { NextConfig } from "next";
2
3const nextConfig: NextConfig = {
4 /* config options here */
5 images: {
6 remotePatterns: [
7 {
8 protocol: "http",
9 hostname: "localhost",
10 port: "1337",
11 pathname: "/uploads/**/*",
12 },
13 ],
14 },
15};
16
17export default nextConfig;
Now, when you restart your application, you should see the following with your image.
Nice, now let's work on our Features Section
Building Out Our Features Section
Modeling Our Features Section Data In Strapi
Looking at our Features Section UI, we can break it down into the following parts.
We have a section that has repeatable components with the following items.
- Icon
- Heading
- Subheading
So, let's jump into our Strapi Admin and create our Features Section Component.
Let's start by navigating to Content-Type Builder
under COMPONENTS
, clicking on Create new component
, and let's call it Features Section and save it under the layout
category.
We will create the following fields.
Text -> Short Text - title Text -> Long Text - description
Finally, let's create a repeatable component called Feature and save it under components.
Display Name -> Feature Category -> components Type: repeatable component Name: features
And add the following fields.
heading -> Text -> Short Text - heading subHeading -> Text -> Long Text - subHeading icon -> Enum -> with the following options
- CLOCK_ICON
- CHECK_ICON
- CLOUD_ICON
Let's add our newly created Feature Section component to our home page.
Now, let's add some features data and save.
Navigate to Content Manager, select the Home Page, add the new Features Section block, and fill in your features.
We are already getting our page data; let's navigate to src/app/page.tsx
and update our query to populate our feature
repeatable component.
Let's update the homePageQuery
query with the following changes. Remember in Strapi 5 we have to user the on
flag to populate our dynamic zone components.
1const homePageQuery = qs.stringify({
2 populate: {
3 blocks: {
4 on: {
5 "layout.hero-section": {
6 populate: {
7 image: {
8 fields: ["url", "alternativeText"],
9 },
10 link: {
11 populate: true,
12 },
13 },
14 },
15 "layout.features-section": {
16 populate: {
17 features: {
18 populate: true,
19 },
20 },
21 },
22 },
23 },
24 },
25});
Also, let's update our getStrapiData
function to use our new helper method, getStrapiURL.
So it will look like the following.
So don't forget to import it.
1import { getStrapiURL } from "@/lib/utils";
1async function getStrapiData(path: string) {
2 const baseUrl = getStrapiURL();
3 const url = new URL(path, baseUrl);
4 url.search = homePageQuery;
5
6 try {
7 const response = await fetch(url.href);
8 const data = await response.json();
9 return data;
10 } catch (error) {
11 console.error(error);
12 }
13}
Now, let's console log our block
and see what the response looks like.
1console.dir(blocks, { depth: null });
We should see the following data.
1[
2 {
3 __component: "layout.hero-section",
4 id: 4,
5 heading: "Summarize Your Videos With Ease",
6 subHeading:
7 "Get back your time by getting all the key points with out watching the whole video.",
8 image: {
9 id: 2,
10 documentId: "bqs73cv7n0r7c08bsi2rdsww",
11 url: "/uploads/pexels_anna_nekrashevich_7552374_00d755b030.jpg",
12 alternativeText: null,
13 },
14 link: { id: 4, href: "/login", label: "Login", isExternal: null },
15 },
16 {
17 __component: "layout.features-section",
18 id: 2,
19 title: "Features",
20 description: "Checkout our cool features.",
21 feature: [
22 {
23 id: 4,
24 heading: "Save Time",
25 subHeading:
26 "No need to watch the entire video. Get the summary and save time.",
27 icon: "CLOCK_ICON",
28 },
29 {
30 id: 5,
31 heading: "Accurate Summaries",
32 subHeading: "Our AI-powered tool provides summaries of your content.",
33 icon: "CHECK_ICON",
34 },
35 {
36 id: 6,
37 heading: "Cloud Based",
38 subHeading: "Access your video summaries from anywhere at any time.",
39 icon: "CLOUD_ICON",
40 },
41 ],
42 },
43];
Notice that we are getting both our Hero Section and Features Section
Now, let's create a component to display our feature data.
Building Our Features Section Data In Next.js
Let's navigate to src/app/components/custom
, create a file called features-section.tsx
, and paste it into the following code.
1import React from "react";
2import { TFeature } from "@/types";
3
4export interface IFeaturesSectionProps {
5 id: number;
6 __component: string;
7 title: string;
8 description: string;
9 features?: TFeature[] | null;
10}
11
12function getIcon(name: string) {
13 switch (name) {
14 case "CLOCK_ICON":
15 return <ClockIcon className="w-12 h-12 mb-4 text-gray-900" />;
16 case "CHECK_ICON":
17 return <CheckIcon className="w-12 h-12 mb-4 text-gray-900" />;
18 case "CLOUD_ICON":
19 return <CloudIcon className="w-12 h-12 mb-4 text-gray-900" />;
20 default:
21 return null;
22 }
23}
24
25const styles = {
26 container: "flex-1",
27 section: "container px-4 py-6 mx-auto md:px-6 lg:py-24",
28 grid: "grid gap-8 md:grid-cols-3",
29 featureCard: "flex flex-col items-center text-center",
30 icon: "w-12 h-12 mb-4 text-gray-900",
31 heading: "mb-4 text-2xl font-bold",
32 description: "text-gray-500",
33};
34
35export function FeaturesSection({ data }: { data: IFeaturesSectionProps }) {
36 if (!data?.features) return null;
37 return (
38 <div>
39 <div className={styles.container}>
40 <section className={styles.section}>
41 <div className={styles.grid}>
42 {data.features.map((item: TFeature) => (
43 <div className={styles.featureCard} key={item.id}>
44 {getIcon(item.icon)}
45 <h2 className={styles.heading}>{item.heading}</h2>
46 <p className={styles.description}>{item.subHeading}</p>
47 </div>
48 ))}
49 </div>
50 </section>
51 </div>
52 </div>
53 );
54}
55
56function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
57 return (
58 <svg
59 {...props}
60 xmlns="http://www.w3.org/2000/svg"
61 width="24"
62 height="24"
63 viewBox="0 0 24 24"
64 fill="none"
65 stroke="currentColor"
66 strokeWidth="2"
67 strokeLinecap="round"
68 strokeLinejoin="round"
69 >
70 <polyline points="20 6 9 17 4 12" />
71 </svg>
72 );
73}
74
75function ClockIcon(props: React.SVGProps<SVGSVGElement>) {
76 return (
77 <svg
78 {...props}
79 xmlns="http://www.w3.org/2000/svg"
80 width="24"
81 height="24"
82 viewBox="0 0 24 24"
83 fill="none"
84 stroke="currentColor"
85 strokeWidth="2"
86 strokeLinecap="round"
87 strokeLinejoin="round"
88 >
89 <circle cx="12" cy="12" r="10" />
90 <polyline points="12 6 12 12 16 14" />
91 </svg>
92 );
93}
94
95function CloudIcon(props: React.SVGProps<SVGSVGElement>) {
96 return (
97 <svg
98 {...props}
99 xmlns="http://www.w3.org/2000/svg"
100 width="24"
101 height="24"
102 viewBox="0 0 24 24"
103 fill="none"
104 stroke="currentColor"
105 strokeWidth="2"
106 strokeLinecap="round"
107 strokeLinejoin="round"
108 >
109 <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
110 </svg>
111 );
112}
Let's navigate to src/app/page.tsx
, import our newly created component, and see what we get.
1import { FeaturesSection } from "@/components/custom/features-section";
And update the return
statement with the following code.
1return (
2 <main>
3 <HeroSection data={blocks[0]} />
4 <FeaturesSection data={blocks[1]} />
5 </main>
6 );
When we restart our application and refresh the page with command + r
, we should see the following.
Before getting too far let's update the way we load data. We will create a simple SDK that will allows us to easily fetch and mutated data.
In src
folder, create a new folder called data
with a new file called data-api.ts
and add the following code:
fetch-data.tsx
1import type { TStrapiResponse } from "@/types";
2// import { actions } from "@/data/actions";
3
4type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
5
6type ApiOptions<P = Record<string, unknown>> = {
7 method: HTTPMethod;
8 payload?: P;
9 timeoutMs?: number;
10};
11
12/**
13 * Unified API function with timeout to prevent requests from hanging indefinitely
14 *
15 * Problem it solves:
16 * - Slow/broken servers can cause requests to hang forever
17 * - This blocks the UI and creates poor user experience
18 * - Manual fetch implementations scattered across the codebase
19 * - Inconsistent error handling and authentication patterns
20 *
21 * How it works:
22 * - Single function handles all HTTP methods (GET, POST, PUT, PATCH, DELETE)
23 * - Automatic authentication - checks for auth token and includes it if available
24 * - AbortController ensures requests complete within reasonable timeframe
25 * - Consistent error formatting for all request types
26 * - Special handling for DELETE requests that may not return JSON
27 *
28 * Features:
29 * - Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE)
30 * - Automatic authentication (auto-adds Bearer token when available)
31 * - Timeout protection (8 seconds default)
32 * - Consistent error handling and response formatting
33 * - Handles DELETE requests without response body parsing
34 */
35
36async function apiWithTimeout(
37 input: RequestInfo,
38 init: RequestInit = {},
39 timeoutMs = 8000 // 8 seconds default - good balance between patience and UX
40): Promise<Response> {
41 // Create controller to manage request cancellation
42 const controller = new AbortController();
43
44 // Set up automatic cancellation after timeout period
45 const timeout = setTimeout(() => controller.abort(), timeoutMs);
46
47 try {
48 const response = await fetch(input, {
49 ...init,
50 signal: controller.signal, // Connect the abort signal to fetch
51 });
52 return response;
53 } finally {
54 // Always clean up the timeout to prevent memory leaks
55 // This runs whether the request succeeds, fails, or times out
56 clearTimeout(timeout);
57 }
58}
59
60export async function apiRequest<T = unknown, P = Record<string, unknown>>(
61 url: string,
62 options: ApiOptions<P>
63): Promise<TStrapiResponse<T>> {
64 const { method, payload, timeoutMs = 8000 } = options;
65
66 // Set up base headers for JSON communication
67 const headers: Record<string, string> = {
68 "Content-Type": "application/json",
69 };
70
71 // Automatically check for auth token and include it if available
72 // Note: This only works in server-side contexts (server components, server actions)
73 // For client-side usage, consider using server actions instead
74 // let authToken: string | undefined;
75 // try {
76 // authToken = await actions.auth.getAuthTokenAction();
77 // } catch {
78 // // getAuthTokenAction is a server action and will fail on client-side
79 // console.warn("Cannot access auth token from client-side. Use server actions for authenticated requests.");
80 // authToken = undefined;
81 // }
82
83 // if (authToken) {
84 // headers["Authorization"] = `Bearer ${authToken}`;
85 // }
86
87 try {
88 // Make the actual API request with timeout protection
89 const response = await apiWithTimeout(url, {
90 method,
91 headers,
92 // GET and DELETE requests don't have request bodies
93 body: method === "GET" || method === "DELETE" ? undefined : JSON.stringify(payload ?? {}),
94 }, timeoutMs);
95
96 // Handle DELETE requests that may not return JSON response body
97 if (method === "DELETE") {
98 return response.ok
99 ? { data: true as T, success: true, status: response.status }
100 : {
101 error: {
102 status: response.status,
103 name: "Error",
104 message: "Failed to delete resource",
105 },
106 success: false,
107 status: response.status,
108 };
109 }
110
111 // Parse the JSON response for all other methods
112 const data = await response.json();
113
114 // Handle unsuccessful responses (4xx, 5xx status codes)
115 if (!response.ok) {
116 console.error(`API ${method} error (${response.status}):`, {
117 url,
118 status: response.status,
119 statusText: response.statusText,
120 data,
121 // hasAuthToken: !!authToken
122 });
123
124 // If Strapi returns a structured error, pass it through as-is
125 if (data.error) {
126 return {
127 error: data.error,
128 success: false,
129 status: response.status,
130 };
131 }
132
133 // Otherwise create a generic error response
134 return {
135 error: {
136 status: response.status,
137 name: data?.error?.name ?? "Error",
138 message: data?.error?.message ?? (response.statusText || "An error occurred"),
139 },
140 success: false,
141 status: response.status,
142 };
143 }
144
145 // Success case - extract Strapi data field to avoid double nesting
146 // Strapi returns: { data: {...}, meta: {...} }
147 // We want to return: { data: {...}, meta: {...}, success: true, status: 200 }
148 const responseData = data.data ? data.data : data;
149 const responseMeta = data.meta ? data.meta : undefined;
150 return {
151 data: responseData as T,
152 meta: responseMeta,
153 success: true,
154 status: response.status
155 };
156 } catch (error) {
157 // Handle timeout errors specifically (when AbortController cancels the request)
158 if ((error as Error).name === "AbortError") {
159 console.error("Request timed out");
160 return {
161 error: {
162 status: 408,
163 name: "TimeoutError",
164 message: "The request timed out. Please try again.",
165 },
166 success: false,
167 status: 408,
168 } as TStrapiResponse<T>;
169 }
170
171 // Handle network errors, JSON parsing errors, and other unexpected issues
172 console.error(`Network or unexpected error on ${method} ${url}:`, error);
173 return {
174 error: {
175 status: 500,
176 name: "NetworkError",
177 message: error instanceof Error ? error.message : "Something went wrong",
178 },
179 success: false,
180 status: 500,
181 } as TStrapiResponse<T>;
182 }
183}
184
185/**
186 * Convenience API methods for common HTTP operations
187 *
188 * Usage examples:
189 *
190 * // Public endpoints (work without authentication)
191 * const homePage = await api.get<THomePage>('/api/home-page');
192 * const authResult = await api.post<TAuthResponse, TLoginData>('/api/auth/local', loginData);
193 *
194 * // Protected endpoints (automatically include auth token when available)
195 * const userProfile = await api.get<TUser>('/api/users/me');
196 * const updated = await api.put<TUser, TProfileData>('/api/users/123', profileData);
197 * const deleted = await api.delete<boolean>('/api/posts/456');
198 */
199export const api = {
200 // GET request - for fetching data
201 get: <T>(url: string, timeoutMs?: number) =>
202 apiRequest<T>(url, { method: "GET", timeoutMs }),
203
204 // POST request - for creating new resources
205 post: <T, P = Record<string, unknown>>(url: string, payload: P, timeoutMs?: number) =>
206 apiRequest<T, P>(url, { method: "POST", payload, timeoutMs }),
207
208 // PUT request - for updating entire resources
209 put: <T, P = Record<string, unknown>>(url: string, payload: P, timeoutMs?: number) =>
210 apiRequest<T, P>(url, { method: "PUT", payload, timeoutMs }),
211
212 // PATCH request - for partial updates
213 patch: <T, P = Record<string, unknown>>(url: string, payload: P, timeoutMs?: number) =>
214 apiRequest<T, P>(url, { method: "PATCH", payload, timeoutMs }),
215
216 // DELETE request - for removing resources
217 delete: <T>(url: string, timeoutMs?: number) =>
218 apiRequest<T>(url, { method: "DELETE", timeoutMs }),
219};
note: I commented out auth logic above but wee will ge to it leated in the tutorial.
Overview of API Utility Code
The code provides a unified API request utility with built-in authentication, error handling, and timeout protection. It ensures all HTTP requests are handled consistently across the application.
Key Components
Type Definitions
- HTTPMethod: Defines supported methods (
GET
,POST
,PUT
,PATCH
,DELETE
). - ApiOptions: Options object including
method
, optionalpayload
, andtimeoutMs
.
apiWithTimeout
- Wraps the native
fetch
with an AbortController. - Prevents requests from hanging indefinitely by automatically cancelling them after a specified timeout (default: 8 seconds).
- Ensures proper cleanup with
clearTimeout
.
apiRequest
- Main function for making API requests.
- Handles:
- Authentication: Automatically includes a Bearer token (if available).
- Request Bodies: Adds payloads for non-GET/DELETE requests.
- Timeouts: Uses
apiWithTimeout
to cancel slow requests. - Error Handling:
- Logs detailed errors for debugging.
- Returns consistent error response structures.
- Special handling for
DELETE
requests (which may not return JSON).
- Response Parsing: Extracts
data
andmeta
from Strapi responses for cleaner results.
Convenience Methods
Exposes a simplified api
object with methods for common operations:
api.get(url)
api.post(url, payload)
api.put(url, payload)
api.patch(url, payload)
api.delete(url)
Each method automatically applies the unified logic from apiRequest
.
Benefits
- Consistency: All requests use the same logic and error formatting.
- Resilience: Timeout protection prevents UI freezes from stalled servers.
- Security: Automatically includes authentication tokens when available.
- Convenience: Provides shorthand methods for common request types.
- Flexibility: Supports all HTTP methods and customizable timeouts.
Example Usage:
1// Public request
2const homePage = await api.get<THomePage>('/api/home-page');
3
4// Authenticated request
5const userProfile = await api.get<TUser>('/api/users/me');
6
7// Creating data
8const newPost = await api.post<TPost, TPostData>('/api/posts', { title: "Hello" });
9
10Now, let's utilized this by creating a specific loader to get our home page data.
11
12In the `data` folder create a new file called `loaders.ts` and add the following code:
13
14``` ts
15import qs from "qs";
16import type {
17 TStrapiResponse,
18 THomePage,
19} from "@/types";
20
21import { api } from "@/data/data-api";
22import { getStrapiURL } from "@/lib/utils";
23
24const baseUrl = getStrapiURL();
25
26async function getHomePageData(): Promise<TStrapiResponse<THomePage>> {
27 const query = qs.stringify({
28 populate: {
29 blocks: {
30 on: {
31 "layout.hero-section": {
32 populate: {
33 image: {
34 fields: ["url", "alternativeText"],
35 },
36 link: {
37 populate: true,
38 },
39 },
40 },
41 "layout.features-section": {
42 populate: {
43 features: {
44 populate: true,
45 },
46 },
47 },
48 },
49 },
50 },
51 });
52
53 const url = new URL("/api/home-page", baseUrl);
54 url.search = query;
55 return api.get<THomePage>(url.href);
56}
57
58export const loaders = {
59 getHomePageData,
60};
Placing this logic in a dedicated loaders.ts file brings several advantages:
- Clear separation of concerns – Pages handle only rendering, while all data-fetching logic lives in one place.
- Type safety – Using TStrapiResponse
ensures compile-time error checking and provides full IntelliSense support when working with API data. - Reusability – The fetchData utility can be used by multiple loaders, while each loader focuses on its specific query requirements.
- Easier maintenance – Centralizing API logic in a single location makes debugging and testing simpler compared to having data-fetching scattered across components.
Before updating our page.tsx
to use our new loader, let's create one more utility to help us with simple error handling.
In the src/lib
forlder create a file called error-handler.ts
and add the following:
1import { notFound } from "next/navigation";
2import type { TStrapiResponse } from "@/types";
3
4/**
5 * Handles API response errors consistently across all routes
6 *
7 * @param data - The API response data
8 * @param resourceName - Optional name of the resource for better error messages (e.g., "summary", "user")
9 * @throws Error when the response indicates failure (non-404 errors)
10 * @returns void - Function either succeeds silently or throws/redirects
11 */
12export function handleApiError<T>(
13 data: TStrapiResponse<T> | null | undefined,
14 resourceName?: string
15): void {
16 if (!data) {
17 throw new Error(`Failed to load ${resourceName || "resource"}`);
18 }
19
20 // Handle 404 errors specifically with notFound()
21 if (data?.error?.status === 404) {
22 notFound();
23 }
24
25 // Handle all other API errors
26 if (!data?.success || !data?.data) {
27 const errorMessage = data?.error?.message || `Failed to load ${resourceName || "resource"}`;
28 throw new Error(errorMessage);
29 }
30}
31
32/**
33 * Validates and extracts data from API response, handling errors automatically
34 *
35 * @param data - The API response data
36 * @param resourceName - Optional name of the resource for better error messages
37 * @returns The extracted data from the response
38 * @throws Error when the response indicates failure
39 */
40export function validateApiResponse<T>(
41 data: TStrapiResponse<T> | null | undefined,
42 resourceName?: string
43): T {
44 handleApiError(data, resourceName);
45 return data!.data!;
46}
Now let's update our page.tsx
file to use the new loader and validation function with the following code:
1import { loaders } from "@/data/loaders";
2import { validateApiResponse } from "@/lib/error-handler";
3
4import { HeroSection } from "@/components/custom/hero-section"; // add this
5import { FeaturesSection } from "@/components/custom/features-section";
6
7export default async function Home() {
8 const homePageData = await loaders.getHomePageData();
9 const data = validateApiResponse(homePageData, "home page");
10 const { blocks } = data;
11
12 return (
13 <main>
14 <HeroSection data={blocks[0]} />
15 <FeaturesSection data={blocks[1]} />
16 </main>
17 );
18}
Everything will still work as before, but we can make an improvement. Right now, our components are hardcoded, which works fine for now—but if we ever want to add more components in the future, it would be nice to handle them more dynamically.
To do this, we can create a block renderer function:
1 function blockRenderer(block: TBlocks, index: number) {
2 switch (block.__component) {
3 case "layout.hero-section":
4 return <HeroSection key={index} data={block as IHeroSectionProps} />;
5 case "layout.features-section":
6 console.log("Sections data:", block)
7 return <FeaturesSection key={index} data={block as IFeaturesSectionProps} />;
8 default:
9 return null;
10 }
11 }
Import the IHeroSectionProps
and IFeaturesSectionProps
interfaces from their appropriate components.
1import { HeroSection, type IHeroSectionProps } from "@/components/custom/hero-section";
2import { FeaturesSection, type IFeaturesSectionProps } from "@/components/custom/features-section";
And create the following union type:
1// Union type of all possible block components
2export type TBlocks = IHeroSectionProps | IFeaturesSectionProps;
Update the code in the return with the following:
1 <main>{blocks.map((block, index) => blockRenderer(block, index))}</main>
And finally in the srs/types/index.ts
file make the following updates"
Import the TBlocks union type.
1import type { TBlocks } from "@/app/page";
And update the following
1export type THomePage = {
2 documentId: string;
3 title: string;
4 description: string;
5 createdAt: string;
6 updatedAt: string;
7 publishedAt: string;
8 blocks: TBlocks[] // we remove the any type
9};
The completed code should look as follows:
1import { loaders } from "@/data/loaders";
2import { validateApiResponse } from "@/lib/error-handler";
3
4import { HeroSection, type IHeroSectionProps } from "@/components/custom/hero-section";
5import { FeaturesSection, type IFeaturesSectionProps } from "@/components/custom/features-section";
6
7// Union type of all possible block components
8export type TBlocks = IHeroSectionProps | IFeaturesSectionProps;
9
10function blockRenderer(block: TBlocks, index: number) {
11 switch (block.__component) {
12 case "layout.hero-section":
13 return <HeroSection key={index} data={block as IHeroSectionProps} />;
14 case "layout.features-section":
15 console.log("Sections data:", block);
16 return (
17 <FeaturesSection key={index} data={block as IFeaturesSectionProps} />
18 );
19 default:
20 return null;
21 }
22}
23
24export default async function Home() {
25 const homePageData = await loaders.getHomePageData();
26 const data = validateApiResponse(homePageData, "home page");
27 const { blocks } = data;
28
29 return (
30 <main>{blocks.map((block, index) => blockRenderer(block, index))}</main>
31 );
32}
Nice. Let's move on and start working on our Header and Footer
Building Our Header and Footer With Strapi and Next.js
Taking a quick look at our Header and Footer, we see that they are simple enough. In the header, we have two items, logo text
and button.
In the footer, we have logo text,
text,
and social icons.
Let's first start by taking a look at how we have represented this data in Strapi.
Modeling Our Header and Footer Data in Strapi
We are going to store the data for our' Header' and' Footer' using a single type.'
Navigating to Content-Type Builder
under SINGLE TYPE
and clicking on Create new single type.
We are going to call it Global
. Go ahead and add the following fields. It should follow the same steps when we built our Home Page single type.
Text -> Short Text - title Text -> Long Text - description
Now, let's create the Header component. To start with and we will add our
In Strapi, inside the global page, let's add the following component.
- Click on
add another field to this single type.
- Select the
Component
field type Display Name
will be Header- Select
Category
will belayout
- Click on
Configure the component
button - In the
Name
field, we will enter header - Finally, click on the
Add the first field to component
button
Now let's create two additional components called logoText
and ctaButton
to store our logo text and call to action button data.
Since both will be links, we can reuse a previously created Link component.
- Select the
Component
field type - Click on
Use an existing component
- Click on the
Select component
button - Inside the
Select a component
field, select Link component - In the
Name
field, we will enter logoText Select
Single component
and click theFinish
buttonSelect
Add another field to this component
- Select the
Component
field type - Click on
Use an existing component
- Click on the
Select a component
button
- Click on the
- In the
Name
field, we will enter ctaButton - Inside the
Select a component
field, select Link component - Select
Single component
and click theFinish
button - Select Single component and click the Finish button
The final Header component should look like the following.
Now that we are getting the hang of modeling content think about how we can represent our footer.
- logoText
- text
- socialLink
We can create the Footer the same way we made our Header.
Can you do it on your own?
Our Footer will have the following fields.
Our footer has the following three items.
If you get stuck at any point, you can always ask in the comments or join us at Strapi Open Office hours on Discord 12:30 pm CST Monday - Friday.
Now just add some data in your Global compoent for both your header and footer.
Now, let's give the proper permissions so we can access the data from our Strapi API.
Navigate to Setting
-> USERS AND PERMISSION PLUGIN
-> Roles
-> Public
-> Global
and check the find
checkbox. We now should be able to make a GET
request to /api/global
and see our data.
Since we have already learned about Strapi's Populate, we can jump straight into our frontend code and implement the function to fetch our Global data.
Fetching Our Global Header and Footer Data
Let's navigate to src/data/loaders.ts
and create a new function called getGlobalData
; it should look like the following.
1async function getGlobalData(): Promise<TStrapiResponse<TGlobal>> {
2 const query = qs.stringify({
3 populate: [
4 "header.logoText",
5 "header.ctaButton",
6 "footer.logoText",
7 "footer.socialLink",
8 ],
9 });
10
11 const url = new URL("/api/global", baseUrl);
12 url.search = query;
13 return api.get<TGlobal>(url.href);
14}
Don't forget import TGlobal types at the top:
1import type {
2 TStrapiResponse,
3 THomePage,
4 TGlobal
5} from "@/types";
And export the new loader:
1export const loaders = {
2 getHomePageData, getGlobalData
3};
One thing to notice here is that we are using array
notation in populate, which is a great way to populate items that don't have many nested items.
If you need more help with Populate and Filtering in Strapi, check out this post.
Your loaders.ts
file should look like the following:
1import qs from "qs";
2import type {
3 TStrapiResponse,
4 THomePage,
5 TGlobal
6} from "@/types";
7
8import { api } from "@/data/data-api";
9import { getStrapiURL } from "@/lib/utils";
10
11const baseUrl = getStrapiURL();
12
13async function getHomePageData(): Promise<TStrapiResponse<THomePage>> {
14 const query = qs.stringify({
15 populate: {
16 blocks: {
17 on: {
18 "layout.hero-section": {
19 populate: {
20 image: {
21 fields: ["url", "alternativeText"],
22 },
23 link: {
24 populate: true,
25 },
26 },
27 },
28 "layout.features-section": {
29 populate: {
30 features: {
31 populate: true,
32 },
33 },
34 },
35 },
36 },
37 },
38 });
39
40 const url = new URL("/api/home-page", baseUrl);
41 url.search = query;
42 return api.get<THomePage>(url.href);
43}
44
45async function getGlobalData(): Promise<TStrapiResponse<TGlobal>> {
46 const query = qs.stringify({
47 populate: [
48 "header.logoText",
49 "header.ctaButton",
50 "footer.logoText",
51 "footer.socialLink",
52 ],
53 });
54
55 const url = new URL("/api/global", baseUrl);
56 url.search = query;
57 return api.get<TGlobal>(url.href);
58}
59
60
61export const loaders = {
62 getHomePageData, getGlobalData
63};
Now that we have our getGlobalData
function let's use it.
Since our Header and Footer will live int the layout.tsx
file, let's call our function there.
Since we can load data within our React Server Component, we can call the function there directly.
First, let's import our function.
1import { loaders } from "@/data/loaders";
2import { validateApiResponse } from "@/lib/error-handler";
Then, update the RootLayout with the following code.
1export default async function RootLayout({
2 children,
3}: Readonly<{
4 children: React.ReactNode;
5}>) {
6 const globalDataResponse = await loaders.getGlobalData();
7 const globalData = validateApiResponse(globalDataResponse, "global page");
8 console.dir(globalData, { depth: null });
9
10 return (
11 <html lang="en">
12 <body
13 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
14 >
15 {children}
16 </body>
17 </html>
18 );
19}
The complete code should look like the following.
1import type { Metadata } from "next";
2
3import { loaders } from "@/data/loaders";
4import { validateApiResponse } from "@/lib/error-handler";
5
6import { Geist, Geist_Mono } from "next/font/google";
7import "./globals.css";
8
9const geistSans = Geist({
10 variable: "--font-geist-sans",
11 subsets: ["latin"],
12});
13
14const geistMono = Geist_Mono({
15 variable: "--font-geist-mono",
16 subsets: ["latin"],
17});
18
19export const metadata: Metadata = {
20 title: "Create Next App",
21 description: "Generated by create next app",
22};
23
24export default async function RootLayout({
25 children,
26}: Readonly<{
27 children: React.ReactNode;
28}>) {
29 const globalDataResponse = await loaders.getGlobalData();
30 const globalData = validateApiResponse(globalDataResponse, "global page");
31 console.dir(globalData, { depth: null });
32
33 return (
34 <html lang="en">
35 <body
36 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
37 >
38 {children}
39 </body>
40 </html>
41 );
42}
Nice. Now restart your Next.js application, and we should see the following output in the terminal console.
{
data: {
id: 2,
documentId: 'vcny1vttvfqm1hd8dd6390rp',
title: 'Global Page',
description: 'Responsible for our header and footer sections.',
createdAt: '2025-08-14T00:02:31.328Z',
updatedAt: '2025-08-14T00:02:31.328Z',
publishedAt: '2025-08-14T00:02:31.336Z',
header: {
id: 2,
ctaButton: { id: 12, href: '/signin', label: 'Sign In', isExternal: null },
logoText: { id: 11, href: '/', label: 'Summarize AI', isExternal: false }
},
footer: {
id: 2,
text: 'Built with love by Paul 2025',
socialLink: [
{
id: 14,
href: 'www.youtube.com',
label: 'YouTube',
isExternal: true
},
{
id: 15,
href: 'www.linkedin.com',
label: 'LinkedIn',
isExternal: true
},
{
id: 16,
href: 'www.twitter.com',
label: 'Twitter',
isExternal: true
}
],
logoText: { id: 13, href: '/', label: 'Summarize AI', isExternal: null }
}
},
meta: {}
}
That is amazing.
Building Our Header In Next.js
Alright, let's build out our Header component for our top navigation.
Just as a reminder, our logo has two items. A logo and button , so let's first create our Logo
component.
Navigate to src/app/components/custom
, create a file called logo.tsx,
and add the following code.
1import Link from "next/link";
2
3const styles = {
4 link: "flex items-center gap-2",
5 icon: "h-6 w-6 text-pink-500",
6 text: {
7 base: "text-lg font-semibold",
8 light: "text-slate-900",
9 dark: "text-white",
10 },
11};
12
13function MountainIcon(props: React.SVGProps<SVGSVGElement>) {
14 return (
15 <svg
16 {...props}
17 xmlns="http://www.w3.org/2000/svg"
18 width="24"
19 height="24"
20 viewBox="0 0 24 24"
21 fill="none"
22 stroke="currentColor"
23 strokeWidth="2"
24 strokeLinecap="round"
25 strokeLinejoin="round"
26 >
27 <path d="m8 3 4 8 5-5 5 15H2L8 3z" />
28 </svg>
29 );
30}
31
32interface ILogoProps {
33 text: string;
34 dark?: boolean;
35}
36
37export function Logo({
38 text,
39 dark = false,
40}: ILogoProps) {
41 return (
42 <Link className={styles.link} href="/">
43 <MountainIcon className={styles.icon} />
44 <span className={`${styles.text.base} ${dark ? styles.text.dark : styles.text.light}`}>
45 {text}
46 </span>
47 </Link>
48 );
49}
It is a simple component that expects text
as a prop to display the name of our site and a dark
prop to allow us to make the text white on dark backgrounds.
Next, let's create our Header component. Navigate to src/app/components/custom
, create a file called header.tsx,
and add the following code.
1import Link from "next/link";
2import type { THeader } from "@/types";
3
4import { Logo } from "@/components/custom/logo";
5import { Button } from "@/components/ui/button";
6
7const styles = {
8 header:
9 "flex items-center justify-between px-4 py-3 bg-white shadow-md dark:bg-gray-800",
10 actions: "flex items-center gap-4",
11 summaryContainer: "flex-1 flex justify-center max-w-2xl mx-auto",
12};
13
14interface IHeaderProps {
15 data?: THeader | null;
16}
17
18export async function Header({ data }: IHeaderProps) {
19 if (!data) return null;
20
21 const { logoText, ctaButton } = data;
22 return (
23 <div className={styles.header}>
24 <Logo text={logoText.label} />
25 <div className={styles.actions}>
26 <Link href={ctaButton.href}>
27 <Button>{ctaButton.label}</Button>
28 </Link>
29 </div>
30 </div>
31 );
32}
Let's navigate to src/app/layout.tsx
file and make the following updates.
First, let's import our Header component.
1import { Header } from "@/components/custom/header";
Next, make the following change in the return
statement.
1return (
2 <html lang="en">
3 <body
4 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
5 >
6 <Header data={globalData?.header} />
7 {children}
8 </body>
9 </html>
10 );
Restart your project, and you should now see our awesome top navigation.
Building Our Footer In Next.js
Now, let's go ahead and build out our footer.
Our footer will display the following items.
Navigate to src/app/components/custom,
create a file called footer.tsx
, and add the following code.
1import Link from "next/link";
2import type { TFooter } from "@/types";
3import { Logo } from "@/components/custom/logo";
4
5const styles = {
6 footer: "dark bg-gray-900 text-white py-8",
7 container: "container mx-auto px-4 md:px-6 flex flex-col md:flex-row items-center justify-between",
8 text: "mt-4 md:mt-0 text-sm text-gray-300",
9 socialContainer: "flex items-center space-x-4",
10 socialLink: "text-white hover:text-gray-300",
11 icon: "h-6 w-6",
12 srOnly: "sr-only"
13};
14
15function selectSocialIcon(url: string) {
16 if (url.includes("youtube")) return <YoutubeIcon className={styles.icon} />;
17 if (url.includes("twitter")) return <TwitterIcon className={styles.icon} />;
18 if (url.includes("github")) return <GithubIcon className={styles.icon} />;
19 return null;
20}
21
22interface IFooterProps {
23 data?: TFooter | null;
24}
25
26export function Footer({ data }: IFooterProps) {
27 if (!data) return null;
28 const { logoText, socialLink, text } = data;
29 return (
30 <div className={styles.footer}>
31 <div className={styles.container}>
32 <Logo dark text={logoText.label} />
33 <p className={styles.text}>{text}</p>
34 <div className={styles.socialContainer}>
35 {socialLink.map((link) => {
36 return (
37 <Link
38 className={styles.socialLink}
39 href={link.href}
40 key={link.id}
41 >
42 {selectSocialIcon(link.href)}
43 <span className={styles.srOnly}>Visit us at {link.label}</span>
44 </Link>
45 );
46 })}
47 </div>
48 </div>
49 </div>
50 );
51}
52
53function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
54 return (
55 <svg
56 {...props}
57 xmlns="http://www.w3.org/2000/svg"
58 width="24"
59 height="24"
60 viewBox="0 0 24 24"
61 fill="none"
62 stroke="currentColor"
63 strokeWidth="2"
64 strokeLinecap="round"
65 strokeLinejoin="round"
66 >
67 <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
68 <path d="M9 18c-4.51 2-5-2-7-2" />
69 </svg>
70 );
71}
72
73function TwitterIcon(props: React.SVGProps<SVGSVGElement>) {
74 return (
75 <svg
76 {...props}
77 xmlns="http://www.w3.org/2000/svg"
78 width="24"
79 height="24"
80 viewBox="0 0 24 24"
81 fill="none"
82 stroke="currentColor"
83 strokeWidth="2"
84 strokeLinecap="round"
85 strokeLinejoin="round"
86 >
87 <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
88 </svg>
89 );
90}
91
92function YoutubeIcon(props: React.SVGProps<SVGSVGElement>) {
93 return (
94 <svg
95 {...props}
96 xmlns="http://www.w3.org/2000/svg"
97 width="24"
98 height="24"
99 viewBox="0 0 24 24"
100 fill="none"
101 stroke="currentColor"
102 strokeWidth="2"
103 strokeLinecap="round"
104 strokeLinejoin="round"
105 >
106 <path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
107 <path d="m10 15 5-3-5-3z" />
108 </svg>
109 );
110}
The code is responsible for rendering our Footer data.
selectSocialIcon(url: string): A function that determines which social media icon to display based on the URL provided. It supports YouTube, Twitter, and GitHub, returning the corresponding icon component or null if the URL does not match these platforms.
note: When adding social links, I only included Twitter, Github, and YouTube. If you have additional links, you will need to add more icons to represent them.
Now that we have completed our footer, let's add it to the layout.tsx file in the root of our app folder.
First, let's import our Footer component.
1import { Footer } from "@/components/custom/footer";
Next, make the following change in the return
statement.
1return (
2 <html lang="en">
3 <body
4 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
5 >
6 <Header data={globalData?.data?.header} />
7 {children}
8 <Footer data={globalData?.footer} />
9 </body>
10 </html>
11 );
12);
The complet file looks like the following:
1import type { Metadata } from "next";
2
3import { loaders } from "@/data/loaders";
4import { validateApiResponse } from "@/lib/error-handler";
5
6import { Header } from "@/components/custom/header";
7import { Footer } from "@/components/custom/footer";
8
9import { Geist, Geist_Mono } from "next/font/google";
10import "./globals.css";
11
12const geistSans = Geist({
13 variable: "--font-geist-sans",
14 subsets: ["latin"],
15});
16
17const geistMono = Geist_Mono({
18 variable: "--font-geist-mono",
19 subsets: ["latin"],
20});
21
22export const metadata: Metadata = {
23 title: "Create Next App",
24 description: "Generated by create next app",
25};
26
27export default async function RootLayout({
28 children,
29}: Readonly<{
30 children: React.ReactNode;
31}>) {
32 const globalDataResponse = await loaders.getGlobalData();
33 const globalData = validateApiResponse(globalDataResponse, "global page");
34 console.dir(globalData, { depth: null });
35
36 return (
37 <html lang="en">
38 <body
39 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
40 >
41 <Header data={globalData?.header} />
42 {children}
43 <Footer data={globalData?.footer} />
44 </body>
45 </html>
46 );
47}
48Ï
Now, if you restart the Next.js application, you should see the following changes.
Yay, we are now getting our data from our Strapi API.
How To Populate Our Metadata Dynamically In Next.js
We have a title
and description
on our Global page in Strapi.
Let's use it as our metadata
information in our app.
Let's look at the src/app/layout.tsx
file. We will see the following.
1export const metadata: Metadata = {
2 title: "Create Next App",
3 description: "Generated by create next app",
4};
This is one way to set metadata in Next.js, but as you notice, it is hardcoded. Let's look at how we can add metadata dynamically.
To dynamically populate our metadata, we must fetch it using our metadata
function.
We already have our getGlobalData
, but that function returns not just the title
and description
but also the rest of our data to populate our Header and Footer.
Let's create a new function called getGlobalPageMetadata,
which only returns the title
and description
fields.
Let's navigate to src/data/loaders.ts
and add the following code.
1async function getMetaData(): Promise<TStrapiResponse<TMetaData>> {
2 const query = qs.stringify({
3 fields: ["title", "description"],
4 });
5
6 const url = new URL("/api/global", baseUrl);
7 url.search = query;
8 return api.get<TMetaData>(url.href);
9}
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.
1 data: {
2 id: 3,
3 documentId: 'vcny1vttvfqm1hd8dd6390rp',
4 title: 'Epic Next Tutorial',
5 description: 'Learn Next.js 15 with Strapi 5.'
6 },
Don'f forget to import TMetaData
type at the top:
1import type { TStrapiResponse, THomePage, TGlobal, TMetaData } from "@/types";```
2
3And export it:
4
5```ts
6export const loaders = {
7 getHomePageData,
8 getGlobalData,
9 getMetaData,
10};
Your loaders.ts
file should look like the folllowing:
1import qs from "qs";
2import type { TStrapiResponse, THomePage, TGlobal, TMetaData } from "@/types";
3
4import { api } from "@/data/data-api";
5import { getStrapiURL } from "@/lib/utils";
6
7const baseUrl = getStrapiURL();
8
9async function getHomePageData(): Promise<TStrapiResponse<THomePage>> {
10 const query = qs.stringify({
11 populate: {
12 blocks: {
13 on: {
14 "layout.hero-section": {
15 populate: {
16 image: {
17 fields: ["url", "alternativeText"],
18 },
19 link: {
20 populate: true,
21 },
22 },
23 },
24 "layout.features-section": {
25 populate: {
26 features: {
27 populate: true,
28 },
29 },
30 },
31 },
32 },
33 },
34 });
35
36 const url = new URL("/api/home-page", baseUrl);
37 url.search = query;
38 return api.get<THomePage>(url.href);
39}
40
41async function getGlobalData(): Promise<TStrapiResponse<TGlobal>> {
42 const query = qs.stringify({
43 populate: [
44 "header.logoText",
45 "header.ctaButton",
46 "footer.logoText",
47 "footer.socialLink",
48 ],
49 });
50
51 const url = new URL("/api/global", baseUrl);
52 url.search = query;
53 return api.get<TGlobal>(url.href);
54}
55
56async function getMetaData(): Promise<TStrapiResponse<TMetaData>> {
57 const query = qs.stringify({
58 fields: ["title", "description"],
59 });
60
61 const url = new URL("/api/global", baseUrl);
62 url.search = query;
63 return api.get<TMetaData>(url.href);
64}
65
66export const loaders = {
67 getHomePageData,
68 getGlobalData,
69 getMetaData,
70};
Let's implement dynamic metadata inside our layout.tsx
file.
Let's update our current metadata
function with the following.
We are already importing our loaders.
1import { loaders } from "@/data/loaders";
Now, replace the previous export const metadata: Metadata
with the following code.
1export async function generateMetadata(): Promise<Metadata> {
2 const metadata = await loaders.getMetaData();
3
4 return {
5 title: metadata?.data?.title ?? "Epic Next Course",
6 description: metadata?.data?.description ?? "Epic Next Course",
7 };
8}
Now, our metadata is dynamically set from our Strapi Api.
The completed code should look like the following:
1import type { Metadata } from "next";
2
3import { loaders } from "@/data/loaders";
4import { validateApiResponse } from "@/lib/error-handler";
5
6import { Header } from "@/components/custom/header";
7import { Footer } from "@/components/custom/footer";
8
9import { Geist, Geist_Mono } from "next/font/google";
10import "./globals.css";
11
12const geistSans = Geist({
13 variable: "--font-geist-sans",
14 subsets: ["latin"],
15});
16
17const geistMono = Geist_Mono({
18 variable: "--font-geist-mono",
19 subsets: ["latin"],
20});
21
22export async function generateMetadata(): Promise<Metadata> {
23 const metadata = await loaders.getMetaData();
24
25 return {
26 title: metadata?.data?.title ?? "Epic Next Course",
27 description: metadata?.data?.description ?? "Epic Next Course",
28 };
29}
30
31export default async function RootLayout({
32 children,
33}: Readonly<{
34 children: React.ReactNode;
35}>) {
36 const globalDataResponse = await loaders.getGlobalData();
37 const globalData = validateApiResponse(globalDataResponse, "global page");
38 console.dir(globalData, { depth: null });
39
40 return (
41 <html lang="en">
42 <body
43 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
44 >
45 <Header data={globalData?.header} />
46 {children}
47 <Footer data={globalData?.footer} />
48 </body>
49 </html>
50 );
51}
Nice job.
How To Create A Not Found Page In Next.js
Our landing page looks great, but we have a small problem. We have not yet implemented the login
page, so when we click our link, we get the default not found page.
But why, if we wanted to make it prettier, how can we accomplish this?
Well, we can create the not-found.js
page. You can learn more about it here in the Next.js docs.
Navigate to src/app,
create a file called not-found.tsx
, and add the following code.
1"use client";
2
3import Link from "next/link";
4import { Button } from "@/components/ui/button";
5import { Home, Search, ArrowLeft } from "lucide-react";
6
7const styles = {
8 container: "min-h-[calc(100vh-200px)] mx-auto container my-8 bg-gradient-to-br rounded-lg shadow-md bg-secondary flex items-center justify-center p-4",
9 content: "max-w-2xl mx-auto text-center space-y-8",
10 textSection: "space-y-4",
11 heading404: "text-9xl font-bold text-primary select-none",
12 headingContainer: "relative",
13 pageTitle: "text-4xl font-bold text-slate-800 mb-4",
14 description: "text-lg text-slate-600 max-w-md mx-auto leading-relaxed",
15 illustrationContainer: "flex justify-center py-8",
16 illustration: "relative animate-pulse",
17 searchCircle: "w-32 h-32 bg-slate-200 rounded-full flex items-center justify-center transition-all duration-300 hover:bg-slate-300",
18 searchIcon: "w-16 h-16 text-slate-400",
19 errorBadge: "absolute -top-2 -right-2 w-8 h-8 bg-red-100 rounded-full flex items-center justify-center animate-bounce",
20 errorSymbol: "text-red-500 text-xl font-bold",
21 buttonContainer: "flex flex-col sm:flex-row gap-4 justify-center items-center",
22 button: "min-w-[160px]",
23 buttonContent: "flex items-center gap-2",
24 buttonIcon: "w-4 h-4",
25 outlineButton: "min-w-[160px] bg-transparent"
26};
27
28export default function NotFound() {
29 return (
30 <div className={styles.container}>
31 <div className={styles.content}>
32 {/* Large 404 Text */}
33 <div className={styles.textSection}>
34 <h1 className={styles.heading404}>404</h1>
35 <div className={styles.headingContainer}>
36 <h2 className={styles.pageTitle}>
37 Page Not Found
38 </h2>
39 <p className={styles.description}>
40 Oops! The page you're looking for seems to have wandered off
41 into the digital wilderness.
42 </p>
43 </div>
44 </div>
45
46 {/* Illustration */}
47 <div className={styles.illustrationContainer}>
48 <div className={styles.illustration}>
49 <div className={styles.searchCircle}>
50 <Search className={styles.searchIcon} />
51 </div>
52 <div className={styles.errorBadge}>
53 <span className={styles.errorSymbol}>✕</span>
54 </div>
55 </div>
56 </div>
57
58 {/* Action Buttons */}
59 <div className={styles.buttonContainer}>
60 <Button asChild size="lg" className={styles.button}>
61 <Link href="/" className={styles.buttonContent}>
62 <Home className={styles.buttonIcon} />
63 Go Home
64 </Link>
65 </Button>
66
67 <Button
68 asChild
69 variant="outline"
70 size="lg"
71 className={styles.outlineButton}
72 >
73 <button
74 onClick={() => window.history.back()}
75 className={styles.buttonContent}
76 >
77 <ArrowLeft className={styles.buttonIcon} />
78 Go Back
79 </button>
80 </Button>
81 </div>
82 </div>
83 </div>
84 );
85}
Now restart your app and navigate to our login
page. You will be treated to this nicer page. It can be better, but you get the point.
Wouldn't it be nice to show a loaded spinner when navigation pages are displayed? Yes, it would. Let's see how we can do that.
How To Create A Loading Page In Next.js
There are many ways to handle the loading state in Next.js; we will start with the simplest one.
This creates a file called loading.tsx
. You can read about other ways here.
Navigate to src/app
, create a file called loading.tsx
, and add the following code.
1const styles = {
2 overlay: "fixed inset-0 flex items-center justify-center bg-gray-200 bg-opacity-50",
3 spinner: "animate-spin h-12 w-12 border-t-4 border-pink-600 rounded-full"
4};
5
6export default function Loading() {
7 return (
8 <div className={styles.overlay}>
9 <div className={styles.spinner} />
10 </div>
11 );
12}
For now we are going to stick with this approach, but latter we can take a look how to show a skeleton while our component loads.
That is all we need to do. Now, let's restart our application and see the amazing loader in action. If you find my loader too boring, feel free to add your own design flair to your application.
Finally, let's take a look at how we can handle errors in our application.
How To Handle Errors In Next.js
Now, let's examine how to handle errors in Next.js to prevent our app from crashing completely.
Based on Next.js Docs, you can declare globarl errors, and route based. Read more here.
Right now, if I go to the src/data/loaders.ts
and add the following, I can throw an error inside the getHomePageData
function.
1throw new Error("Test error");
The complete function will look like the following.
1async function getGlobalData(): Promise<TStrapiResponse<TGlobal>> {
2 throw new Error("Test error");
3
4 const query = qs.stringify({
5 populate: [
6 "header.logoText",
7 "header.ctaButton",
8 "footer.logoText",
9 "footer.socialLink",
10 ],
11 });
12
13 const url = new URL("/api/global", baseUrl);
14 url.search = query;
15 return api.get<TGlobal>(url.href);
16}
Our app will break with an ugly error.
We can fix this by creating a global-error.ts
file to break our app gracefully. You can read more about Next.js global errors here.
Let's create a file called global-error.tsx
inside our app folder and paste it into the following code. I used v0 to help me make it prety.
1"use client";
2import Link from "next/link";
3import { usePathname } from "next/navigation";
4import { Home, RefreshCw, AlertTriangle } from "lucide-react";
5
6const styles = {
7 container:
8 "min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4",
9 content: "max-w-2xl mx-auto text-center space-y-8",
10 textSection: "space-y-4",
11 headingError: "text-8xl font-bold text-red-600 select-none",
12 headingContainer: "relative",
13 pageTitle: "text-4xl font-bold text-gray-900 mb-4",
14 description: "text-lg text-gray-600 max-w-md mx-auto leading-relaxed",
15 illustrationContainer: "flex justify-center py-8",
16 illustration: "relative animate-pulse",
17 errorCircle:
18 "w-32 h-32 bg-red-100 rounded-full flex items-center justify-center transition-all duration-300 hover:bg-red-200",
19 errorIcon: "w-16 h-16 text-red-500",
20 warningBadge:
21 "absolute -top-2 -right-2 w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center animate-bounce",
22 warningSymbol: "text-orange-500 text-xl font-bold",
23 buttonContainer:
24 "flex flex-col sm:flex-row gap-4 justify-center items-center",
25 button: "min-w-[160px] bg-red-600 hover:bg-red-700 text-white",
26 buttonContent: "flex items-center gap-2",
27 buttonIcon: "w-4 h-4",
28 outlineButton: "min-w-[160px] border-red-600 text-red-600 hover:bg-red-50",
29 errorDetails:
30 "mt-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left text-sm text-red-800",
31 errorTitle: "font-semibold mb-2",
32};
33
34interface IGlobalError {
35 error: Error & { digest?: string };
36 reset: () => void;
37}
38
39export default function GlobalError({ error, reset }: IGlobalError) {
40 const pathname = usePathname();
41 const isHomePage = pathname === "/";
42
43 return (
44 <html>
45 <body>
46 <div className={styles.container}>
47 <div className={styles.content}>
48 {/* Large Error Text */}
49 <div className={styles.textSection}>
50 <h1 className={styles.headingError}>Global Error</h1>
51 <div className={styles.headingContainer}>
52 <h2 className={styles.pageTitle}>Application Error</h2>
53 <p className={styles.description}>
54 A critical error occurred that prevented the application from
55 loading properly. Please try refreshing the page.
56 </p>
57 </div>
58 </div>
59
60 {/* Illustration */}
61 <div className={styles.illustrationContainer}>
62 <div className={styles.illustration}>
63 <div className={styles.errorCircle}>
64 <AlertTriangle className={styles.errorIcon} />
65 </div>
66 <div className={styles.warningBadge}>
67 <span className={styles.warningSymbol}>!</span>
68 </div>
69 </div>
70 </div>
71
72 {/* Action Buttons */}
73 <div className={styles.buttonContainer}>
74 <button
75 onClick={reset}
76 className={`${styles.button} px-6 py-3 rounded-lg font-medium transition-colors`}
77 >
78 <div className={styles.buttonContent}>
79 <RefreshCw className={styles.buttonIcon} />
80 Try Again
81 </div>
82 </button>
83
84 {!isHomePage && (
85 <Link
86 href="/"
87 className={`${styles.outlineButton} px-6 py-3 rounded-lg font-medium border-2 transition-colors inline-flex`}
88 >
89 <div className={styles.buttonContent}>
90 <Home className={styles.buttonIcon} />
91 Go Home
92 </div>
93 </Link>
94 )}
95 </div>
96
97 {process.env.NODE_ENV === "development" && (
98 <div className={styles.errorDetails}>
99 <div className={styles.errorTitle}>
100 Error Details (Development Only):
101 </div>
102 <div>Message: {error.message}</div>
103 {error.digest && <div>Digest: {error.digest}</div>}
104 {error.stack && (
105 <details className="mt-2">
106 <summary className="cursor-pointer font-medium">
107 Stack Trace
108 </summary>
109 <pre className="mt-2 text-xs overflow-auto">
110 {error.stack}
111 </pre>
112 </details>
113 )}
114 </div>
115 )}
116 </div>
117 </div>
118 </body>
119 </html>
120 );
121}
Now, when our app crashes, it does not look as scary.
note: Global error UI must define its own and
tags, since it is replacing the root layout or template when active.So if you would like to have a fallback UI, you would need to create it. I will add a fallback header using our FallbackHeader which we are about to create.
In the src/components/custom
folder create the following file fallback-header.tsx
with the following code:
1import Link from "next/link";
2import type { THeader } from "@/types";
3
4import { Logo } from "@/components/custom/logo";
5import { Button } from "@/components/ui/button";
6
7interface IFallbackHeaderProps {
8 header?: THeader | null;
9}
10
11const styles = {
12 header:
13 "flex items-center justify-between px-4 py-3 bg-white shadow-md dark:bg-gray-800",
14 actions: "flex items-center gap-4",
15};
16
17export function FallbackHeader({ header }: IFallbackHeaderProps) {
18 if (!header) return null;
19
20 const { logoText, ctaButton } = header;
21 return (
22 <div className={styles.header}>
23 <Logo text={logoText.label} />
24 <div className={styles.actions}>
25 <Link href={ctaButton.href}>
26 <Button>{ctaButton.label}</Button>
27 </Link>
28 </div>
29 </div>
30 );
31}
Now add it in your globar-error.tsx
component:
1"use client";
2import Link from "next/link";
3import { usePathname } from "next/navigation";
4import { Home, RefreshCw, AlertTriangle } from "lucide-react";
5import { FallbackHeader } from "@/components/custom/fallback-header";
6
7const styles = {
8 container:
9 "min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4",
10 content: "max-w-2xl mx-auto text-center space-y-8",
11 textSection: "space-y-4",
12 headingError: "text-8xl font-bold text-red-600 select-none",
13 headingContainer: "relative",
14 pageTitle: "text-4xl font-bold text-gray-900 mb-4",
15 description: "text-lg text-gray-600 max-w-md mx-auto leading-relaxed",
16 illustrationContainer: "flex justify-center py-8",
17 illustration: "relative animate-pulse",
18 errorCircle:
19 "w-32 h-32 bg-red-100 rounded-full flex items-center justify-center transition-all duration-300 hover:bg-red-200",
20 errorIcon: "w-16 h-16 text-red-500",
21 warningBadge:
22 "absolute -top-2 -right-2 w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center animate-bounce",
23 warningSymbol: "text-orange-500 text-xl font-bold",
24 buttonContainer:
25 "flex flex-col sm:flex-row gap-4 justify-center items-center",
26 button: "min-w-[160px] bg-red-600 hover:bg-red-700 text-white",
27 buttonContent: "flex items-center gap-2",
28 buttonIcon: "w-4 h-4",
29 outlineButton: "min-w-[160px] border-red-600 text-red-600 hover:bg-red-50",
30 errorDetails:
31 "mt-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left text-sm text-red-800",
32 errorTitle: "font-semibold mb-2",
33};
34
35interface IGlobalError {
36 error: Error & { digest?: string };
37 reset: () => void;
38}
39
40export default function GlobalError({ error, reset }: IGlobalError) {
41 const pathname = usePathname();
42 const isHomePage = pathname === "/";
43
44 return (
45 <html>
46 <body>
47 <FallbackHeader
48 header={{
49 logoText: {
50 id: 1,
51 href: "/",
52 label: "Summarize AI",
53 },
54 ctaButton: {
55 id: 1,
56 label: "Get Help",
57 href: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
58 isExternal: true,
59 }
60 }}
61 />
62 <div className={styles.container}>
63 <div className={styles.content}>
64 {/* Large Error Text */}
65 <div className={styles.textSection}>
66 <h1 className={styles.headingError}>Global Error</h1>
67 <div className={styles.headingContainer}>
68 <h2 className={styles.pageTitle}>Application Error</h2>
69 <p className={styles.description}>
70 A critical error occurred that prevented the application from
71 loading properly. Please try refreshing the page.
72 </p>
73 </div>
74 </div>
75
76 {/* Illustration */}
77 <div className={styles.illustrationContainer}>
78 <div className={styles.illustration}>
79 <div className={styles.errorCircle}>
80 <AlertTriangle className={styles.errorIcon} />
81 </div>
82 <div className={styles.warningBadge}>
83 <span className={styles.warningSymbol}>!</span>
84 </div>
85 </div>
86 </div>
87
88 {/* Action Buttons */}
89 <div className={styles.buttonContainer}>
90 <button
91 onClick={reset}
92 className={`${styles.button} px-6 py-3 rounded-lg font-medium transition-colors`}
93 >
94 <div className={styles.buttonContent}>
95 <RefreshCw className={styles.buttonIcon} />
96 Try Again
97 </div>
98 </button>
99
100 {!isHomePage && (
101 <Link
102 href="/"
103 className={`${styles.outlineButton} px-6 py-3 rounded-lg font-medium border-2 transition-colors inline-flex`}
104 >
105 <div className={styles.buttonContent}>
106 <Home className={styles.buttonIcon} />
107 Go Home
108 </div>
109 </Link>
110 )}
111 </div>
112
113 {process.env.NODE_ENV === "development" && (
114 <div className={styles.errorDetails}>
115 <div className={styles.errorTitle}>
116 Error Details (Development Only):
117 </div>
118 <div>Message: {error.message}</div>
119 {error.digest && <div>Digest: {error.digest}</div>}
120 {error.stack && (
121 <details className="mt-2">
122 <summary className="cursor-pointer font-medium">
123 Stack Trace
124 </summary>
125 <pre className="mt-2 text-xs overflow-auto">
126 {error.stack}
127 </pre>
128 </details>
129 )}
130 </div>
131 )}
132 </div>
133 </div>
134 </body>
135 </html>
136 );
137}
Now, that we know our error works, don't forget to remove throw new Error("Test error");
that we added for testing.
Excellent, we covered a lot in this post. Let's do a quick recap of what we covered.
Conclusion
In Part 3 of the Epic Next.js 15 Tutorial series, we focused on completing the home page design of a real-life project. The tutorial covered several key areas:
Refactoring the Hero Section: we refactored the Hero Section to use the Next.js Image component for optimized image handling. This included creating a custom StrapiImage component for additional quality-of-life improvements.
Building the Features Section: This Section involved modeling the Features Section data in Strapi, creating corresponding components in Next.js, and implementing functionality to display features dynamically from the Strapi CMS.
Displaying Dynamic Meta Data: We examined how to get our metadata from Strapi and display it on our layout.tsx
page.
Top Header and Footer: We created our Header and Footer, leveraging Strapi to manage and fetch global data like logo texts and social links.
We finished by covering how to handle loading, not found, and errors pages.
I can't wait to see the next post, where we cover how to create our Sign In and Sign Up pages. This will include form validation with Zod
, handling form submission with server actions
, creating and storing http only
cookies, and protecting our routes with Next.js middleware
.
I am so excited. Thanks for checking out this post. I look forward to seeing you in the next one.
Note about this project
This project has been updated to use Next.js 15 and Strapi 5.
If you have any questions, feel free to stop by at our Discord Community for our daily "open office hours" from 12:30 PM CST to 1:30 PM CST.
Feel free to make PRs to fix any issues you find in the project, or let me know if you have any questions.
Happy coding!
Paul