Based on a tutorial by Paul Bratslavsky
Are you struggling to build modern, client-friendly websites that balance developer experience with content management flexibility? Many developers find themselves caught between creating technically excellent sites and providing simple content management systems for their clients.
In this comprehensive guide, I’ve summarized Paul Bratslavsky’s extensive 6+ hour tutorial on building a full-stack application with Next.js 15 and Strapi 5. You’ll discover how this powerful combination creates an ideal tech stack for freelancers and agencies looking to deliver high-performance, content-rich websites that clients can easily manage themselves.
By the end of this summary, you’ll understand how to build a complete summer camp website with dynamic content, user forms, search functionality, pagination, and much more—all while leveraging the latest features of Next.js 15 like server components and server actions.
Quick Navigation
- Project Overview & Technology Stack (00:00-07:30)
- Setting Up the Development Environment (07:31-14:45)
- Creating Your First Content Type in Strapi (14:46-26:15)
- Setting Up SASS Styling in Next.js (26:16-33:20)
- Building Reusable Components in Strapi (33:21-50:30)
- Creating the Hero & Info Block Components (50:31-01:10:00)
- Implementing the Block Renderer (01:10:01-01:25:30)
- Building Dynamic Routes for Pages (01:25:31-01:45:15)
- Creating Global Header & Footer (01:45:16-02:05:45)
- Building the Blog Feature & Content Types (02:05:46-02:30:00)
- Implementing Newsletter Signup with Server Actions (02:30:01-02:55:30)
- Creating Blog Detail Pages with Dynamic Content (02:55:31-03:20:00)
- Implementing Search & Pagination (03:20:01-03:45:30)
- Building the Events Collection & Pages (03:45:31-04:15:00)
- Creating Event Registration Forms (04:15:01-04:45:30)
- Form Validation & Error Handling (04:45:31-05:15:00)
- Building Dynamic Event Detail Pages (05:15:01-05:40:30)
- Final Touches & Project Review (05:40:31-06:00:00)
Project Overview & Technology Stack (00:00-07:30)
The tutorial begins with an introduction to the project you’ll be building: a fully functional summer camp website that demonstrates the integration of Next.js 15 and Strapi 5. This modern tech stack combines the power of React server components with a flexible headless CMS to create websites that are both developer-friendly and client-manageable.
Key Points:
- Next.js 15 provides server components, improved performance, and an intuitive routing system
- Strapi 5 offers a flexible, customizable headless CMS where clients can manage content
- The decoupled architecture allows for future front-end changes without affecting content
- This stack is ideal for freelancers and agencies building small to medium business websites
- The tutorial includes a complete Figma design file that guides the development process
Paul walks through a detailed demo of the finished website, showing the various pages and features you’ll be implementing. The website includes:
- Homepage: With hero section, info blocks, and featured articles
- Experience page: Using similar components with different content
- Blog section: Featuring articles, search, pagination, and newsletter signup
- Blog detail pages: With table of contents and dynamic content blocks
- Events section: Displaying upcoming events with search and filters
- Event detail pages: With registration forms that store submissions
The tutorial demonstrates how all this content can be managed through the Strapi admin panel, allowing non-technical users to update text, images, and even create new pages using predefined components.
My Take:
This tech stack strikes an excellent balance between developer needs and client usability. The separation of concerns between front-end and back-end makes this approach particularly future-proof—if you ever need to rebuild the front-end with a different technology, your content remains accessible through Strapi’s API.
Setting Up the Development Environment (07:31-14:45)
In this section, Paul guides you through the process of setting up your development environment by installing both Next.js and Strapi. You’ll create a project structure that includes both the frontend and backend in a single repository.
Key Points:
- Create two separate folders for client (Next.js) and server (Strapi)
- Configure Next.js 15 with App Router and without Tailwind (using SASS instead)
- Set up Strapi 5 with SQLite database for simplicity
- Create a Git repository for version control of the entire project
- Set up a resources folder for code snippets and documentation
# Install Next.js
npx create-next-app@latest client
# Answer the setup questions:
# ✅ Would you like to use TypeScript? Yes
# ✅ Would you like to use ESLint? Yes
# ❌ Would you like to use Tailwind CSS? No
# ✅ Would you like to use the src/ directory? Yes
# ✅ Would you like to use App Router? Yes
# ❌ Would you like to customize the default import alias? No
# Install Strapi
npx create-strapi-app@latest server
# Answer the setup questions:
# Choose your installation type: Custom (manual settings)
# Choose your preferred database client: SQLite
# Database name: default
# Host: 127.0.0.1
# Port: 1337
# Username: admin
# Password: [your-secure-password]
# Enable SSL connection: No
After installing both frameworks, Paul demonstrates how to start each application. For Next.js, you’ll use yarn dev
in the client directory, and for Strapi, you’ll use yarn develop
in the server directory.
When starting Strapi for the first time, you’ll need to create an admin user. This account will be used to access the Strapi admin panel at http://localhost:1337/admin
.
My Take:
Using a monorepo approach (keeping both frontend and backend in one repository) makes deployment and version control much simpler. For real-world projects, you might consider using a more robust database like PostgreSQL instead of SQLite, especially if you’re planning to deploy to a platform like Heroku or Digital Ocean.
Creating Your First Content Type in Strapi (14:46-26:15)
This section introduces Strapi’s content management capabilities. You’ll learn about Strapi’s Content Type Builder and create your first content type to represent the homepage data. You’ll also establish the connection between your Next.js frontend and Strapi backend.
Key Points:
- Understand the difference between Single Types and Collection Types in Strapi
- Create a “Homepage” Single Type with title and description fields
- Configure permissions in Strapi’s users & permissions plugin
- Test API endpoints using Postman or the browser
- Create a data fetching utility in Next.js to consume Strapi’s API
- Implement basic error handling for API requests
Paul explains that Single Types in Strapi are used for unique content that appears only once on your website (like homepage, about page, or global settings), while Collection Types are used for repeatable content (like blog posts, events, or testimonials).
After creating the Homepage Single Type with title and description fields, you’ll populate it with some test content. Then, you’ll navigate to the settings and configure the permissions to make the API publicly accessible.
// In your Next.js application
// Create utils/fetchApi.ts for API requests
export async function fetchAPI(
path: string,
options = {}
) {
try {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const mergedOptions = {
...defaultOptions,
...options,
};
const res = await fetch(`${getStrapiURL()}${path}`, mergedOptions);
const data = await res.json();
return data;
} catch (error) {
console.error(error);
throw new Error(`Failed to fetch data from API: ${error.message}`);
}
}
// Create utils/getStrapiURL.ts
export function getStrapiURL() {
return process.env.NEXT_PUBLIC_STRAPI_API_URL || 'http://localhost:1337';
}
// Create data/loaders.ts for data fetching
export async function getHomepageData() {
try {
const homepage = await fetchAPI('/api/homepage');
return homepage;
} catch (error) {
console.error('Error fetching homepage data:', error);
return null;
}
}
With these utility functions in place, you can now fetch data from your Strapi backend in your Next.js pages:
// In app/page.tsx
import { getHomepageData } from '@/data/loaders';
import { notFound } from 'next/navigation';
export default async function HomeRoute() {
const data = await getHomepageData();
if (!data) {
notFound();
}
return (
{data.data.attributes.title}
{data.data.attributes.description}
); }
My Take:
The way Strapi structures its API responses with nested data.attributes can be a bit confusing at first. Creating utility functions to abstract away these details makes your code more readable and maintainable. For larger projects, you might want to add TypeScript interfaces to define the expected data structure from each API endpoint.
Setting Up SASS Styling in Next.js (26:16-33:20)
This section covers setting up SASS for styling your Next.js application. Paul explains how to organize your styles for maintainability and how to implement them in your project.
Key Points:
- Install SASS in your Next.js project using
yarn add sass
- Create a structured approach to organizing your styles
- Import your main stylesheet in the root layout
- Understand BEM (Block Element Modifier) methodology for CSS naming
- Set up component-specific SASS files
The tutorial uses a modular SASS approach, organizing styles into separate files for components, layouts, and utilities. Paul demonstrates how to create and organize the SASS folder structure:
src/
└── sass/
├── main.scss # Main entry point that imports all other files
├── base/
│ ├── _reset.scss # CSS reset
│ ├── _typography.scss # Typography styles
│ └── _variables.scss # Variables for colors, spacing, etc.
├── components/
│ ├── _hero.scss # Styles for hero section
│ ├── _info-block.scss # Styles for info blocks
│ └── _button.scss # Styles for buttons
├── layout/
│ ├── _header.scss # Styles for header
│ ├── _footer.scss # Styles for footer
│ └── _container.scss # Styles for container
└── utilities/
├── _mixins.scss # SASS mixins
└── _helpers.scss # Helper classes
To import the styles in your Next.js application, you’ll need to update the root layout file:
// In app/layout.tsx
import '@/src/sass/main.scss';
export default function RootLayout({ children }) {
return (
{children}
);
}
Paul explains the BEM methodology for naming CSS classes, which helps maintain a clear structure in your styles:
// Example of BEM methodology in SASS
.hero {
position: relative;
width: 100%;
min-height: 80vh;
&__title {
font-size: 3rem;
font-weight: bold;
margin-bottom: 1rem;
}
&__subtitle {
font-size: 1.5rem;
margin-bottom: 2rem;
}
&__cta {
display: inline-block;
padding: 1rem 2rem;
&--primary {
background-color: $primary-color;
color: white;
}
&--secondary {
background-color: transparent;
border: 1px solid $primary-color;
}
}
}
My Take:
While many developers gravitate toward utility-based CSS frameworks like Tailwind, using SASS with BEM provides better separation of concerns and can be easier for beginners to understand. This approach also makes it clearer to see how styles relate to your component structure. The nesting capabilities of SASS make BEM much more maintainable than it would be in plain CSS.
Building Reusable Components in Strapi (33:21-50:30)
This section dives into creating reusable components in Strapi that will serve as building blocks for your pages. Paul explains how to design these components with the right fields and relationships to support your frontend needs.
Key Points:
- Create reusable components for Logo, Link, Hero Section, and Info Block
- Organize components into categories (Elements, Blocks)
- Set up component relationships and nested components
- Use enums for theme options (colors)
- Implement Dynamic Zones in Single Types for flexible content
- Understand how to structure data for optimal frontend consumption
Paul starts by analyzing the landing page design from Figma and identifying repeating elements that can be turned into components. He creates several components in Strapi:
- Logo Component (Elements category): Contains logo text and image fields
- Link Component (Elements category): Contains text, href, and external flag fields
- Hero Section (Blocks category): Contains logo, image, heading, CTA (Link component), and theme fields
- Info Block (Blocks category): Contains reversed flag, image, headline, content, CTA, and theme fields
After creating these components, Paul shows how to add a Dynamic Zone to the Homepage Single Type. This allows content editors to add and arrange different blocks (Hero Section, Info Block) on the page in any order they choose.
He then populates these components with content, explaining the process of uploading images and setting up the relationships between components. This demonstrates how non-technical users can manage complex page layouts through Strapi’s intuitive interface.
My Take:
The way Paul designs these components shows excellent architectural thinking. By identifying common patterns in the design and turning them into reusable components, he’s creating a system that’s both flexible for content editors and maintainable for developers. The use of component relationships (like embedding the Link component within Hero and Info Block components) creates a powerful composition system.
Creating the Hero & Info Block Components (50:31-01:10:00)
Now that you’ve set up the content structure in Strapi, this section focuses on building the corresponding React components in Next.js to display your hero section and info blocks.
Key Points:
- Create interfaces/types for your component props
- Build a reusable StrappyImage component for handling media from Strapi
- Implement the HeroSection component with proper styling
- Create the InfoBlock component with conditional rendering for reversed layout
- Add ReactMarkdown for rendering rich text content
- Set up Next.js Image configuration for Strapi media
First, Paul creates TypeScript interfaces to define the structure of data coming from Strapi:
// In types.ts
export interface LinkProps {
id: number;
text: string;
href: string;
isExternal: boolean;
}
export interface ImageProps {
id: number;
url: string;
alternativeText?: string;
width: number;
height: number;
formats?: {
thumbnail?: { url: string };
small?: { url: string };
medium?: { url: string };
large?: { url: string };
};
}
export interface LogoProps {
id: number;
logoText: string;
image: ImageProps;
}
// Base component type with common fields
interface BaseBlockProps {
id: number;
__component: string;
}
// Hero Section component
export interface HeroSectionProps extends BaseBlockProps {
heading: string;
image: ImageProps;
logo: LogoProps;
cta?: LinkProps;
theme: 'turquoise' | 'orange';
}
// Info Block component
export interface InfoBlockProps extends BaseBlockProps {
reversed: boolean;
image: ImageProps;
headline: string;
content: string;
cta?: LinkProps;
theme: 'turquoise' | 'orange';
}
Next, Paul creates a utility function to handle Strapi image URLs:
// In components/StrappyImage.tsx
import Image from 'next/image';
import { ImageProps } from '@/types';
function getStrapiMedia(url: string) {
if (url.startsWith('http') || url.startsWith('//')) {
return url;
}
return `${process.env.NEXT_PUBLIC_STRAPI_API_URL || 'http://localhost:1337'}${url}`;
}
export default function StrappyImage({
image,
className,
priority = false,
sizes = '100vw',
height = 500,
width = 600,
}) {
if (!image || !image.url) {
return null;
}
const imageUrl = getStrapiMedia(image.url);
return (
);
}
Then, Paul implements the HeroSection component:
// In components/blocks/HeroSection.tsx
import StrappyImage from '../StrappyImage';
import { HeroSectionProps } from '@/types';
export default function HeroSection({
heading,
image,
logo,
cta,
theme,
}: HeroSectionProps) {
return (
); }
And the InfoBlock component with ReactMarkdown for rich text rendering:
// First, install ReactMarkdown
// yarn add react-markdown
// In components/blocks/InfoBlock.tsx
import ReactMarkdown from 'react-markdown';
import StrappyImage from '../StrappyImage';
import { InfoBlockProps } from '@/types';
export default function InfoBlock({
reversed,
image,
headline,
content,
cta,
theme,
}: InfoBlockProps) {
return (
); }
Finally, Paul configures Next.js to allow images from Strapi:
// In next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
],
},
};
module.exports = nextConfig;
My Take:
Paul’s approach to component design follows best practices by creating reusable, well-typed components. The StrappyImage component is particularly useful, as it abstracts away the complexity of handling Strapi media URLs and provides a consistent interface for all images in the application. The use of TypeScript interfaces ensures type safety and helps prevent errors when working with data from the API.
Implementing the Block Renderer (01:10:01-01:25:30)
This section introduces the concept of a Block Renderer—a powerful pattern for dynamically rendering different content blocks based on their type. This approach allows for flexible page layouts that can be managed entirely through the Strapi admin panel.
Key Points:
- Create a BlockRenderer component that dynamically renders different block types
- Set up a query utility for retrieving data with proper population of relationships
- Implement the qs library for building complex Strapi queries
- Update the homepage to use the BlockRenderer
- Understand Strapi’s populate parameter for retrieving relational data
First, Paul installs the qs library to help build complex queries for Strapi:
yarn add qs
yarn add @types/qs -D
Then, he creates a complex query to retrieve homepage data with all nested relationships:
// In data/loaders.ts
import qs from 'qs';
import { fetchAPI } from '@/utils/fetchApi';
const homepageQuery = qs.stringify({
populate: {
blocks: {
on: {
'blocks.hero-section': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
logo: {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
},
},
cta: {
populate: '*',
},
},
},
'blocks.info-block': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
cta: {
populate: '*',
},
},
},
},
},
},
});
export async function getHomepageData() {
try {
const url = `/api/homepage?${homepageQuery}`;
const data = await fetchAPI(url);
return data;
} catch (error) {
console.error('Error fetching homepage data:', error);
return null;
}
}
Next, Paul implements the BlockRenderer component that will dynamically render different block types:
// In components/BlockRenderer.tsx
import { ReactNode } from 'react';
import HeroSection from './blocks/HeroSection';
import InfoBlock from './blocks/InfoBlock';
interface BlockRendererProps {
blocks: any[];
}
export default function BlockRenderer({ blocks = [] }: BlockRendererProps): ReactNode {
return (
<>
{blocks.map((block) => {
switch (block.__component) {
case 'blocks.hero-section':
return ;
case 'blocks.info-block':
return ;
default:
console.warn(`Unknown block type: ${block.__component}`);
return null;
}
})}
</>
);
}
Finally, Paul updates the homepage to use the BlockRenderer:
// In app/page.tsx
import { getHomepageData } from '@/data/loaders';
import { notFound } from 'next/navigation';
import BlockRenderer from '@/components/BlockRenderer';
export default async function HomeRoute() {
const data = await getHomepageData();
if (!data) {
notFound();
}
const blocks = data.data.attributes.blocks || [];
return (
);
}
With this implementation, the homepage now dynamically renders the hero section and info blocks based on the content structure defined in Strapi. This approach makes it easy to add, remove, or reorder blocks in the Strapi admin panel without having to change any code.
My Take:
The BlockRenderer pattern is one of the most powerful concepts in this tutorial. It creates a flexible system where non-technical users can build complex page layouts using predefined components, similar to how modern page builders work. As your project grows, you can easily extend this pattern by adding new block types to both Strapi and your BlockRenderer component.
Implementing Newsletter Signup with Server Actions (02:30:01-02:55:30)
This section covers implementing a newsletter signup form using Next.js 15’s Server Actions. You’ll learn how to handle form submissions, validate input data, and store submissions in Strapi.
Key Points:
- Create a Newsletter Signup collection type in Strapi
- Set up API permissions for form submissions
- Implement Server Actions in Next.js 15 for form handling
- Add form validation using Zod
- Create proper error handling for form submissions
- Implement success and error messaging for users
First, Paul creates a Newsletter Signup collection type in Strapi with an email field. Then, he configures the permissions to allow public creation of newsletter signups in the users & permissions roles settings.
Next, he tests the API endpoint using Postman to ensure that it’s working correctly:
// POST request to http://localhost:1337/api/newsletter-signups
{
"data": {
"email": "test@example.com"
}
}
After confirming that the API works, Paul installs Zod for form validation:
yarn add zod
Then, he creates a server action to handle the form submission:
// In data/actions.ts
"use server";
import { z } from "zod";
import { subscribeService } from "./services";
// Initial state for the form
const initialState = {
zodErrors: null,
strapiErrors: null,
errorMessage: null,
successMessage: null,
};
// Form validation schema
const subscribeSchema = z.object({
email: z.string().email("Please enter a valid email address"),
});
export async function subscribeAction(prevState, formData) {
// Get the email from the form
const email = formData.get("email");
// Validate the email
const validatedFields = subscribeSchema.safeParse({ email });
// If validation fails, return errors
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
errorMessage: "Invalid input",
successMessage: null,
};
}
// Prepare data for Strapi
const dataToSend = {
data: {
email: validatedFields.data.email,
},
};
// Send data to Strapi
const responseData = await subscribeService(dataToSend);
// If no response, return server error
if (!responseData) {
return {
...prevState,
zodErrors: null,
strapiErrors: null,
errorMessage: "Oops, something went wrong. Please try again later.",
successMessage: null,
};
}
// If Strapi returns an error
if (responseData.error) {
return {
...prevState,
zodErrors: null,
strapiErrors: responseData.error,
errorMessage: "Failed to subscribe",
successMessage: null,
};
}
// Success case
return {
...prevState,
zodErrors: null,
strapiErrors: null,
errorMessage: null,
successMessage: "Successfully subscribed!",
};
}
He also creates a service to handle the API call:
// In data/services.ts
import { fetchAPI } from "@/utils/fetchApi";
interface SubscribeData {
data: {
email: string;
};
}
export async function subscribeService(data: SubscribeData) {
try {
const response = await fetchAPI("/api/newsletter-signups", {
method: "POST",
body: JSON.stringify(data),
});
return response;
} catch (error) {
console.error("Error in subscribe service:", error);
return null;
}
}
Next, Paul updates the Subscribe component to use the server action:
// In components/blocks/Subscribe.tsx
"use client";
import { useActionState } from "react-dom";
import { subscribeAction } from "@/data/actions";
// Initial state
const initialState = {
zodErrors: null,
strapiErrors: null,
errorMessage: null,
successMessage: null,
};
export default function Subscribe({
headline,
content,
placeholderText,
buttonText,
}) {
// Use the action state to get form state and action
const [formState, formAction] = useActionState(subscribeAction, initialState);
// Get errors from form state
const zodErrors = formState.zodErrors;
const strapiErrors = formState.strapiErrors;
const errorMessage = formState.errorMessage ||
zodErrors?.email?.[0] ||
strapiErrors?.message;
const successMessage = formState.successMessage;
return (
{headline}
{content}
{buttonText} {successMessage && (
{successMessage}
)}
); }
Finally, Paul creates a SubmitButton component that shows loading state during form submission:
// In components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
);
}
My Take:
Server Actions in Next.js 15 are a game-changer for handling forms. They provide a clean way to implement form submissions without having to create separate API routes. Combining Server Actions with Zod for validation creates a robust system for handling user input. The approach of using useActionState to manage form state provides a great developer experience while maintaining the benefits of server-side validation.
Creating Blog Detail Pages with Dynamic Content (02:55:31-03:20:00)
This section focuses on building dynamic blog detail pages with rich content blocks. You’ll learn how to create flexible content structures in Strapi and render them in your Next.js application.
Key Points:
- Create an Article collection type with dynamic content blocks
- Build components for different content types (headings, paragraphs, images)
- Set up dynamic routes for article pages
- Generate a table of contents from headings
- Display related articles at the bottom of the page
- Add proper navigation and metadata
First, Paul creates several components in Strapi for the article content:
Content Block Components:
- Heading: With heading text and link ID fields
- Paragraph: With rich text content field
- Full Image: With image field
- Paragraph With Image: With content, image, reversed, and image layout fields
Then, he adds a Dynamic Zone to the Article collection type to allow for flexible content structure. This enables content editors to build articles with different blocks in any order they choose.
Next, Paul creates the corresponding React components for each content block:
// In components/blocks/Heading.tsx
export default function Heading({ heading, linkId }) {
return (
{heading}
); }
// In components/blocks/Paragraph.tsx
import ReactMarkdown from 'react-markdown';
export default function Paragraph({ content }) {
return (
); }
// In components/blocks/FullImage.tsx
import StrappyImage from '../StrappyImage';
export default function FullImage({ image }) {
return (
); }
// In components/blocks/ParagraphWithImage.tsx
import ReactMarkdown from 'react-markdown';
import StrappyImage from '../StrappyImage';
export default function ParagraphWithImage({
content,
image,
reversed,
imageLandscape,
}) {
return (
); }
Then, Paul updates the BlockRenderer to include these new components:
// In components/BlockRenderer.tsx
// Update the switch statement to include new components
switch (block.__component) {
case 'blocks.hero-section':
return ;
case 'blocks.info-block':
return ;
case 'blocks.featured-article':
return ;
case 'blocks.subscribe':
return ;
case 'blocks.heading':
return ;
case 'blocks.paragraph':
return ;
case 'blocks.full-image':
return ;
case 'blocks.paragraph-with-image':
return ;
default:
console.warn(`Unknown block type: ${block.__component}`);
return null;
}
Next, Paul creates a function to fetch article data by slug:
// In data/loaders.ts
export async function getContentBySlug(type, slug) {
const query = qs.stringify({
filters: {
slug: {
$eq: slug,
},
},
populate: {
image: {
fields: ['url', 'alternativeText'],
},
blocks: {
on: {
'blocks.heading': {
populate: '*',
},
'blocks.paragraph': {
populate: '*',
},
'blocks.full-image': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
},
},
'blocks.paragraph-with-image': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
},
},
'blocks.hero-section': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
logo: {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
},
},
cta: {
populate: '*',
},
},
},
},
},
},
});
try {
const url = `/api/${type}?${query}`;
const data = await fetchAPI(url);
if (data.data.length === 0) {
return null;
}
return {
article: data.data[0].attributes,
blocks: data.data[0].attributes.blocks,
};
} catch (error) {
console.error(`Error fetching content with slug ${slug}:`, error);
return null;
}
}
Then, Paul creates a dynamic route for blog posts:
// In app/blog/[slug]/page.tsx
import { getContentBySlug } from '@/data/loaders';
import { notFound } from 'next/navigation';
import HeroSection from '@/components/blocks/HeroSection';
import ArticleOverview from '@/components/ArticleOverview';
import BlockRenderer from '@/components/BlockRenderer';
import ContentList from '@/components/ContentList';
import Card from '@/components/Card';
interface PageProps {
params: {
slug: string;
};
}
export default async function SingleBlogRoute({ params }: PageProps) {
const { slug } = params;
const data = await getContentBySlug('articles', slug);
if (!data) {
notFound();
}
const { article, blocks } = data;
// Generate table of contents from heading blocks
const tableOfContents = blocks
.filter(block => block.__component === 'blocks.heading')
.map(block => ({
heading: block.heading,
linkId: block.linkId
}));
// Component for displaying blog cards
const BlogCard = (props) => (
);
return (
);
}
Finally, Paul creates an ArticleOverview component to display the article introduction and table of contents:
// In components/ArticleOverview.tsx
import Link from 'next/link';
interface TableOfContentItem {
heading: string;
linkId: string;
}
interface ArticleOverviewProps {
description: string;
tableOfContents?: TableOfContentItem[];
}
export default function ArticleOverview({
description,
tableOfContents = [],
}: ArticleOverviewProps) {
return (
{description}
{tableOfContents.length > 0 && (
Table of Contents
-
- {tableOfContents.map((item, index) => (
- {item.heading}
))}
)}
); }
My Take:
The approach to creating dynamic blog content is incredibly powerful. By using a combination of different content blocks that can be arranged in any order, content editors have complete flexibility in how they structure their articles. The table of contents generation from heading blocks is a nice touch that improves user experience. This pattern could be extended to support more complex content types like code blocks, quotes, or embedded media.
Implementing Search & Pagination (03:20:01-03:45:30)
This section covers adding search functionality and pagination to your content lists. These features enhance user experience by making it easier to find and navigate through articles or events.
Key Points:
- Create a Search component with debounced input
- Implement pagination for content lists
- Use URL parameters to store search and pagination state
- Update Strapi queries to handle filtering and pagination
- Create reusable components that can be applied to different content types
- Make search results and pagination state shareable via URLs
First, Paul installs the use-debounce package to optimize search input handling:
yarn add use-debounce
Then, he creates a Search component that updates URL parameters:
// In components/Search.tsx
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useDebounceCallback } from "use-debounce";
export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
// Handle search with debounce to avoid too many updates
const handleSearch = useDebounceCallback((term) => {
const params = new URLSearchParams(searchParams);
// Reset to page 1 when search changes
params.set("page", "1");
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
// Update URL with search parameters
replace(`${pathname}?${params.toString()}`);
}, 300);
return (
); }
Next, Paul creates a Pagination component:
// In components/Pagination.tsx
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
interface PaginationProps {
pageCount: number;
}
export default function Pagination({ pageCount }: PaginationProps) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
// Get current page from URL or default to 1
const currentPage = Number(searchParams.get("page") || "1");
// Create URL for a specific page
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
); }
Then, Paul updates the getContent function to handle search and pagination:
// In data/loaders.ts
const BASE_PAGE_SIZE = 3;
export async function getContent(
path: string,
featured?: boolean,
query?: string,
page?: string
) {
// Set up filters
const filters: any = {};
// Add featured filter if provided
if (featured) {
filters.featured = {
$eq: true,
};
}
// Add search query filter if provided
if (query) {
filters.$or = [
{
title: {
$containsi: query,
},
},
{
description: {
$containsi: query,
},
},
];
}
// Create query with filters, pagination, and population
const contentQuery = qs.stringify({
filters,
pagination: {
pageSize: BASE_PAGE_SIZE,
page: page ? parseInt(page) : 1,
},
populate: {
image: {
fields: ['url', 'alternativeText'],
},
},
sort: ['createdAt:desc'],
});
try {
const url = `/api/${path}?${contentQuery}`;
const data = await fetchAPI(url);
return {
data: data.data,
meta: data.meta,
};
} catch (error) {
console.error(`Error fetching content from ${path}:`, error);
return {
data: [],
meta: {
pagination: {
pageCount: 0,
},
},
};
}
}
Finally, Paul updates the ContentList component to use search and pagination:
// In components/ContentList.tsx
import { getContent } from '@/data/loaders';
import Search from './Search';
import Pagination from './Pagination';
interface ContentListProps {
headline: string;
path: string;
component: React.ComponentType;
featured?: boolean;
showSearch?: boolean;
showPagination?: boolean;
query?: string;
page?: string;
headlineAlignment?: 'left' | 'center' | 'right';
}
export default async function ContentList({
headline,
path,
component: Component,
featured = false,
showSearch = false,
showPagination = false,
query = "",
page = "1",
headlineAlignment = 'left',
}: ContentListProps) {
// Fetch content with filters and pagination
const { data: articles, meta } = await getContent(path, featured, query, page);
const pageCount = meta.pagination.pageCount || 1;
return (
{headline}
{showSearch && (
)}
{articles.length > 0 ? ( articles.map((article) => ( )) ) : (
No results found
)}
{showPagination && pageCount > 1 && (
)}
); }
The blog page needs to be updated to pass search parameters and page to the ContentList component:
// In app/blog/page.tsx
interface BlogPageProps {
searchParams: {
query?: string;
page?: string;
};
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const { query, page } = searchParams;
// Create blog card component
const BlogCard = (props) => (
);
return (
); }
My Take:
The approach of storing search and pagination state in URL parameters has several advantages. It makes the current state shareable, bookmarkable, and preservable across page refreshes. The use of debounced search prevents unnecessary API calls while typing, improving performance. This pattern could be extended to include more advanced filtering options like categories, tags, or date ranges.
Building the Events Collection & Pages (03:45:31-04:15:00)
This section covers creating an Events feature for your website. You’ll learn how to model event data in Strapi and create pages to display events with filtering and sorting.
Key Points:
- Create an Event collection type in Strapi
- Set up fields for event details (title, description, date, price)
- Reuse the content list pattern for displaying events
- Create an events page with search and filtering
- Implement dynamic routes for individual event pages
- Add proper formatting for dates and prices
First, Paul creates an Event collection type in Strapi with the following fields:
- Title: Text field for the event name
- Description: Long text field for event details
- Slug: UID field connected to title
- Image: Media field for event image
- Featured: Boolean field to mark featured events
- Price: Text field for event price
- Start Date: Date & time field
- Blocks: Dynamic Zone with Heading and Paragraph components
Next, Paul creates several event entries in Strapi. He also creates a specific “Stay in touch” event that will be displayed on the main events page as a general contact form.
Then, he creates an Events page in Next.js:
// In app/events/page.tsx
import { getContentBySlug, getContent } from '@/data/loaders';
import { notFound } from 'next/navigation';
import BlockRenderer from '@/components/BlockRenderer';
import ContentList from '@/components/ContentList';
import EventSignupForm from '@/components/EventSignupForm';
import Card from '@/components/Card';
interface EventsPageProps {
searchParams: {
query?: string;
page?: string;
};
}
export default async function EventsPage({ searchParams }: EventsPageProps) {
const { query, page } = searchParams;
// Get the "Stay in touch" event for the main form
const stayInTouchData = await getContentBySlug('events', 'stay-in-touch');
if (!stayInTouchData) {
notFound();
}
const { article: event, blocks } = stayInTouchData;
// Create event card component
const EventCard = (props) => (
);
return (
); }
He also creates a Card component specifically styled for events:
// This extends the Card component with event-specific styling
import { formatDate } from '@/utils/formatDate';
export default function EventCard({
title,
description,
slug,
image,
price,
startDate,
basePath,
}) {
return (
{title}
{description}
View Details
); }
For the date formatting, Paul creates a utility function:
// In utils/formatDate.ts
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(date);
}
The EventSignupForm component (without the form submission logic yet):
// In components/EventSignupForm.tsx
import BlockRenderer from './BlockRenderer';
export default function EventSignupForm({ event, blocks }) {
return (
{event.title}
{event.description}
Sign up for updates
{/* Form will be added in the next section */}
Form will go here
); }
My Take:
The approach of reusing the ContentList pattern for events demonstrates the power of creating generic, reusable components. By simply swapping out the component prop, you can display different types of content with the same pagination and search functionality. This pattern makes your codebase more maintainable and reduces duplication. The separation of the “Stay in touch” event as a special case is a clever way to handle the general contact form while still using the same data structure.
Creating Event Registration Forms (04:15:01-04:45:30)
This section focuses on building registration forms for events. You’ll learn how to create a form submission system that associates sign-ups with specific events.
Key Points:
- Create an EventSignup collection type in Strapi
- Set up a relation between EventSignup and Event
- Implement a form interface for collecting user information
- Create reusable form components (TextInput, SubmitButton)
- Set up the server action for form submission
- Add proper error handling and success messaging
First, Paul creates an EventSignup collection type in Strapi with the following fields:
- First Name: Text field
- Last Name: Text field
- Email: Email field
- Telephone: Text field
- Event: Relation to Event (many-to-one)
Then, he sets up permissions in the users & permissions plugin to allow public creation of event signups.
Next, Paul creates reusable form components:
// In components/TextInput.tsx
interface TextInputProps {
label: string;
name: string;
type?: 'text' | 'email' | 'tel';
placeholder?: string;
required?: boolean;
errors?: string[];
defaultValue?: string;
}
export default function TextInput({
label,
name,
type = 'text',
placeholder,
required = true,
errors,
defaultValue = '',
}: TextInputProps) {
return (
{errors?.length > 0 && (
{errors[0]}
)}
); }
Then, he updates the EventSignupForm component to include the form:
// In components/EventSignupForm.tsx
"use client";
import { useActionState } from "react-dom";
import BlockRenderer from './BlockRenderer';
import TextInput from './TextInput';
import SubmitButton from './SubmitButton';
import { eventSubscribeAction } from '@/data/actions';
// Initial state for the form
const initialState = {
zodErrors: null,
strapiErrors: null,
errorMessage: null,
successMessage: null,
formData: {
firstName: '',
lastName: '',
email: '',
telephone: '',
},
};
export default function EventSignupForm({ event, blocks }) {
const [formState, formAction] = useActionState(eventSubscribeAction, initialState);
// Get errors and success message from form state
const zodErrors = formState.zodErrors;
const strapiErrors = formState.strapiErrors;
const errorMessage = formState.errorMessage;
const successMessage = formState.successMessage;
return (
{event.title}
{event.description}
{event.image && (
)} {blocks && } {event.price && (
)} {event.startDate && (
)}
Sign up for this event
Sign Up {errorMessage && (
{errorMessage}
)} {successMessage && (
{successMessage}
)}
); }
My Take:
Creating reusable form components like TextInput makes it much easier to maintain consistency across your application. The pattern of passing error messages as props allows for flexible error display while keeping the core input component simple. The approach of storing form data in the form state ensures that users don’t lose their input if validation fails, which significantly improves the user experience.
Form Validation & Error Handling (04:45:31-05:15:00)
This section covers implementing form validation and error handling for event registration forms. You’ll learn how to validate user input on the server and provide meaningful feedback to users.
Key Points:
- Implement server-side validation using Zod
- Create validation schemas for form fields
- Add phone number validation with regex
- Handle Strapi-specific errors
- Display appropriate error messages to users
- Implement success messages for completed submissions
First, Paul creates the server action for event registration:
// In data/actions.ts
"use server";
import { z } from "zod";
import { eventSubscribeService } from "./services";
// Initial state for the form
const initialState = {
zodErrors: null,
strapiErrors: null,
errorMessage: null,
successMessage: null,
formData: {
firstName: '',
lastName: '',
email: '',
telephone: '',
},
};
// Form validation schema
const eventSubscribeSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Please enter a valid email address"),
telephone: z.string()
.min(1, "Phone number is required")
.refine(
(val) => /^(+d{1,3})?s?(?d{3})?[s.-]?d{3}[s.-]?d{4}$/.test(val),
"Please enter a valid phone number"
),
eventId: z.string(),
});
export async function eventSubscribeAction(prevState, formData) {
// Extract form data
const formValues = {
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
email: formData.get("email"),
telephone: formData.get("telephone"),
eventId: formData.get("eventId"),
};
// Validate form data
const validatedFields = eventSubscribeSchema.safeParse(formValues);
// If validation fails, return errors
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
strapiErrors: null,
errorMessage: "Please correct the errors in the form",
successMessage: null,
formData: formValues,
};
}
// Prepare data for Strapi
const dataToSend = {
data: {
firstName: validatedFields.data.firstName,
lastName: validatedFields.data.lastName,
email: validatedFields.data.email,
telephone: validatedFields.data.telephone,
event: validatedFields.data.eventId,
},
};
// Send data to Strapi
const responseData = await eventSubscribeService(dataToSend);
// If no response, return server error
if (!responseData) {
return {
...prevState,
zodErrors: null,
strapiErrors: null,
errorMessage: "Oops, something went wrong. Please try again later.",
successMessage: null,
formData: formValues,
};
}
// If Strapi returns an error
if (responseData.error) {
return {
...prevState,
zodErrors: null,
strapiErrors: responseData.error,
errorMessage: "Failed to subscribe",
successMessage: null,
formData: formValues,
};
}
// Success case
return {
...prevState,
zodErrors: null,
strapiErrors: null,
errorMessage: null,
successMessage: "Successfully subscribed!",
formData: {
firstName: '',
lastName: '',
email: '',
telephone: '',
},
};
}
Then, he creates the service to handle the API call:
// In data/services.ts
// Add a new service for event signup
interface EventSubscribeData {
data: {
firstName: string;
lastName: string;
email: string;
telephone: string;
event: string; // ID of the event
};
}
export async function eventSubscribeService(data: EventSubscribeData) {
try {
const response = await fetchAPI("/api/event-signups", {
method: "POST",
body: JSON.stringify(data),
});
return response;
} catch (error) {
console.error("Error in event subscribe service:", error);
return null;
}
}
My Take:
Server-side validation with Zod provides a robust way to ensure data integrity before it reaches your database. The approach of returning detailed error information to the client allows for clear user feedback without exposing sensitive information. The telephone validation using regex is particularly useful, as phone number formats can vary widely. In a production application, you might want to add additional validation for security, such as CSRF protection or rate limiting.
Building Dynamic Event Detail Pages (05:15:01-05:40:30)
This section covers creating dynamic event detail pages that display information about specific events and allow users to register for them.
Key Points:
- Set up dynamic routing for event detail pages
- Create a page component for displaying event details
- Reuse the EventSignupForm component
- Display related events at the bottom of the page
- Add proper navigation and metadata
- Handle 404 errors for non-existent events
First, Paul creates a dynamic route for event detail pages:
// In app/events/[slug]/page.tsx
import { getContentBySlug } from '@/data/loaders';
import { notFound } from 'next/navigation';
import EventSignupForm from '@/components/EventSignupForm';
import ContentList from '@/components/ContentList';
import Card from '@/components/Card';
interface EventPageProps {
params: {
slug: string;
};
}
export default async function EventPage({ params }: EventPageProps) {
const { slug } = params;
const data = await getContentBySlug('events', slug);
if (!data) {
notFound();
}
const { article: event, blocks } = data;
// Create event card component
const EventCard = (props) => (
);
return (
);
}
This page reuses the same EventSignupForm component that we created for the main events page, but with different event data based on the slug parameter.
To handle navigation between events, Paul adds links to the EventCard component:
// In components/Card.tsx
// Updated to add navigation links for events
import Link from 'next/link';
import StrappyImage from './StrappyImage';
import { formatDate } from '@/utils/formatDate';
export default function Card({
title,
description,
slug,
image,
price,
startDate,
basePath,
}) {
return (
{title}
{description}
{basePath === ‘blog’ ? ‘Read more’ : ‘View Details’}
); }
My Take:
The reuse of components across different pages demonstrates the power of component-based architecture. By creating flexible, reusable components like EventSignupForm and ContentList, you can build complex pages with minimal code duplication. The approach of displaying related events on the detail page is a nice touch that encourages users to explore more content and potentially sign up for multiple events.
Final Touches & Project Review (05:40:31-06:00:00)
In the final section of the tutorial, Paul reviews the project, discusses potential improvements, and provides guidance on deploying your Next.js and Strapi application.
Key Points:
- Review the complete project structure and components
- Discuss potential improvements and extensions
- Add metadata and SEO optimization
- Consider performance optimizations
- Explore deployment options for Next.js and Strapi
- Reflect on the strengths of this tech stack for freelancers
Paul adds metadata to improve SEO:
// In app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | Surf Camp',
default: 'Surf Camp - Summer Adventures',
},
description: 'Join our summer surf camp for an unforgettable adventure!',
keywords: ['surf camp', 'summer camp', 'adventures', 'surfing lessons'],
};
He also adds specific metadata for dynamic pages:
// In app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const { slug } = params;
const data = await getContentBySlug('articles', slug);
if (!data) {
return {
title: 'Article Not Found',
description: 'The requested article could not be found.',
};
}
const { article } = data;
return {
title: article.title,
description: article.description,
openGraph: {
title: article.title,
description: article.description,
images: [
{
url: getStrapiMedia(article.image.url),
width: article.image.width,
height: article.image.height,
alt: article.image.alternativeText || article.title,
},
],
},
};
}
Paul discusses deployment options for both Next.js and Strapi:
- Next.js Deployment: Vercel, Netlify, or AWS Amplify
- Strapi Deployment: Digital Ocean, Render, Heroku, or Strapi Cloud
- Database Options: PostgreSQL for production (instead of SQLite)
- Media Storage: Cloudinary, AWS S3, or other object storage services
Finally, Paul reflects on potential improvements and extensions to the project:
- Adding authentication for protected content
- Implementing email notifications for form submissions
- Adding social sharing capabilities
- Creating a sitemap and robots.txt for better SEO
- Implementing analytics integration
- Adding internationalization (i18n) support
- Integrating payment processing for paid events
My Take:
The combination of Next.js 15 and Strapi 5 creates a powerful, flexible stack for building modern websites. The server components in Next.js 15 provide excellent performance while maintaining a great developer experience. Strapi’s headless CMS capabilities make it easy for clients to manage their content without developer intervention. This stack is particularly well-suited for freelancers and agencies who need to deliver high-quality websites with content management capabilities on tight timelines.
This article summarizes the excellent tutorial created by Paul Bratslavsky. If you found this summary helpful, please support the creator by watching the full video and subscribing to their channel.
Based on a tutorial by Paul Bratslavsky
Are you struggling to build modern, client-friendly websites that balance developer experience with content management flexibility? Many developers find themselves caught between creating technically excellent sites and providing simple content management systems for their clients.
In this comprehensive guide, I’ve summarized Paul Bratslavsky’s extensive 6+ hour tutorial on building a full-stack application with Next.js 15 and Strapi 5. You’ll discover how this powerful combination creates an ideal tech stack for freelancers and agencies looking to deliver high-performance, content-rich websites that clients can easily manage themselves.
By the end of this summary, you’ll understand how to build a complete summer camp website with dynamic content, user forms, search functionality, pagination, and much more—all while leveraging the latest features of Next.js 15 like server components and server actions.
Quick Navigation
- Project Overview & Technology Stack (00:00-07:30)
- Setting Up the Development Environment (07:31-14:45)
- Creating Your First Content Type in Strapi (14:46-26:15)
- Setting Up SASS Styling in Next.js (26:16-33:20)
- Building Reusable Components in Strapi (33:21-50:30)
- Creating the Hero & Info Block Components (50:31-01:10:00)
- Implementing the Block Renderer (01:10:01-01:25:30)
- Building Dynamic Routes for Pages (01:25:31-01:45:15)
- Creating Global Header & Footer (01:45:16-02:05:45)
- Building the Blog Feature & Content Types (02:05:46-02:30:00)
- Implementing Newsletter Signup with Server Actions (02:30:01-02:55:30)
- Creating Blog Detail Pages with Dynamic Content (02:55:31-03:20:00)
- Implementing Search & Pagination (03:20:01-03:45:30)
- Building the Events Collection & Pages (03:45:31-04:15:00)
- Creating Event Registration Forms (04:15:01-04:45:30)
- Form Validation & Error Handling (04:45:31-05:15:00)
- Building Dynamic Event Detail Pages (05:15:01-05:40:30)
- Final Touches & Project Review (05:40:31-06:00:00)
Project Overview & Technology Stack (00:00-07:30)
The tutorial begins with an introduction to the project you’ll be building: a fully functional summer camp website that demonstrates the integration of Next.js 15 and Strapi 5. This modern tech stack combines the power of React server components with a flexible headless CMS to create websites that are both developer-friendly and client-manageable.
Key Points:
- Next.js 15 provides server components, improved performance, and an intuitive routing system
- Strapi 5 offers a flexible, customizable headless CMS where clients can manage content
- The decoupled architecture allows for future front-end changes without affecting content
- This stack is ideal for freelancers and agencies building small to medium business websites
- The tutorial includes a complete Figma design file that guides the development process
Paul walks through a detailed demo of the finished website, showing the various pages and features you’ll be implementing. The website includes:
- Homepage: With hero section, info blocks, and featured articles
- Experience page: Using similar components with different content
- Blog section: Featuring articles, search, pagination, and newsletter signup
- Blog detail pages: With table of contents and dynamic content blocks
- Events section: Displaying upcoming events with search and filters
- Event detail pages: With registration forms that store submissions
The tutorial demonstrates how all this content can be managed through the Strapi admin panel, allowing non-technical users to update text, images, and even create new pages using predefined components.
My Take:
This tech stack strikes an excellent balance between developer needs and client usability. The separation of concerns between front-end and back-end makes this approach particularly future-proof—if you ever need to rebuild the front-end with a different technology, your content remains accessible through Strapi’s API.
Setting Up the Development Environment (07:31-14:45)
In this section, Paul guides you through the process of setting up your development environment by installing both Next.js and Strapi. You’ll create a project structure that includes both the frontend and backend in a single repository.
Key Points:
- Create two separate folders for client (Next.js) and server (Strapi)
- Configure Next.js 15 with App Router and without Tailwind (using SASS instead)
- Set up Strapi 5 with SQLite database for simplicity
- Create a Git repository for version control of the entire project
- Set up a resources folder for code snippets and documentation
# Install Next.js
npx create-next-app@latest client
# Answer the setup questions:
# ✅ Would you like to use TypeScript? Yes
# ✅ Would you like to use ESLint? Yes
# ❌ Would you like to use Tailwind CSS? No
# ✅ Would you like to use the src/ directory? Yes
# ✅ Would you like to use App Router? Yes
# ❌ Would you like to customize the default import alias? No
# Install Strapi
npx create-strapi-app@latest server
# Answer the setup questions:
# Choose your installation type: Custom (manual settings)
# Choose your preferred database client: SQLite
# Database name: default
# Host: 127.0.0.1
# Port: 1337
# Username: admin
# Password: [your-secure-password]
# Enable SSL connection: No
After installing both frameworks, Paul demonstrates how to start each application. For Next.js, you’ll use yarn dev
in the client directory, and for Strapi, you’ll use yarn develop
in the server directory.
When starting Strapi for the first time, you’ll need to create an admin user. This account will be used to access the Strapi admin panel at http://localhost:1337/admin
.
My Take:
Using a monorepo approach (keeping both frontend and backend in one repository) makes deployment and version control much simpler. For real-world projects, you might consider using a more robust database like PostgreSQL instead of SQLite, especially if you’re planning to deploy to a platform like Heroku or Digital Ocean.
Creating Your First Content Type in Strapi (14:46-26:15)
This section introduces Strapi’s content management capabilities. You’ll learn about Strapi’s Content Type Builder and create your first content type to represent the homepage data. You’ll also establish the connection between your Next.js frontend and Strapi backend.
Key Points:
- Understand the difference between Single Types and Collection Types in Strapi
- Create a “Homepage” Single Type with title and description fields
- Configure permissions in Strapi’s users & permissions plugin
- Test API endpoints using Postman or the browser
- Create a data fetching utility in Next.js to consume Strapi’s API
- Implement basic error handling for API requests
Paul explains that Single Types in Strapi are used for unique content that appears only once on your website (like homepage, about page, or global settings), while Collection Types are used for repeatable content (like blog posts, events, or testimonials).
After creating the Homepage Single Type with title and description fields, you’ll populate it with some test content. Then, you’ll navigate to the settings and configure the permissions to make the API publicly accessible.
// In your Next.js application
// Create utils/fetchApi.ts for API requests
export async function fetchAPI(
path: string,
options = {}
) {
try {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const mergedOptions = {
...defaultOptions,
...options,
};
const res = await fetch(`${getStrapiURL()}${path}`, mergedOptions);
const data = await res.json();
return data;
} catch (error) {
console.error(error);
throw new Error(`Failed to fetch data from API: ${error.message}`);
}
}
// Create utils/getStrapiURL.ts
export function getStrapiURL() {
return process.env.NEXT_PUBLIC_STRAPI_API_URL || 'http://localhost:1337';
}
// Create data/loaders.ts for data fetching
export async function getHomepageData() {
try {
const homepage = await fetchAPI('/api/homepage');
return homepage;
} catch (error) {
console.error('Error fetching homepage data:', error);
return null;
}
}
With these utility functions in place, you can now fetch data from your Strapi backend in your Next.js pages:
// In app/page.tsx
import { getHomepageData } from '@/data/loaders';
import { notFound } from 'next/navigation';
export default async function HomeRoute() {
const data = await getHomepageData();
if (!data) {
notFound();
}
return (
{data.data.attributes.title}
{data.data.attributes.description}
); }
My Take:
The way Strapi structures its API responses with nested data.attributes can be a bit confusing at first. Creating utility functions to abstract away these details makes your code more readable and maintainable. For larger projects, you might want to add TypeScript interfaces to define the expected data structure from each API endpoint.
Setting Up SASS Styling in Next.js (26:16-33:20)
This section covers setting up SASS for styling your Next.js application. Paul explains how to organize your styles for maintainability and how to implement them in your project.
Key Points:
- Install SASS in your Next.js project using
yarn add sass
- Create a structured approach to organizing your styles
- Import your main stylesheet in the root layout
- Understand BEM (Block Element Modifier) methodology for CSS naming
- Set up component-specific SASS files
The tutorial uses a modular SASS approach, organizing styles into separate files for components, layouts, and utilities. Paul demonstrates how to create and organize the SASS folder structure:
src/
└── sass/
├── main.scss # Main entry point that imports all other files
├── base/
│ ├── _reset.scss # CSS reset
│ ├── _typography.scss # Typography styles
│ └── _variables.scss # Variables for colors, spacing, etc.
├── components/
│ ├── _hero.scss # Styles for hero section
│ ├── _info-block.scss # Styles for info blocks
│ └── _button.scss # Styles for buttons
├── layout/
│ ├── _header.scss # Styles for header
│ ├── _footer.scss # Styles for footer
│ └── _container.scss # Styles for container
└── utilities/
├── _mixins.scss # SASS mixins
└── _helpers.scss # Helper classes
To import the styles in your Next.js application, you’ll need to update the root layout file:
// In app/layout.tsx
import '@/src/sass/main.scss';
export default function RootLayout({ children }) {
return (
{children}
);
}
Paul explains the BEM methodology for naming CSS classes, which helps maintain a clear structure in your styles:
// Example of BEM methodology in SASS
.hero {
position: relative;
width: 100%;
min-height: 80vh;
&__title {
font-size: 3rem;
font-weight: bold;
margin-bottom: 1rem;
}
&__subtitle {
font-size: 1.5rem;
margin-bottom: 2rem;
}
&__cta {
display: inline-block;
padding: 1rem 2rem;
&--primary {
background-color: $primary-color;
color: white;
}
&--secondary {
background-color: transparent;
border: 1px solid $primary-color;
}
}
}
My Take:
While many developers gravitate toward utility-based CSS frameworks like Tailwind, using SASS with BEM provides better separation of concerns and can be easier for beginners to understand. This approach also makes it clearer to see how styles relate to your component structure. The nesting capabilities of SASS make BEM much more maintainable than it would be in plain CSS.
Building Reusable Components in Strapi (33:21-50:30)
This section dives into creating reusable components in Strapi that will serve as building blocks for your pages. Paul explains how to design these components with the right fields and relationships to support your frontend needs.
Key Points:
- Create reusable components for Logo, Link, Hero Section, and Info Block
- Organize components into categories (Elements, Blocks)
- Set up component relationships and nested components
- Use enums for theme options (colors)
- Implement Dynamic Zones in Single Types for flexible content
- Understand how to structure data for optimal frontend consumption
Paul starts by analyzing the landing page design from Figma and identifying repeating elements that can be turned into components. He creates several components in Strapi:
- Logo Component (Elements category): Contains logo text and image fields
- Link Component (Elements category): Contains text, href, and external flag fields
- Hero Section (Blocks category): Contains logo, image, heading, CTA (Link component), and theme fields
- Info Block (Blocks category): Contains reversed flag, image, headline, content, CTA, and theme fields
After creating these components, Paul shows how to add a Dynamic Zone to the Homepage Single Type. This allows content editors to add and arrange different blocks (Hero Section, Info Block) on the page in any order they choose.
He then populates these components with content, explaining the process of uploading images and setting up the relationships between components. This demonstrates how non-technical users can manage complex page layouts through Strapi’s intuitive interface.
My Take:
The way Paul designs these components shows excellent architectural thinking. By identifying common patterns in the design and turning them into reusable components, he’s creating a system that’s both flexible for content editors and maintainable for developers. The use of component relationships (like embedding the Link component within Hero and Info Block components) creates a powerful composition system.
Creating the Hero & Info Block Components (50:31-01:10:00)
Now that you’ve set up the content structure in Strapi, this section focuses on building the corresponding React components in Next.js to display your hero section and info blocks.
Key Points:
- Create interfaces/types for your component props
- Build a reusable StrappyImage component for handling media from Strapi
- Implement the HeroSection component with proper styling
- Create the InfoBlock component with conditional rendering for reversed layout
- Add ReactMarkdown for rendering rich text content
- Set up Next.js Image configuration for Strapi media
First, Paul creates TypeScript interfaces to define the structure of data coming from Strapi:
// In types.ts
export interface LinkProps {
id: number;
text: string;
href: string;
isExternal: boolean;
}
export interface ImageProps {
id: number;
url: string;
alternativeText?: string;
width: number;
height: number;
formats?: {
thumbnail?: { url: string };
small?: { url: string };
medium?: { url: string };
large?: { url: string };
};
}
export interface LogoProps {
id: number;
logoText: string;
image: ImageProps;
}
// Base component type with common fields
interface BaseBlockProps {
id: number;
__component: string;
}
// Hero Section component
export interface HeroSectionProps extends BaseBlockProps {
heading: string;
image: ImageProps;
logo: LogoProps;
cta?: LinkProps;
theme: 'turquoise' | 'orange';
}
// Info Block component
export interface InfoBlockProps extends BaseBlockProps {
reversed: boolean;
image: ImageProps;
headline: string;
content: string;
cta?: LinkProps;
theme: 'turquoise' | 'orange';
}
Next, Paul creates a utility function to handle Strapi image URLs:
// In components/StrappyImage.tsx
import Image from 'next/image';
import { ImageProps } from '@/types';
function getStrapiMedia(url: string) {
if (url.startsWith('http') || url.startsWith('//')) {
return url;
}
return `${process.env.NEXT_PUBLIC_STRAPI_API_URL || 'http://localhost:1337'}${url}`;
}
export default function StrappyImage({
image,
className,
priority = false,
sizes = '100vw',
height = 500,
width = 600,
}) {
if (!image || !image.url) {
return null;
}
const imageUrl = getStrapiMedia(image.url);
return (
);
}
Then, Paul implements the HeroSection component:
// In components/blocks/HeroSection.tsx
import StrappyImage from '../StrappyImage';
import { HeroSectionProps } from '@/types';
export default function HeroSection({
heading,
image,
logo,
cta,
theme,
}: HeroSectionProps) {
return (
); }
And the InfoBlock component with ReactMarkdown for rich text rendering:
// First, install ReactMarkdown
// yarn add react-markdown
// In components/blocks/InfoBlock.tsx
import ReactMarkdown from 'react-markdown';
import StrappyImage from '../StrappyImage';
import { InfoBlockProps } from '@/types';
export default function InfoBlock({
reversed,
image,
headline,
content,
cta,
theme,
}: InfoBlockProps) {
return (
); }
Finally, Paul configures Next.js to allow images from Strapi:
// In next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
],
},
};
module.exports = nextConfig;
My Take:
Paul’s approach to component design follows best practices by creating reusable, well-typed components. The StrappyImage component is particularly useful, as it abstracts away the complexity of handling Strapi media URLs and provides a consistent interface for all images in the application. The use of TypeScript interfaces ensures type safety and helps prevent errors when working with data from the API.
Implementing the Block Renderer (01:10:01-01:25:30)
This section introduces the concept of a Block Renderer—a powerful pattern for dynamically rendering different content blocks based on their type. This approach allows for flexible page layouts that can be managed entirely through the Strapi admin panel.
Key Points:
- Create a BlockRenderer component that dynamically renders different block types
- Set up a query utility for retrieving data with proper population of relationships
- Implement the qs library for building complex Strapi queries
- Update the homepage to use the BlockRenderer
- Understand Strapi’s populate parameter for retrieving relational data
First, Paul installs the qs library to help build complex queries for Strapi:
yarn add qs
yarn add @types/qs -D
Then, he creates a complex query to retrieve homepage data with all nested relationships:
// In data/loaders.ts
import qs from 'qs';
import { fetchAPI } from '@/utils/fetchApi';
const homepageQuery = qs.stringify({
populate: {
blocks: {
on: {
'blocks.hero-section': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
logo: {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
},
},
cta: {
populate: '*',
},
},
},
'blocks.info-block': {
populate: {
image: {
fields: ['url', 'alternativeText'],
},
cta: {
populate: '*',
},
},
},
},
},
},
});
export async function getHomepageData() {
try {
const url = `/api/homepage?${homepageQuery}`;
const data = await fetchAPI(url);
return data;
} catch (error) {
console.error('Error fetching homepage data:', error);
return null;
}
}
Next, Paul implements the BlockRenderer component that will dynamically render different block types:
// In components/BlockRenderer.tsx
import { ReactNode } from 'react';
import HeroSection from './blocks/HeroSection';
import InfoBlock from './blocks/InfoBlock';
interface BlockRendererProps {
blocks: any[];
}
export default function BlockRenderer({ blocks = [] }: BlockRendererProps): ReactNode {
return (
<>
{blocks.map((block) => {
switch (block.__component) {
case 'blocks.hero-section':
return ;
case 'blocks.info-block':
return ;
default:
console.warn(`Unknown block type: ${block.__component}`);
return null;
}
})}
</>
);
}
Finally, Paul updates the homepage to use the BlockRenderer:
// In app/page.tsx
import { getHomepageData } from '@/data/loaders';
import { notFound } from 'next/navigation';
import BlockRenderer from '@/components/BlockRenderer';
export default async function HomeRoute() {
const data = await getHomepageData();
if (!data) {
notFound();
}
const blocks = data.data.attributes.blocks || [];
return (
);
}
With this implementation, the homepage now dynamically renders the hero section and info blocks based on the content structure defined in Strapi. This approach makes it easy to add, remove, or reorder blocks in the Strapi admin panel without having to change any code.
My Take:
The BlockRenderer pattern is one of the most powerful concepts in this tutorial. It creates a flexible system where non-technical users can build complex page layouts using predefined components, similar to how modern page builders work. As your project grows, you can easily extend this pattern by adding new block types to both Strapi and your BlockRenderer component.