Next.js course Β· Module 9: Integrations and Advanced Features

Next.js and Strapi - A Powerful Frontend and Headless CMS Combination

8 min read
In this lesson9

In Quantum Metropolis, where frontend and backend technologies merge into a harmonious whole, you will discover one of the most powerful combinations in modern web development - Next.js with Strapi. This duo allows you to build scalable, performant applications with full content control.

Czym jest Strapi?

Strapi is an open-source headless CMS (Content Management System) that provides a RESTful or GraphQL API for content management. Unlike traditional CMSs like WordPress, Strapi does not impose any frontend structure - you decide how to display the data.

Kluczowe cechy Strapi:

  • 100% JavaScript - napisany w Node.js
  • Customizable - full control over data structure
  • Self-hosted or Cloud - choose your hosting method
  • RESTful & GraphQL API - flexible data access
  • Admin Panel - intuitive management interface
  • Roles-Based Access Control - advanced permission management

Dlaczego Next.js + Strapi?

Combining Next.js with Strapi creates a complete stack for building modern web applications:

  1. Separation of concerns - frontend and backend are independent
  2. Performance - static generation with dynamic content
  3. Developer Experience - both tools are developer-friendly
  4. Scalability - easy scaling of each layer separately
  5. TypeScript support - full typing support

Instalacja i konfiguracja Strapi

Krok 1: Tworzenie nowego projektu Strapi

1# Create a new Strapi project
2npx create-strapi-app@latest my-strapi-backend --quickstart
3
4# Lub z TypeScript
5npx create-strapi-app@latest my-strapi-backend --typescript

After installation, Strapi will automatically run on http://localhost:1337. Your first visit will redirect you to the admin account creation panel.

Krok 2: Konfiguracja Content Types

In the Strapi admin panel, you can create data structures (Content Types) using the intuitive Content-Type Builder:

  1. Go to Content-Type Builder
  2. Click Create new collection type
  3. Name your collection (e.g., "Article")
  4. Dodaj pola:
    • title (Text)
    • content (Rich text)
    • slug (UID)
    • featuredImage (Media)
    • author (Relation to Users)
    • publishedAt (Datetime)

Krok 3: Konfiguracja API permissions

By default, Strapi blocks public access to the API. To enable data reading:

  1. Go to Settings β†’ Roless β†’ Public
  2. Dla swojego Content Type zaznacz odpowiednie permissions (np. find i findOne)
  3. Zapisz zmiany

Integracja Next.js ze Strapi

Krok 1: Przygotowanie projektu Next.js

1# In a new terminal, create a Next.js project
2npx create-next-app@latest my-nextjs-frontend --typescript --tailwind --app
3
4cd my-nextjs-frontend

Step 2: Environment variables configuration

Create the file .env.local:

1NEXT_PUBLIC_STRAPI_API_URL=http://localhost:1337
2STRAPI_API_TOKEN=your-api-token-here

Krok 3: Tworzenie klienta API

Create helper functions for communicating with Strapi:

1// lib/strapi.ts
2interface StrapiResponse<T> {
3  data: T;
4  meta: {
5    pagination?: {
6      page: number;
7      pageSize: number;
8      pageCount: number;
9      total: number;
10    };
11  };
12}
13
14const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_API_URL || 'http://localhost:1337';
15
16export async function fetchAPI<T>(
17  path: string,
18  urlParamsObject: Record<string, any> = {},
19  options: RequestInit = {}
20): Promise<T> {
21  // Merge default and user options
22  const mergedOptions: RequestInit = {
23    headers: {
24      "Content-Type": "application/json",
25      ...(process.env.STRAPI_API_TOKEN && {
26        Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
27      }),
28    },
29    ...options,
30  };
31
32  // Build request URL
33  const queryString = new URLSearchParams(urlParamsObject).toString();
34  const requestUrl = `${strapiUrl}${path}${queryString ? `?${queryString}` : ""}`;
35
36  // Trigger API call
37  const response = await fetch(requestUrl, mergedOptions);
38  const data = await response.json();
39
40  if (!response.ok) {
41    throw new Error(`API call failed: ${response.status}`);
42  }
43
44  return data;
45}
46
47// Helper to get media URL
48export function getStrapiMedia(url: string | null) {
49  if (url == null) return null;
50  if (url.startsWith("http") || url.startsWith("//")) return url;
51  return `${strapiUrl}${url}`;
52}

Pobieranie danych ze Strapi w Next.js

Static Site Generation (SSG)

Wykorzystaj generateStaticParams i funkcje asynchroniczne w komponentach:

1// app/articles/page.tsx
2import { fetchAPI } from '@/lib/strapi';
3
4interface Article {
5  id: number;
6  attributes: {
7    title: string;
8    slug: string;
9    content: string;
10    publishedAt: string;
11    featuredImage: {
12      data: {
13        attributes: {
14          url: string;
15          alternativeText: string;
16        };
17      };
18    };
19  };
20}
21
22async function getArticles() {
23  const articlesRes = await fetchAPI<StrapiResponse<Article[]>>('/api/articles', {
24    populate: {
25      featuredImage: {
26        fields: ['url', 'alternativeText']
27      },
28      author: {
29        fields: ['name', 'email']
30      }
31    },
32    sort: ['publishedAt:desc'],
33    pagination: {
34      pageSize: 10,
35    },
36  });
37
38  return articlesRes.data;
39}
40
41export default async function ArticlesPage() {
42  const articles = await getArticles();
43
44  return (
45    <div className="container mx-auto px-4 py-8">
46      <h1 className="text-4xl font-bold mb-8">Articles</h1>
47      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
48        {articles.map((article) => (
49          <article key={article.id} className="border rounded-lg overflow-hidden shadow-lg">
50            {article.attributes.featuredImage?.data && (
51              <img
52                src={getStrapiMedia(article.attributes.featuredImage.data.attributes.url)}
53                alt={article.attributes.featuredImage.data.attributes.alternativeText}
54                className="w-full h-48 object-cover"
55              />
56            )}
57            <div className="p-4">
58              <h2 className="text-xl font-semibold mb-2">
59                {article.attributes.title}
60              </h2>
61              <p className="text-gray-600">
62                {new Date(article.attributes.publishedAt).toLocaleDateString('pl-PL')}
63              </p>
64              <a
65                href={`/articles/${article.attributes.slug}`}
66                className="text-blue-500 hover:underline mt-2 inline-block"
67              >
68                Read more β†’
69              </a>
70            </div>
71          </article>
72        ))}
73      </div>
74    </div>
75  );
76}

Dynamic Routes

For individual articles, use dynamic routes:

1// app/articles/[slug]/page.tsx
2import { fetchAPI } from '@/lib/strapi';
3import { notFound } from 'next/navigation';
4import ReactMarkdown from 'react-markdown';
5
6interface ArticlePageProps {
7  params: Promise<{
8    slug: string;
9  }>;
10}
11
12async function getArticleBySlug(slug: string) {
13  const articlesRes = await fetchAPI<StrapiResponse<Article[]>>('/api/articles', {
14    filters: {
15      slug: {
16        $eq: slug,
17      },
18    },
19    populate: {
20      featuredImage: {
21        fields: ['url', 'alternativeText']
22      },
23      author: {
24        populate: {
25          avatar: {
26            fields: ['url']
27          }
28        }
29      }
30    },
31  });
32
33  if (!articlesRes.data || articlesRes.data.length === 0) {
34    return null;
35  }
36
37  return articlesRes.data[0];
38}
39
40export async function generateStaticParams() {
41  const articlesRes = await fetchAPI<StrapiResponse<Article[]>>('/api/articles', {
42    fields: ['slug'],
43    pagination: {
44      pageSize: 100,
45    },
46  });
47
48  return articlesRes.data.map((article) => ({
49    slug: article.attributes.slug,
50  }));
51}
52
53export default async function ArticlePage({ params }: ArticlePageProps) {
54  const article = await getArticleBySlug((await params).slug);
55
56  if (!article) {
57    notFound();
58  }
59
60  return (
61    <article className="container mx-auto px-4 py-8 max-w-4xl">
62      {article.attributes.featuredImage?.data && (
63        <img
64          src={getStrapiMedia(article.attributes.featuredImage.data.attributes.url)}
65          alt={article.attributes.featuredImage.data.attributes.alternativeText}
66          className="w-full h-96 object-cover rounded-lg mb-8"
67        />
68      )}
69
70      <h1 className="text-4xl font-bold mb-4">{article.attributes.title}</h1>
71
72      <div className="flex items-center gap-4 mb-8 text-gray-600">
73        <span>Autor: {article.attributes.author?.data?.attributes.name}</span>
74        <span>β€’</span>
75        <time>
76          {new Date(article.attributes.publishedAt).toLocaleDateString('pl-PL')}
77        </time>
78      </div>
79
80      <div className="prose prose-lg max-w-none">
81        <ReactMarkdown>{article.attributes.content}</ReactMarkdown>
82      </div>
83    </article>
84  );
85}

Advanced features

Real-time updates z webhooks

Strapi can send webhooks when content changes. Configure an endpoint in Next.js:

1// app/api/revalidate/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { revalidatePath, revalidateTag } from 'next/cache';
4
5export async function POST(request: NextRequest) {
6  const body = await request.json();
7  const secret = request.headers.get('x-strapi-signature');
8
9  // Weryfikuj webhook signature
10  if (secret !== process.env.STRAPI_WEBHOOK_SECRET) {
11    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12  }
13
14  try {
15    // Revalidate appropriate paths based on the event
16    switch (body.event) {
17      case 'entry.create':
18      case 'entry.update':
19        if (body.model === 'article') {
20          revalidatePath('/articles');
21          revalidatePath(`/articles/${body.entry.slug}`);
22        }
23        break;
24      case 'entry.delete':
25        if (body.model === 'article') {
26          revalidatePath('/articles');
27        }
28        break;
29    }
30
31    return NextResponse.json({ revalidated: true });
32  } catch (error) {
33    return NextResponse.json({ error: 'Error revalidating' }, { status: 500 });
34  }
35}

GraphQL integration

Strapi also offers a GraphQL API. Install the plugin:

1# W folderze Strapi
2npm install @strapi/plugin-graphql

Then use GraphQL in Next.js:

1// lib/graphql.ts
2import { GraphQLClient } from 'graphql-request';
3
4const client = new GraphQLClient(`${process.env.NEXT_PUBLIC_STRAPI_API_URL}/graphql`, {
5  headers: {
6    authorization: process.env.STRAPI_API_TOKEN
7      ? `Bearer ${process.env.STRAPI_API_TOKEN}`
8      : '',
9  },
10});
11
12export async function queryStrapi<T>(query: string, variables?: any): Promise<T> {
13  return client.request<T>(query, variables);
14}

Image optimization

Integruj Strapi z Next.js Image Optimization:

1// components/StrapiImage.tsx
2import Image from 'next/image';
3import { getStrapiMedia } from '@/lib/strapi';
4
5interface StrapiImageProps {
6  src: string;
7  alt: string;
8  width: number;
9  height: number;
10  className?: string;
11}
12
13export default function StrapiImage({ src, alt, ...props }: StrapiImageProps) {
14  const imageUrl = getStrapiMedia(src);
15
16  if (!imageUrl) {
17    return null;
18  }
19
20  return (
21    <Image
22      src={imageUrl}
23      alt={alt}
24      {...props}
25      loader={({ src, width, quality }) => {
26        const params = new URLSearchParams();
27        if (width) params.set('width', width.toString());
28        if (quality) params.set('quality', quality.toString());
29        return `${src}?${params.toString()}`;
30      }}
31    />
32  );
33}

Preview mode

Implement preview for unpublished content:

1// app/api/preview/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { draftMode } from 'next/headers';
4
5export async function GET(request: NextRequest) {
6  const secret = request.nextUrl.searchParams.get('secret');
7  const slug = request.nextUrl.searchParams.get('slug');
8
9  if (secret !== process.env.STRAPI_PREVIEW_SECRET || !slug) {
10    return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
11  }
12
13  // Enable Draft Mode
14  (await draftMode()).enable();
15
16  // Redirect to the preview page
17  return NextResponse.redirect(new URL(`/articles/${slug}`, request.url));
18}
19
20// app/api/exit-preview/route.ts
21export async function GET() {
22  (await draftMode()).disable();
23  return NextResponse.redirect('/');
24}

Deployment i najlepsze praktyki

1. Environment Variables

Always use environment variables for sensitive data:

1# .env.production
2NEXT_PUBLIC_STRAPI_API_URL=https://your-strapi-instance.com
3STRAPI_API_TOKEN=your-production-token
4STRAPI_WEBHOOK_SECRET=your-webhook-secret
5STRAPI_PREVIEW_SECRET=your-preview-secret

2. Caching Strategy

Wykorzystaj Next.js caching:

1// Cache na 60 sekund
2export const revalidate = 60;
3
4// Or use cache tags
5fetch(url, { next: { tags: ['articles'] } });

3. Error Handling

Implement robust error handling:

1export async function getArticles() {
2  try {
3    const data = await fetchAPI<StrapiResponse<Article[]>>('/api/articles');
4    return data;
5  } catch (error) {
6    console.error('Error fetching articles:', error);
7    return { data: [], meta: {} };
8  }
9}

4. TypeScript Types

Generuj typy ze Strapi schema:

1# Install the type generator
2npm install -D @strapi/types

Summary

Combining Next.js with Strapi creates a powerful stack for building modern web applications. Strapi provides a flexible API and intuitive content management system, while Next.js delivers a performant frontend with SSG/SSR and optimizations.

Ta kombinacja jest idealna dla:

  • Blogs and news portals
  • E-commerce
  • Portfolio i stron korporacyjnych
  • Aplikacji SaaS
  • Platform edukacyjnych

In the following modules, we will explore more advanced topics such as multi-tenancy, internationalization, and custom plugins in Strapi.

Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3interface Article {
4  id: number;
5  title: string;
6  slug: string;
7  content: string;
8  author: string;
9  publishedAt: string;
10  category: string;
11}
12
13const mockArticles: Article[] = [
14  { id: 1, title: 'Introduction to Headless CMS', slug: 'intro-headless-cms', content: 'Headless CMS separates the backend from the frontend, giving full control over content presentation...', author: 'Admin', publishedAt: '2025-01-15', category: 'CMS' },
15  { id: 2, title: 'Strapi and Next.js - Getting Started', slug: 'strapi-nextjs-start', content: 'Integrating Strapi with Next.js allows building performant applications with dynamic content...', author: 'Developer', publishedAt: '2025-02-10', category: 'Tutorial' },
16  { id: 3, title: 'REST vs GraphQL in Strapi', slug: 'rest-vs-graphql', content: 'Strapi offers both REST API and GraphQL. Each approach has its advantages...', author: 'Admin', publishedAt: '2025-03-05', category: 'API' },
17  { id: 4, title: 'Typing Strapi Responses in TypeScript', slug: 'strapi-typescript', content: 'With TypeScript we can type Strapi API responses for better DX...', author: 'Developer', publishedAt: '2025-03-20', category: 'TypeScript' },
18];
19
20const StrapiDemo = () => {
21  const [articles] = useState<Article[]>(mockArticles);
22  const [selected, setSelected] = useState<Article | null>(null);
23  const [endpoint, setEndpoint] = useState('/api/articles');
24  const [response, setResponse] = useState<string>('');
25
26  const simulateFetch = () => {
27    const isDetail = endpoint.includes('/api/articles/');
28    const slug = endpoint.split('/api/articles/')[1];
29
30    if (isDetail && slug) {
31      const found = articles.find(a => a.slug === slug);
32      if (found) {
33        setResponse(JSON.stringify({ data: { id: found.id, attributes: { title: found.title, content: found.content, slug: found.slug, author: found.author, publishedAt: found.publishedAt } }, meta: {} }, null, 2));
34        setSelected(found);
35      } else {
36        setResponse(JSON.stringify({ error: { status: 404, message: 'Not found' } }, null, 2));
37      }
38    } else {
39      setResponse(JSON.stringify({ data: articles.map(a => ({ id: a.id, attributes: { title: a.title, slug: a.slug, category: a.category, publishedAt: a.publishedAt } })), meta: { pagination: { page: 1, pageSize: 25, total: articles.length } } }, null, 2));
40      setSelected(null);
41    }
42  };
43
44  return (
45    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
46      <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Next.js + Strapi CMS</h1>
47      <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>Headless CMS API Simulation</p>
48
49      <div style={{ maxWidth: '800px', margin: '0 auto' }}>
50        <div style={{ background: 'rgba(255,255,255,0.05)', padding: '16px', borderRadius: '10px', marginBottom: '16px', border: '1px solid #333' }}>
51          <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>API Explorer</h3>
52          <div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
53            <input value={endpoint} onChange={e => setEndpoint(e.target.value)}
54              style={{ flex: 1, padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff', fontFamily: 'monospace' }} />
55            <button onClick={simulateFetch} style={{ padding: '10px 20px', background: '#7c4dff', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer' }}>
56              GET
57            </button>
58          </div>
59          <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
60            <button onClick={() => setEndpoint('/api/articles')} style={{ padding: '4px 10px', background: 'rgba(100,255,218,0.1)', border: '1px solid #64ffda', borderRadius: '4px', color: '#64ffda', cursor: 'pointer', fontSize: '12px' }}>/api/articles</button>
61            {articles.map(a => (
62              <button key={a.id} onClick={() => setEndpoint('/api/articles/' + a.slug)} style={{ padding: '4px 10px', background: 'rgba(124,77,255,0.1)', border: '1px solid #7c4dff', borderRadius: '4px', color: '#7c4dff', cursor: 'pointer', fontSize: '12px' }}>
63                /articles/{a.slug}
64              </button>
65            ))}
66          </div>
67        </div>
68
69        {response && (
70          <div style={{ background: '#000', padding: '16px', borderRadius: '10px', marginBottom: '16px' }}>
71            <h4 style={{ color: '#4caf50', marginBottom: '8px' }}>Response:</h4>
72            <pre style={{ color: '#0f0', fontSize: '12px', overflow: 'auto', maxHeight: '250px', whiteSpace: 'pre-wrap' }}>{response}</pre>
73          </div>
74        )}
75
76        {selected && (
77          <div style={{ background: 'rgba(255,255,255,0.05)', padding: '20px', borderRadius: '10px', border: '1px solid #333' }}>
78            <span style={{ color: '#7c4dff', fontSize: '12px' }}>{selected.category}</span>
79            <h2 style={{ color: '#64ffda', margin: '8px 0' }}>{selected.title}</h2>
80            <p style={{ color: '#aaa', fontSize: '13px' }}>By {selected.author} | {selected.publishedAt}</p>
81            <p style={{ color: '#ccc', marginTop: '12px', lineHeight: '1.6' }}>{selected.content}</p>
82          </div>
83        )}
84      </div>
85    </div>
86  );
87};
88
89export default StrapiDemo;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What data does Strapi return by default through its REST API?

  2. 2. Which of the following features BEST describes a Headless CMS?

Hands-on tasks in the game

  • Click in order

    Arrange the steps of integrating Next.js with Strapi in order.

  • Code editor

    Create a component that fetches data from headless CMS Strapi in Next.js

  • Code editor

    Create an Article content type with title, content, and slug fields.

Useful articles