Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje

Next.js i Strapi - Potężne połączenie frontend i headless CMS

8 min czytania
W tej lekcji9

W Metropolii Quantum, gdzie technologie frontend i backend łączą się w harmonijną całość, poznasz jedno z najpotężniejszych połączeń współczesnego web developmentu - Next.js ze Strapi. To duet, który pozwala budować skalowalne, wydajne aplikacje z pełną kontrolą nad treścią.

Czym jest Strapi?

Strapi to open-source'owy headless CMS (Content Management System), który dostarcza RESTful lub GraphQL API do zarządzania treścią. W przeciwieństwie do tradycyjnych CMS-ów jak WordPress, Strapi nie narzuca żadnej struktury frontend - to ty decydujesz, jak wyświetlać dane.

Kluczowe cechy Strapi:

  • 100% JavaScript - napisany w Node.js
  • Customizable - pełna kontrola nad strukturą danych
  • Self-hosted lub Cloud - wybierz sposób hostingu
  • RESTful & GraphQL API - elastyczny dostęp do danych
  • Admin Panel - intuicyjny interfejs zarządzania
  • Role-Based Access Control - zaawansowane zarządzanie uprawnieniami

Dlaczego Next.js + Strapi?

Połączenie Next.js ze Strapi tworzy kompletny stack do budowania nowoczesnych aplikacji webowych:

  1. Separacja concerns - frontend i backend są niezależne
  2. Performance - statyczne generowanie z dynamiczną treścią
  3. Developer Experience - oba narzędzia są przyjazne dla programistów
  4. Scalability - łatwe skalowanie każdej warstwy osobno
  5. TypeScript support - pełne wsparcie dla typowania

Instalacja i konfiguracja Strapi

Krok 1: Tworzenie nowego projektu Strapi

1# Utwórz nowy projekt Strapi
2npx create-strapi-app@latest my-strapi-backend --quickstart
3
4# Lub z TypeScript
5npx create-strapi-app@latest my-strapi-backend --typescript

Po instalacji Strapi automatycznie uruchomi się na http://localhost:1337. Pierwsza wizyta przekieruje cię do panelu tworzenia konta administratora.

Krok 2: Konfiguracja Content Types

W panelu admina Strapi możesz utworzyć struktury danych (Content Types) używając intuicyjnego Content-Type Builder:

  1. Przejdź do Content-Type Builder
  2. Kliknij Create new collection type
  3. Nazwij swoją kolekcję (np. "Article")
  4. Dodaj pola:
    • title (Text)
    • content (Rich text)
    • slug (UID)
    • featuredImage (Media)
    • author (Relation to Users)
    • publishedAt (Datetime)

Krok 3: Konfiguracja API permissions

Domyślnie Strapi blokuje publiczny dostęp do API. Aby umożliwić odczyt danych:

  1. Przejdź do Settings → Roles → Public
  2. Dla swojego Content Type zaznacz odpowiednie uprawnienia (np. find i findOne)
  3. Zapisz zmiany

Integracja Next.js ze Strapi

Krok 1: Przygotowanie projektu Next.js

1# W nowym terminalu, utwórz projekt Next.js
2npx create-next-app@latest my-nextjs-frontend --typescript --tailwind --app
3
4cd my-nextjs-frontend

Krok 2: Konfiguracja zmiennych środowiskowych

Utwórz plik .env.local:

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

Krok 3: Tworzenie klienta API

Stwórz pomocnicze funkcje do komunikacji ze 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">Artykuły</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                Czytaj więcej →
69              </a>
70            </div>
71          </article>
72        ))}
73      </div>
74    </div>
75  );
76}

Dynamic Routes

Dla pojedynczych artykułów użyj 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}

Zaawansowane funkcjonalności

Real-time updates z webhooks

Strapi może wysyłać webhooks przy zmianach w contencie. Skonfiguruj endpoint w 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    // Rewaliduj odpowiednie ścieżki na podstawie eventu
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 oferuje również GraphQL API. Zainstaluj plugin:

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

Następnie użyj GraphQL w 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}

Optymalizacja obrazów

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

Implementuj podgląd nieopublikowanych treści:

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

Zawsze używaj zmiennych środowiskowych dla wrażliwych danych:

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// Lub użyj cache tags
5fetch(url, { next: { tags: ['articles'] } });

3. Error Handling

Implementuj solidną obsługę błędów:

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# Zainstaluj generator typów
2npm install -D @strapi/types

Podsumowanie

Połączenie Next.js ze Strapi tworzy potężny stack do budowania nowoczesnych aplikacji webowych. Strapi dostarcza elastyczne API i intuicyjny system zarządzania treścią, podczas gdy Next.js zapewnia wydajny frontend z SSG/SSR i optymalizacjami.

Ta kombinacja jest idealna dla:

  • Blogów i portali informacyjnych
  • E-commerce
  • Portfolio i stron korporacyjnych
  • Aplikacji SaaS
  • Platform edukacyjnych

W następnych modułach zgłębimy bardziej zaawansowane tematy, takie jak multi-tenancy, internationalization i custom plugins w Strapi.

Kod do tej lekcji: 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: 'Wprowadzenie do Headless CMS', slug: 'intro-headless-cms', content: 'Headless CMS oddziela backend od frontendu, dając pełną kontrolę nad prezentacją treści...', author: 'Admin', publishedAt: '2025-01-15', category: 'CMS' },
15  { id: 2, title: 'Strapi i Next.js - Pierwsze kroki', slug: 'strapi-nextjs-start', content: 'Integracja Strapi z Next.js pozwala na budowanie wydajnych aplikacji z dynamiczną treścią...', author: 'Developer', publishedAt: '2025-02-10', category: 'Tutorial' },
16  { id: 3, title: 'REST vs GraphQL w Strapi', slug: 'rest-vs-graphql', content: 'Strapi oferuje zarówno REST API jak i GraphQL. Każde podejście ma swoje zalety...', author: 'Admin', publishedAt: '2025-03-05', category: 'API' },
17  { id: 4, title: 'Typowanie odpowiedzi Strapi w TypeScript', slug: 'strapi-typescript', content: 'Dzięki TypeScript możemy typować odpowiedzi z API Strapi dla lepszego 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' }}>Symulacja headless CMS API</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;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jakie dane Strapi domyślnie zwraca przez REST API?

  2. 2. Która z poniższych cech NAJLEPIEJ opisuje Headless CMS?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Uporządkuj kroki integracji Next.js ze Strapi.

  • Edytor kodu

    Stwórz komponent pobierający dane z headless CMS Strapi w Next.js

  • Edytor kodu

    Stwórz Article content type z polami title, content, slug.

Przydatne artykuły