12 / 12Proje
Ürün kataloğu
Cache, streaming, Server Action ve metadata ile ürün listesi, detay sayfası ve sepet.
Senaryo
Küçük bir mağaza kuracaksınız. Uygulama:
/productsadresinde ürünleri listeler; arama kutusu aramayı URL'ye yazar.Ürün sayfasında adı ve fiyatı cache'ten anında, stoğu istek anında akıtarak gösterir.
Sepete ekle stoğu sunucuda kontrol eder, sepeti cookie'ye yazar; üstteki sepet sayısı aynı yanıtta güncellenir.
/cartsepeti listeler ve ürün kaldırır.Bir CMS ürün değişince webhook'u çağırır, ilgili cache yenilenir.
Her ürünün başlığı, canonical adresi, paylaşım görseli ve JSON-LD'si olur; site haritası ve
robots.txtüretilir.Bir
instant()testi ürün sayfasına geçişin anında kaldığını korur.
Amaç tasarım değil, derslerdeki parçaların birlikte çalışmasıdır. Her parçanın hangi dersten geldiği:
| Parça | Dosya | Ders |
|---|---|---|
"use cache", cacheLife, cacheTag, connection() | lib/products.ts | Cache ve render modeli |
Cookie'de sepet, server-only | lib/cart.ts | Server ve Client Components |
Kök layout, <Suspense> içinde sepet linki | app/layout.tsx, components/cart-link.tsx | Routing ve layout, Veri çekme ve streaming |
next/form, searchParams | app/products/page.tsx, components/product-list.tsx | Routing ve layout |
params'ı <Suspense> içinde okumak, prefetch | app/products/[slug]/page.tsx | Navigasyon ve prefetch |
Server Action, useActionState, bind | app/actions.ts, components/add-to-cart-form.tsx, app/cart/page.tsx | Server Actions |
notFound, error.tsx ve retry | not-found.tsx, error.tsx | Hata yönetimi |
Webhook ile revalidateTag | app/api/revalidate/route.ts | Route Handler ve proxy |
generateMetadata, OG görseli, sitemap, JSON-LD | app/products/[slug]/, app/sitemap.ts, app/robots.ts | Metadata ve SEO |
instant() testi | e2e/product.spec.ts | Navigasyon ve prefetch |
Adım 1: Projeyi Kurun
Kurulum ve proje yapısı dersindeki gibi varsayılanlarla bir proje açın:1
$ pnpm create next-app@latest my-shop --yesnext.config.ts'te cacheComponents: true ve partialPrefetching: true hazır gelir; bu proje ikisine de dayanıyor. app/page.tsx'in içeriğini Adım 4'te değiştireceksiniz.
Adım 2: Veri Katmanı
Gerçek bir veritabanı yerine gecikmeli fonksiyonlar kullanın. Ürün bilgisi saatlerce değişmez, cache'lenir. Stok ise her istekte güncel olmalı.
// lib/products.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import { connection } from "next/server";
export type Product = {
slug: string;
name: string;
price: number;
description: string;
};
const products: Product[] = [
{ slug: "canvas-sneaker", name: "Kanvas Ayakkabı", price: 1499, description: "Günlük kullanım için hafif kanvas ayakkabı." },
{ slug: "leather-backpack", name: "Deri Sırt Çantası", price: 2899, description: "Laptop bölmeli, el yapımı deri çanta." },
{ slug: "wool-beanie", name: "Yün Bere", price: 449, description: "Yumuşak merinos yünden bere." },
];
const stock: Record<string, number> = {
"canvas-sneaker": 12,
"leather-backpack": 0,
"wool-beanie": 4,
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function getProducts(query = "") {
"use cache";
cacheLife("hours");
cacheTag("products");
await wait(300);
const q = query.toLocaleLowerCase("tr");
return products.filter((product) => product.name.toLocaleLowerCase("tr").includes(q));
}
export async function getProduct(slug: string) {
"use cache";
cacheLife("hours");
cacheTag("products", `product-${slug}`);
await wait(300);
return products.find((product) => product.slug === slug) ?? null;
}
export async function getStock(slug: string) {
await connection();
await wait(800);
return stock[slug] ?? 0;
}"use cache"sonucu argümanlara göre saklar.getProduct("canvas-sneaker")ilegetProduct("wool-beanie")ayrı kayıtlardır.2İki etiket var:
productstüm listeyi,product-${slug}tek ürünü yeniler. Adım 10'daki webhook bunları kullanacak.3connection()Next.js'e "bunu prerender etme, istek anında çalıştır" der. Stok her ziyarette yeniden okunur.2
Adım 3: Cookie'de Sepet
Sepeti bir cookie'de JSON olarak tutun. Cookie okumak Server Component'te serbesttir; yazmak ise yalnızca bir Server Action ya da Route Handler'da olur, çünkü cookie yanıt başlığıyla gönderilir.4
// lib/cart.ts
import "server-only";
import { cookies } from "next/headers";
export type Cart = Record<string, number>;
const CART_COOKIE = "cart";
export async function getCart(): Promise<Cart> {
const value = (await cookies()).get(CART_COOKIE)?.value;
if (!value) return {};
try {
return JSON.parse(value) as Cart;
} catch {
return {};
}
}
export async function saveCart(cart: Cart) {
(await cookies()).set(CART_COOKIE, JSON.stringify(cart), {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}httpOnly cookie'yi tarayıcıdaki JavaScript'ten gizler. Yine de kullanıcı onu DevTools'tan değiştirebilir; bu yüzden içindeki her değeri güvenilmez kabul edeceksiniz (Adım 7 ve 8).
Adım 4: Layout ve Sepet Linki
Sepet sayısı cookie'den gelir, yani istek anında hesaplanır. Onu <Suspense> içine alırsanız header'ın geri kalanı statik kabukta kalır.2
// components/cart-link.tsx
import Link from "next/link";
import { getCart } from "@/lib/cart";
export async function CartLink() {
const cart = await getCart();
const count = Object.values(cart).reduce((sum, quantity) => sum + quantity, 0);
return <Link href="/cart">Sepet ({count})</Link>;
}// app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { CartLink } from "@/components/cart-link";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://shop.example.com"),
title: { default: "Mağaza", template: "%s | Mağaza" },
description: "Next.js ile kurulmuş küçük bir ürün kataloğu.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="tr">
<body>
<header>
<nav>
<Link href="/">Mağaza</Link>
<Link href="/products">Ürünler</Link>
<Suspense fallback={<span>Sepet</span>}>
<CartLink />
</Suspense>
</nav>
</header>
<main>{children}</main>
</body>
</html>
);
}// app/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<section>
<h1>Yeni sezon</h1>
<p>Ayakkabı, çanta ve aksesuarlar.</p>
<Link href="/products">Ürünleri gör</Link>
</section>
);
}create-next-app'in yazdığı layout.tsx Geist font'larını next/font ile yükler. Bu dosyayla değiştirince globals.css'teki --font-geist-sans ve --font-geist-mono değişkenleri boşta kalır; onları system-ui, sans-serif ve ui-monospace, monospace ile değiştirin.
Adım 5: Ürün Listesi ve Arama
next/form, action bir adres olduğunda aramayı ?q= olarak URL'ye yazar ve istemci tarafında geçiş yapar.5 searchParams istek anında bilinir; okuyan kısım <Suspense> içinde olmalı.2
// app/products/page.tsx
import type { Metadata } from "next";
import Form from "next/form";
import { Suspense } from "react";
import { ProductList } from "@/components/product-list";
export const metadata: Metadata = { title: "Ürünler" };
export default function ProductsPage({ searchParams }: PageProps<"/products">) {
return (
<>
<h1>Ürünler</h1>
<Form action="/products">
<input name="q" aria-label="Ürün ara" placeholder="Ara..." />
<button>Ara</button>
</Form>
<Suspense fallback={<p>Ürünler yükleniyor...</p>}>
<ProductList searchParams={searchParams} />
</Suspense>
</>
);
}// components/product-list.tsx
import Link from "next/link";
import { getProducts } from "@/lib/products";
export async function ProductList({
searchParams,
}: Pick<PageProps<"/products">, "searchParams">) {
const { q } = await searchParams;
const products = await getProducts(typeof q === "string" ? q : "");
if (products.length === 0) return <p>Sonuç yok.</p>;
return (
<ul>
{products.map((product) => (
<li key={product.slug}>
<Link href={`/products/${product.slug}`} prefetch>
{product.name}
</Link>{" "}
{product.price} TL
</li>
))}
</ul>
);
}q bir dizi de olabilir (?q=a&q=b); typeof kontrolü bu yüzden var. prefetch ürün sayfasındaki cache'li içeriği tıklamadan önce getirir; etkisini Adım 12'deki test gösterecek.6
Adım 6: Ürün Sayfası
Sayfanın üç katmanı var:
| Katman | İçerik | Ne zaman gelir |
|---|---|---|
| App Shell | <article> ve "Ürün yükleniyor..." | Tüm ürün linkleri için ortak, hemen |
| Cache'li detay | Ad, açıklama, fiyat, JSON-LD | prefetch ile tıklamadan önce |
| İstek anı | Stok ve Sepete ekle | Tıkladıktan sonra akarak |
// app/products/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { AddToCartForm } from "@/components/add-to-cart-form";
import { getProduct, getProducts, getStock } from "@/lib/products";
export async function generateStaticParams() {
const products = await getProducts();
return products.map((product) => ({ slug: product.slug }));
}
export async function generateMetadata({
params,
}: PageProps<"/products/[slug]">): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) return {};
return {
title: product.name,
description: product.description,
alternates: { canonical: `/products/${product.slug}` },
};
}
export default function ProductPage({ params }: PageProps<"/products/[slug]">) {
return (
<article>
<Suspense fallback={<p>Ürün yükleniyor...</p>}>
<ProductDetails params={params} />
</Suspense>
</article>
);
}
async function ProductDetails({ params }: Pick<PageProps<"/products/[slug]">, "params">) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
offers: { "@type": "Offer", price: product.price, priceCurrency: "TRY" },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>{product.price} TL</p>
<Suspense fallback={<p>Stok kontrol ediliyor...</p>}>
<Stock slug={product.slug} />
</Suspense>
</>
);
}
async function Stock({ slug }: { slug: string }) {
const count = await getStock(slug);
if (count === 0) return <p>Tükendi</p>;
return (
<>
<p>Stokta {count} adet</p>
<AddToCartForm slug={slug} />
</>
);
}ProductPageasyncdeğil veparams'ıawaitetmiyor. Promise'i<Suspense>içindeki child'a veriyor.await paramssayfanın en üstünde olsaydı geliştirmede "URL data outside of Suspense" uyarısı çıkardı.6generateStaticParamsüç ürünü build'de prerender eder. Ürüne doğrudan gelen ziyaretçi adı statik HTML'de görür.7generateMetadataveProductDetailsaynıgetProduct'ı çağırıyor;"use cache"sayesinde veri bir kez okunur.8
Adım 7: Sepete Ekle
Server Action stoğu ve ürünü sunucuda doğrular. İstemciden gelen slug'a güvenmez.9
// app/actions.ts
"use server";
import { getCart, saveCart } from "@/lib/cart";
import { getProduct, getStock } from "@/lib/products";
export type AddToCartState = { message: string | null };
export async function addToCart(
_prev: AddToCartState,
formData: FormData,
): Promise<AddToCartState> {
const slug = String(formData.get("slug"));
const product = await getProduct(slug);
if (!product) return { message: "Ürün bulunamadı." };
const cart = await getCart();
const quantity = (cart[slug] ?? 0) + 1;
if (quantity > (await getStock(slug))) return { message: "Stokta yeterli ürün yok." };
await saveCart({ ...cart, [slug]: quantity });
return { message: `${product.name} sepete eklendi.` };
}
export async function removeFromCart(slug: string) {
const cart = await getCart();
delete cart[slug];
await saveCart(cart);
}// components/add-to-cart-form.tsx
"use client";
import { useActionState } from "react";
import { addToCart } from "@/app/actions";
export function AddToCartForm({ slug }: { slug: string }) {
const [state, formAction, isPending] = useActionState(addToCart, { message: null });
return (
<form action={formAction}>
<input type="hidden" name="slug" value={slug} />
<button disabled={isPending}>{isPending ? "Ekleniyor..." : "Sepete ekle"}</button>
{state.message && <p role="status">{state.message}</p>}
</form>
);
}Header'daki Sepet (1) için hiçbir şey yazmadınız. Bir Server Action cookie yazınca Next.js o sayfayı sunucuda yeniden render eder ve yeni arayüzü action'ın yanıtıyla aynı turda gönderir.49 Stok kontrolü bir hata değil, beklenen bir durum; bu yüzden throw yerine mesaj dönüyor.
Adım 8: Sepet Sayfası
// app/cart/page.tsx
import type { Metadata } from "next";
import { Suspense } from "react";
import { removeFromCart } from "@/app/actions";
import { getCart } from "@/lib/cart";
import { getProduct } from "@/lib/products";
export const metadata: Metadata = { title: "Sepet", robots: { index: false } };
export default function CartPage() {
return (
<>
<h1>Sepet</h1>
<Suspense fallback={<p>Sepet yükleniyor...</p>}>
<CartItems />
</Suspense>
</>
);
}
async function CartItems() {
const cart = await getCart();
const lines = (
await Promise.all(
Object.entries(cart).map(async ([slug, quantity]) => {
const product = await getProduct(slug);
return product ? [{ product, quantity }] : [];
}),
)
).flat();
if (lines.length === 0) return <p>Sepetiniz boş.</p>;
return (
<ul>
{lines.map(({ product, quantity }) => (
<li key={product.slug}>
{product.name} x {quantity}
<form action={removeFromCart.bind(null, product.slug)}>
<button>Kaldır</button>
</form>
</li>
))}
</ul>
);
}Ürünler
Promise.allile paralel okunur; sıraylaawaitetmek bir şelale yaratırdı.Cookie'de artık var olmayan bir ürün olabilir;
[]dönen satır listeden düşer.bind,slug'ı action'a ilk argüman olarak bağlar. Form JavaScript yüklenmeden de çalışır.10robots: { index: false }sepet sayfasını arama sonuçlarından çıkarır.
Adım 9: Bulunamadı ve Hata
// app/products/[slug]/not-found.tsx
import Link from "next/link";
export default function ProductNotFound() {
return (
<>
<h1>Ürün bulunamadı</h1>
<Link href="/products">Tüm ürünler</Link>
</>
);
}// app/products/error.tsx
"use client";
export default function ProductsError({ retry }: { error: unknown; retry: () => void }) {
return (
<>
<h2>Ürünler yüklenemedi.</h2>
<button onClick={() => retry()}>Tekrar dene</button>
</>
);
}error.tsx app/products/ altında olduğu için hem listeyi hem ürün sayfalarını korur; header yerinde kalır. /products/nope adresi not-found.tsx'i gösterir. notFound() akış başladıktan sonra çağrıldığı için durum kodu 200 kalır ve sayfaya noindex eklenir.11
Adım 10: CMS Webhook'u
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
if (request.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = (await request.json()) as { slug?: string };
revalidateTag(slug ? `product-${slug}` : "products", "max");
return Response.json({ revalidated: true });
}Gizli anahtarı .env.local'a yazın:
# .env.local
REVALIDATE_SECRET=change-me"max" profili stale-while-revalidate çalışır: webhook'tan sonraki ilk ziyaretçi eski sayfayı hemen alır ve arka planda yenileme başlar, sonraki ziyaretçi yeni veriyi görür.3 Bu rehber yazılırken production build'de denendi: webhook'tan sonraki ilk istek eski veriyi, ikincisi yenisini gösterdi.
Adım 11: SEO Dosyaları
Metadata ve SEO dersindeki üç dosyayı ekleyin:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getProducts } from "@/lib/products";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getProducts();
return [
{ url: "https://shop.example.com/products" },
...products.map((product) => ({
url: `https://shop.example.com/products/${product.slug}`,
})),
];
}// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/cart" },
sitemap: "https://shop.example.com/sitemap.xml",
};
}// app/products/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getProduct } from "@/lib/products";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProduct(slug);
return new ImageResponse(
<div style={{ display: "flex", width: "100%", height: "100%", alignItems: "center", justifyContent: "center", fontSize: 96, background: "white" }}>
{product?.name ?? "Mağaza"}
</div>,
size,
);
}Next.js opengraph-image.tsx'i bulunca ürün sayfasının <head>'ine og:image etiketini kendisi ekler; metadataBase sayesinde adres tam URL olur.12
Adım 12: Anında Navigasyonu Test Edin
Playwright ve Next.js'in test yardımcısını kurun:1314
$ pnpm add -D @playwright/test @next/playwright$ pnpm dlx playwright install chromium// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
use: { baseURL: "http://localhost:3000" },
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: true,
},
});// e2e/product.spec.ts
import { instant } from "@next/playwright";
import { expect, test } from "@playwright/test";
test("ürün sayfası anında açılır, stok sonra gelir", async ({ page }) => {
await page.goto("/products");
await instant(page, async () => {
await page.getByRole("link", { name: "Kanvas Ayakkabı" }).click();
await page.waitForURL((url) => url.pathname === "/products/canvas-sneaker");
await expect(page.getByRole("heading", { name: "Kanvas Ayakkabı" })).toBeVisible();
await expect(page.getByText("Stok kontrol ediliyor...")).toBeVisible();
});
await expect(page.getByText("Stokta 12 adet")).toBeVisible();
});
test("sepete ekleme sepet sayısını günceller", async ({ page }) => {
await page.goto("/products/canvas-sneaker");
await page.getByRole("button", { name: "Sepete ekle" }).click();
await expect(page.getByRole("status")).toHaveText("Kanvas Ayakkabı sepete eklendi.");
await expect(page.getByRole("link", { name: "Sepet (1)" })).toBeVisible();
});$ pnpm dlx playwright testinstant() bloğunun içinde yalnızca tıklama anında hazır olan arayüz görünür; istek anı verisi blok bitene kadar bekletilir. Test, ürün adının orada olduğunu ve stoğun henüz gelmediğini doğruluyor.14
waitForURLolmadan seçici, hedef sayfa gelmeden kaynak sayfadaki bir öğeyi bulabilir. Hedef URL'yi beklemek bunu önler.14ProductList'tekiprefetch'i silip testi yeniden çalıştırın. Ürün adı artık bloğun içinde görünmediği için test başarısız olur. Biri ileride bu linki değiştirirse CI aynı şekilde yakalar.
Testler next dev'e karşı çalışır; test API'si orada kendiliğinden açıktır.14 Playwright'ın kendisi Playwright rehberinde.
Adım 13: Son Kontrol
npm run lint
npm run buildnext build tipleri de kontrol eder. Build çıktısında ürün sayfaları ◐ ile işaretlenir: statik kabuk build'de üretildi, stok istek anında akıyor. Ürün bilgisinin yenilenme süresi cacheLife("hours")'tan gelir.2
├ /products/[slug]
│ ├ ◐ /products/canvas-sneaker 1h 1d
│ ├ ◐ /products/leather-backpack 1h 1d
│ └ ◐ /products/wool-beanie 1h 1d
├ ƒ /api/revalidatenpm start ile açın ve şu sırayı deneyin: arama, ürün sayfası, Sepete ekle, stoğu 0 olan Deri Sırt Çantası, sepetten kaldırma, /products/nope, /sitemap.xml ve /products/canvas-sneaker/opengraph-image.
İpucu
Bu projeyi bir agent'la geliştirecekseniz next-dev-loop skill'ini kurun ve her değişiklikten sonra npx playwright test'i çalıştırmasını isteyin. Ayrıntısı AI agent'larla Next.js dersinde.
Kendin Dene
DevTools'tan
cartcookie'sini{"canvas-sneaker":99}yapın. Sepet ne gösteriyor? Sepete ekle'ye basınca ne oluyor? Gerçek bir mağazada sepetin neden veritabanında tutulduğunu düşünün.getProduct'tan"use cache",cacheLifevecacheTagsatırlarını silipinstant()testini çalıştırın. Neden başarısız oldu? (Yalnızca"use cache"'i silersenizcacheLifehata verir.)getProduct'ın döndürdüğü açıklamayanew Date().toISOString()koyun.npm run build && npm startile açıp webhook'ucurl -X POST -H "x-revalidate-secret: change-me" -H "content-type: application/json" -d '{"slug":"canvas-sneaker"}' localhost:3000/api/revalidateile çağırın; sayfayı iki kez yenileyin.Ürün sayfasına öneriler bölümü ekleyin ve Hata yönetimi dersindeki
SectionBoundaryile sarın. Öneriler hata verince sayfanın geri kalanı yerinde kalıyor mu?Sepet sayfasına toplam fiyatı ekleyin. Hesabı sunucuda mı istemcide mi yaptınız, neden?
next.config.ts'e/sepet→/cartyönlendirmesiniredirectsile ekleyin.
Kaynaklar
`create-next-app` ve varsayılan ayarlar.
`"use cache"`, `cacheLife`, `connection()`, runtime veriyi `<Suspense>` içinde okumak ve statik kabuk.
Etiketle yenileme, `"max"` profili ve stale-while-revalidate davranışı.
Cookie'nin yalnızca Server Function ya da Route Handler'da yazılması ve aynı turda güncellenen arayüz.
Aramayı URL'ye yazan form ve istemci tarafı geçiş.
URL verisini `<Suspense>` içine taşımak ve `prefetch={true}` ile cache'li içeriği önceden getirmek.
Dinamik segmentleri build'de prerender etmek.
`generateMetadata` ve sayfanın aynı veriyi tekrar okumaması.
Her action'ı güvenilmez giriş noktası saymak, cookie yazınca sayfanın yeniden render edilmesi ve tek turda veri ile arayüz.
`bind` ile action'a argüman geçmek ve JavaScript yüklenmeden çalışan formlar.
Akış başladıktan sonra `notFound()` ve `noindex`.
Kodla üretilen paylaşım görseli ve otomatik `og:image` etiketi.
Kurulum, tarayıcıları indirmek ve testleri çalıştırmak.
`instant()` testi, hedef URL'yi beklemek ve `next dev`'de test API'si.
Son güncelleme tarihi: 21 Ekim 2026
