Next.js to React framework który rozwiązuje problem SEO i wydajności aplikacji SPA – renderuje strony po stronie serwera (SSR) lub generuje statycznie (SSG), tak jak klasyczne PHP aplikacje. Dla PHP developera Next.js ma znajomą strukturę: routing oparty na plikach, server-side rendering, API routes jako własny backend. Pokazuję podstawy z perspektywy kogoś kto myśli w PHP i Magento.
Dlaczego Next.js a nie czysty React?
Czysty React (Create React App, Vite) renderuje stronę w przeglądarce – robot Google widzi pustą stronę HTML i czeka na JavaScript. Next.js renderuje HTML po stronie serwera – robot dostaje gotową stronę. To fundamentalna różnica dla SEO sklepu e-commerce:
// Czysty React - przeglądarka dostaje:
<html>
<body>
<div id="root"></div> // puste - JavaScript renderuje treść
<script src="bundle.js"></script>
</body>
</html>
// Next.js - przeglądarka i robot Google dostają:
<html>
<body>
<h1>Widget Pro - Najlepszy widget na rynku</h1>
<p>Cena: 29.99 PLN</p>
// ... pełna treść strony gotowa przy pierwszym ładowaniu
</body>
</html>
Instalacja i struktura projektu
# Nowy projekt Next.js (App Router - zalecany od Next.js 13+)
npx create-next-app@latest my-magento-frontend \
--typescript \
--tailwind \
--app
cd my-magento-frontend
npm run dev
my-magento-frontend/
app/ # App Router - routing oparty na katalogach
layout.tsx # Globalny layout - jak layout.xml w Magento
page.tsx # Strona główna - /
catalog/
category/
[slug]/
page.tsx # /catalog/category/[slug]
product/
[sku]/
page.tsx # /catalog/product/[sku]
api/ # API Routes - własny backend
cart/
route.ts # POST /api/cart
search/
route.ts # GET /api/search?q=...
components/ # Komponenty React
Header.tsx
ProductCard.tsx
AddToCartButton.tsx
lib/
magento.ts # Klient Magento GraphQL/REST API
types/
product.ts # TypeScript interfejsy
Pobieranie danych z Magento GraphQL – Server Component
App Router Next.js 13+ ma Server Components – komponenty które renderują się na serwerze i mogą bezpośrednio fetchować dane. Dla PHP developera to jak metoda kontrolera która pobiera dane z bazy:
// lib/magento.ts - klient GraphQL dla Magento
const MAGENTO_URL = process.env.NEXT_PUBLIC_MAGENTO_URL || 'https://shop.example.com';
interface GraphQLResponse<T> {
data: T;
errors?: Array<{ message: string }>;
}
export async function magentoQuery<T>(
query: string,
variables?: Record<string, unknown>,
token?: string
): Promise<T> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${MAGENTO_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
next: { revalidate: 60 }, // cache 60 sekund - jak Varnish TTL
});
const json: GraphQLResponse<T> = await response.json();
if (json.errors) {
throw new Error(json.errors[0].message);
}
return json.data;
}
// app/catalog/product/[sku]/page.tsx - Server Component (domyślnie)
// Renderuje się na serwerze - jak PHP controller + template
import { magentoQuery } from '@/lib/magento';
import { notFound } from 'next/navigation';
import AddToCartButton from '@/components/AddToCartButton';
interface Product {
id: number;
sku: string;
name: string;
price_range: {
minimum_price: {
final_price: { value: number; currency: string };
};
};
description: { html: string };
media_gallery: Array<{ url: string; label: string }>;
}
const PRODUCT_QUERY = `
query GetProduct($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
id
sku
name
description { html }
price_range {
minimum_price {
final_price { value currency }
}
}
media_gallery { url label }
}
}
}
`;
// generateMetadata - odpowiednik meta tagów w PHTML
export async function generateMetadata({ params }: { params: { sku: string } }) {
const data = await magentoQuery<{ products: { items: Product[] } }>(
PRODUCT_QUERY,
{ sku: params.sku }
);
const product = data.products.items[0];
if (!product) return {};
return {
title: `${product.name} | Mój Sklep`,
description: product.description.html.replace(/<[^>]*>/g, '').slice(0, 160),
};
}
// Główny komponent - renderuje się na serwerze
export default async function ProductPage({ params }: { params: { sku: string } }) {
const data = await magentoQuery<{ products: { items: Product[] } }>(
PRODUCT_QUERY,
{ sku: params.sku }
);
const product = data.products.items[0];
if (!product) {
notFound(); // jak 404 w Magento
}
const price = product.price_range.minimum_price.final_price;
const mainImage = product.media_gallery[0];
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
{mainImage && (
<img
src={mainImage.url}
alt={mainImage.label || product.name}
className="w-full rounded-lg shadow"
/>
)}
</div>
<div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-2xl text-primary font-semibold mb-6">
{price.value.toFixed(2)} {price.currency}
</p>
<div
className="prose mb-6"
dangerouslySetInnerHTML={{ __html: product.description.html }}
/>
{/* Client Component - interaktywny */}
<AddToCartButton productId={product.id} productName={product.name} />
</div>
</div>
</div>
);
}
Client Component – interaktywność
// components/AddToCartButton.tsx - Client Component
// "use client" - ten komponent renderuje się w przeglądarce
'use client';
import { useState } from 'react';
interface Props {
productId: number;
productName: string;
}
export default function AddToCartButton({ productId, productName }: Props) {
const [qty, setQty] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function addToCart() {
setIsLoading(true);
setMessage(null);
try {
// API Route Next.js jako proxy do Magento REST API
const response = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, qty }),
});
const data = await response.json();
if (response.ok) {
setMessage('Produkt dodany do koszyka!');
} else {
setMessage(data.error || 'Wystąpił błąd');
}
} catch {
setMessage('Błąd połączenia');
} finally {
setIsLoading(false);
}
}
return (
<div>
<div className="flex items-center gap-3 mb-4">
<button onClick={() => setQty(Math.max(1, qty - 1))} className="btn-qty">-</button>
<span className="w-12 text-center font-semibold">{qty}</span>
<button onClick={() => setQty(qty + 1)} className="btn-qty">+</button>
</div>
<button
onClick={addToCart}
disabled={isLoading}
className="w-full bg-primary text-white py-3 rounded disabled:opacity-50"
>
{isLoading ? 'Dodaję...' : 'Dodaj do koszyka'}
</button>
{message && (
<p className="mt-2 text-sm text-green-600">{message}</p>
)}
</div>
);
}
Porównanie z klasycznym PHP MVC
| Koncepcja PHP/Magento | Odpowiednik Next.js |
|---|---|
| Controller::execute() | page.tsx (Server Component) |
| Repository::getById() | fetch() z danymi GraphQL/REST |
| layout.xml / templates | layout.tsx + komponenty |
| REST API endpoint | app/api/…/route.ts |
| Varnish cache (TTL) | next: { revalidate: 60 } |
| Magento Frontend (Luma) | Client Components |
| generateMetadata() | Meta tagi SEO dynamicznie |
Podsumowanie
Next.js to naturalny wybór dla headless frontendu przy Magento 2 gdy chcesz SSR i dobrego SEO. Architektura Server Components jest bardziej znajoma dla PHP developera niż czysty React SPA – rendering po stronie serwera, bezpośrednie fetche danych, routing oparty na plikach. Client Components obsługują interaktywność tam gdzie jest potrzebna. Razem z Magento GraphQL API to solidny stack dla nowoczesnego sklepu headless.
