Aplikacje Next.js często mają problemy z indeksowaniem w Google i innych wyszukiwarkach. Przyczyna niemal zawsze jest taka sama: treść renderuje się po stronie klienta, więc roboty widzą pusty HTML. Nowoczesne wyszukiwarki potrafią wykonywać JavaScript, ale obniżają priorytet treści, która tego wymaga. Ten przewodnik opisuje wymagania techniczne, dzięki którym aplikacje Next.js mogą być w pełni indeksowane.
Strategie renderowania
W przeciwieństwie do tradycyjnych jednostronicowych aplikacji React, które renderują się w całości w przeglądarce, Next.js udostępnia kilka strategii renderowania. Wybór strategii decyduje o tym, czy wyszukiwarki będą mogły skutecznie indeksować treść.
| Strategia renderowania | Wpływ na SEO | Zastosowanie |
|---|---|---|
| SSG (Static Site Generation) | Doskonały | Wpisy blogowe, strony marketingowe |
| ISR (Incremental Static Regeneration) | Doskonały | Strony produktów, często aktualizowana treść |
| SSR (Server-Side Rendering) | Dobry | Strony personalizowane, dane w czasie rzeczywistym |
| CSR (Client-Side Rendering) | Słaby | Panele administracyjne, obszary wymagające uwierzytelnienia |
Zasada jest prosta: jeśli treść ma być indeksowana, musi być renderowana po stronie serwera.
1. Konfiguracja renderowania po stronie serwera
Usuń ‘use client’ ze stron kluczowych dla SEO
Dyrektywa 'use client' nakazuje Next.js renderowanie komponentu w przeglądarce. To najczęstszy błąd uniemożliwiający indeksowanie:
// ŹLE - Treść nie pojawi się w początkowym kodzie HTML
"use client";
export function ProductPage() {
const [product, setProduct] = useState(null);
useEffect(() => {
fetchProduct().then(setProduct);
}, []);
return <div>{product?.name}</div>;
}
// DOBRZE - Treść jest renderowana po stronie serwera
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await fetchProduct(id);
return <div>{product.name}</div>;
}
Generowanie statyczne z generateStaticParams
W przypadku stron z trasami dynamicznymi użyj generateStaticParams, aby wyrenderować je z wyprzedzeniem podczas budowania:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}
ISR dla treści dynamicznej
Gdy treść często się zmienia, Incremental Static Regeneration zapewnia najlepszą równowagę:
// Ponowna walidacja co godzinę
export const revalidate = 3600;
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return <ProductDetails product={product} />;
}
2. Optymalizacja metadanych
Dynamiczne metadane z generateMetadata
Next.js 13+ udostępnia rozbudowane API do zarządzania metadanymi strony:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: `${post.title} | Your Brand`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://yoursite.com/blog/${slug}`,
type: "article",
publishedTime: post.date,
images: [
{
url: post.ogImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.ogImage],
},
alternates: {
canonical: `https://yoursite.com/blog/${slug}`,
languages: {
en: `https://yoursite.com/en/blog/${slug}`,
pl: `https://yoursite.com/pl/blog/${slug}`,
},
},
};
}
Lista kontrolna niezbędnych metatagów
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
title: {
default: "Your Brand - Main Keyword",
template: "%s | Your Brand",
},
description: "Your compelling meta description under 160 characters.",
keywords: ["keyword1", "keyword2", "keyword3"],
authors: [{ name: "Author Name" }],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
verification: {
google: "your-google-verification-code",
yandex: "your-yandex-verification-code",
},
};
3. Dane strukturalne (JSON-LD)
Dane strukturalne pomagają wyszukiwarkom zrozumieć treść i umożliwiają wyświetlanie wyników z elementami rozszerzonymi:
// components/seo/JsonLd.tsx
export function ArticleJsonLd({
title,
description,
url,
imageUrl,
datePublished,
dateModified,
authorName,
}: {
title: string;
description: string;
url: string;
imageUrl: string;
datePublished: string;
dateModified?: string;
authorName: string;
}) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: title,
description: description,
url: url,
image: imageUrl,
datePublished: datePublished,
dateModified: dateModified || datePublished,
author: {
"@type": "Person",
name: authorName,
},
publisher: {
"@type": "Organization",
name: "Your Brand",
logo: {
"@type": "ImageObject",
url: "https://yoursite.com/logo.png",
},
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
Popularne typy schematów
- Article - wpisy blogowe, artykuły informacyjne
- Product - produkty e-commerce
- Organization - informacje o firmie
- BreadcrumbList - nawigacja okruszkowa
- FAQPage - często zadawane pytania
- HowTo - instrukcje krok po kroku
4. Podstawy technicznego SEO
Generowanie mapy witryny
Utwórz dynamiczną mapę witryny w app/sitemap.ts:
// app/sitemap.ts
import { MetadataRoute } from "next";
export default async function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://yoursite.com";
// Strony statyczne
const staticPages = [
{ url: baseUrl, lastModified: new Date(), priority: 1 },
{ url: `${baseUrl}/about`, lastModified: new Date(), priority: 0.8 },
{ url: `${baseUrl}/contact`, lastModified: new Date(), priority: 0.8 },
];
// Strony dynamiczne (np. wpisy blogowe)
const posts = await getAllPosts();
const blogPages = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.date),
priority: 0.6,
}));
return [...staticPages, ...blogPages];
}
Konfiguracja robots.txt
// app/robots.ts
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/_next/"],
},
sitemap: "https://yoursite.com/sitemap.xml",
};
}
Nagłówki bezpieczeństwa w next.config.js
Nagłówki bezpieczeństwa poprawiają zarówno bezpieczeństwo, jak i sygnały zaufania istotne dla SEO:
// next.config.js
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
];
module.exports = {
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
};
5. Optymalizacja wydajności (Core Web Vitals)
Google wykorzystuje Core Web Vitals jako czynniki rankingowe. Te optymalizacje mają bezpośredni wpływ na pozycje w wynikach wyszukiwania.
Optymalizacja obrazów
import Image from 'next/image';
// DOBRZE - Optymalizacja za pomocą Next.js Image
<Image
src="/hero.jpg"
alt="Descriptive alt text for SEO"
width={1200}
height={630}
priority // Obrazy widoczne bez przewijania
sizes="(max-width: 768px) 100vw, 50vw"
/>
// ŹLE - Brak optymalizacji
<img src="/hero.jpg" alt="" />
Optymalizacja czcionek
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // Zapobiega FOIT
preload: true,
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
Leniwe ładowanie treści poniżej pierwszego ekranu
import dynamic from "next/dynamic";
// Leniwe ładowanie ciężkich komponentów
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <ChartSkeleton />,
ssr: false, // Jeśli nie jest potrzebne dla SEO
});
6. Internacjonalizacja (i18n) pod kątem SEO
Witryny wielojęzyczne wymagają poprawnego wdrożenia hreflang:
// W generateMetadata
alternates: {
canonical: `https://yoursite.com/en/page`,
languages: {
'en': 'https://yoursite.com/en/page',
'pl': 'https://yoursite.com/pl/page',
'de': 'https://yoursite.com/de/page',
'x-default': 'https://yoursite.com/en/page',
},
}
i18n po stronie serwera z next-international
// app/[locale]/page.tsx
import { setStaticParamsLocale } from "next-international/server";
import { getI18n } from "@/locales/config";
export default async function Page({ params }) {
const { locale } = await params;
setStaticParamsLocale(locale); // Wymagane do generowania statycznego
const t = await getI18n();
return <h1>{t("homepage.title")}</h1>;
}
7. Problem BAILOUT: dlaczego komponenty serwerowe renderują się po stronie klienta
To jeden z najpoważniejszych i najsłabiej udokumentowanych problemów SEO w Next.js. Nawet jeśli korzystasz z komponentów serwerowych, niektóre hooki Reacta wymuszą na Next.js całkowite porzucenie renderowania po stronie serwera.
Czym jest BAILOUT_TO_CLIENT_SIDE_RENDERING?
Gdy Next.js napotka określone hooki w drzewie komponentów, wstawia ukryty znacznik o nazwie BAILOUT_TO_CLIENT_SIDE_RENDERING i renderuje cały komponent po stronie klienta. W efekcie roboty widzą pusty HTML.
Niebezpieczne hooki powodujące BAILOUT:
| Hook | Biblioteka | Dlaczego powoduje BAILOUT |
|---|---|---|
usePathname() | next/navigation | Zależy od adresu URL przeglądarki |
useSearchParams() | next/navigation | Zależy od parametrów zapytania |
useParams() | next/navigation | Zależy od parametrów adresu URL |
useRouter() | next/navigation | Stan routera jest dostępny tylko po stronie klienta |
useChangeLocale() | next-international | Wewnętrznie używa usePathname() |
useCurrentLocale() | next-international | Wewnętrznie używa hooków routera |
Jak wykryć BAILOUT
# Sprawdź, czy strona zawiera znaczniki BAILOUT
curl -s https://yoursite.com/page | grep -o 'BAILOUT_TO_CLIENT_SIDE_RENDERING' | wc -l
# Wynik: 0 = dobrze, 1+ = problem
Jeśli wynik jest większy od 0, treść renderuje się po stronie klienta mimo użycia komponentów serwerowych.
Przykład z praktyki: selektor języka
Rozważmy typowy komponent selektora języka:
// ŹLE - Powoduje BAILOUT
"use client";
import { useChangeLocale, useCurrentLocale } from "next-international/client";
export function LanguageSelector() {
const currentLocale = useCurrentLocale(); // BAILOUT!
const changeLocale = useChangeLocale(); // BAILOUT!
return <button onClick={() => changeLocale("pl")}>{currentLocale}</button>;
}
Chociaż ten komponent jest mały, umieszczenie go w Navigation lub Footer spowoduje BAILOUT całej strony.
Rozwiązanie: przekazuj wartości obliczone na serwerze przez propsy
// DOBRZE - Brak BAILOUT
"use client";
import Link from "next/link";
interface LanguageSelectorProps {
locale: string; // Przekazane z serwera
currentPath: string; // Przekazane z serwera
}
function getLocalizedPath(path: string, currentLocale: string, newLocale: string): string {
const segments = path.split("/");
if (segments.length >= 2 && ["en", "pl", "de"].includes(segments[1])) {
segments[1] = newLocale;
return segments.join("/") || `/${newLocale}`;
}
return `/${newLocale}`;
}
export function LanguageSelector({ locale, currentPath }: LanguageSelectorProps) {
const languages = ["en", "pl", "de"];
return (
<div>
{languages.map((lang) => (
<Link key={lang} href={getLocalizedPath(currentPath, locale, lang)}>
{lang.toUpperCase()}
</Link>
))}
</div>
);
}
Wzorzec komponentu serwerowego pełniącego rolę opakowania
// components/layout/footer/index.tsx (komponent serwerowy)
import { getCurrentLocale } from "@/locales/config";
import { FooterClient } from "./footer-client";
interface FooterProps {
currentPath?: string;
}
export async function Footer({ currentPath }: FooterProps) {
const locale = await getCurrentLocale();
const path = currentPath || `/${locale}`;
return <FooterClient locale={locale} currentPath={path} />;
}
// Użycie w page.tsx (komponent serwerowy)
export default async function BlogPage({ params }) {
const { locale, slug } = await params;
return (
<>
<article>{/* treść */}</article>
<Footer currentPath={`/${locale}/blog/${slug}`} />
</>
);
}
Granice Suspense dla hydratacji
Owiń komponenty interaktywne w Suspense, aby umożliwić strumieniowanie przy zachowaniu SSR:
import { Suspense } from "react";
export default async function Page() {
return (
<>
<Suspense fallback={<NavigationSkeleton />}>
<Navigation />
</Suspense>
<main>{/* Treść renderowana po stronie serwera */}</main>
<Suspense fallback={<FooterSkeleton />}>
<Footer currentPath="/en/blog" />
</Suspense>
</>
);
}
Testowanie poprawki
Po wprowadzeniu tych zmian wykonaj weryfikację:
# Zbuduj projekt i sprawdź BAILOUT
npm run build
# Uruchom serwer produkcyjny
npm run start
# Przetestuj kilka stron
curl -s http://localhost:3000/en | grep -o 'BAILOUT' | wc -l
curl -s http://localhost:3000/en/blog | grep -o 'BAILOUT' | wc -l
curl -s http://localhost:3000/en/blog/your-article | grep -o 'BAILOUT' | wc -l
# Wszystkie polecenia powinny zwrócić 0
Najważniejsze wnioski
- Sprawdź komponenty klienckie pod kątem niebezpiecznych hooków (
usePathname,useSearchParamsitd.) - Przekazuj dane przez propsy z komponentów serwerowych zamiast używać hooków klienckich
- Używaj komponentów Link do nawigacji zamiast routingu programistycznego
- Umieszczaj części interaktywne w granicach Suspense
- Testuj za pomocą curl - jeśli treści nie ma w odpowiedzi, roboty również jej nie zobaczą
Ten jeden problem, BAILOUT, może decydować o tym, czy witryna zostanie w pełni zaindeksowana, czy Google całkowicie ją zignoruje.
8. Inne częste błędy SEO w Next.js
Błąd 1: pobieranie po stronie klienta danych istotnych dla SEO
// ŹLE - Google widzi pustą treść
"use client";
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/data").then(setData);
}, []);
Błąd 2: brak atrybutów alt
// ŹLE
<Image src="/photo.jpg" alt="" />
// DOBRZE
<Image src="/photo.jpg" alt="Team meeting in modern office space" />
Błąd 3: zduplikowana treść bez adresu kanonicznego
Zawsze określaj kanoniczne adresy URL, aby uniknąć kar za zduplikowaną treść.
Błąd 4: blokowanie CSS/JS w robots.txt
Nigdy nie blokuj /_next/static/, ponieważ Google potrzebuje tych plików do renderowania stron.
Błąd 5: brak strony 404
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h1>404 - Page Not Found</h1>
<a href="/">Return to Homepage</a>
</div>
);
}
9. Monitorowanie i testowanie SEO
Lista kontrolna narzędzi
- Google Search Console - monitorowanie indeksowania i naprawianie błędów
- Google PageSpeed Insights - analiza Core Web Vitals
- Rich Results Test - walidacja danych strukturalnych
- Mobile-Friendly Test - sprawdzanie renderowania na urządzeniach mobilnych
- Ahrefs/SEMrush - śledzenie pozycji i linków przychodzących
Testowanie renderowania po stronie serwera
# Sprawdź, czy treść pojawia się w początkowym kodzie HTML
curl -A "Googlebot" https://yoursite.com/page | grep "your-content"
# Możesz też użyć Chrome DevTools
# Wyświetl źródło strony (View Page Source), a nie Zbadaj element (Inspect Element), aby zobaczyć HTML wyrenderowany po stronie serwera
Podsumowanie
SEO w Next.js sprowadza się do trzech zasad:
- Renderuj treść po stronie serwera - używaj SSG/ISR/SSR dla stron przeznaczonych do indeksowania
- Udostępniaj kompletne metadane - tytuł, opis, tagi OG i dane strukturalne
- Optymalizuj wydajność - szybsze strony zajmują wyższe pozycje
Przestrzeganie tych wytycznych sprawia, że aplikacje Next.js są w pełni zoptymalizowane pod kątem robotów wyszukiwarek. SEO nie jest zadaniem jednorazowym. Regularne monitorowanie za pomocą Search Console i iteracyjne ulepszenia oparte na danych są niezbędne do utrzymania widoczności.
Potrzebujesz pomocy w optymalizacji SEO aplikacji Next.js? Skontaktuj się, aby umówić konsultację.
