Blok 6 · Ders 43
Next.js temelleri
Next.js App Router: dosya tabanlı sayfalar, layout, sunucu ve istemci bileşenleri, Link ve dinamik sayfalar.
Bu dersin sonunda
create-next-appile TypeScript ve App Router kullanan bir Next.js projesi açıp klasörlerin ne işe yaradığını anlatabilirsinappklasöründepage.tsxvelayout.tsxdosyalarıyla sayfalar ve ortak bir çerçeve kurabilir, bir klasör ağacına bakıp sitenin adreslerini söyleyebilirsin- Bir bileşenin sunucu bileşeni mi istemci bileşeni mi olacağına karar verip gerektiğinde dosyanın başına
"use client"yazabilirsin Linkile sayfa yenilemeden gezinebilir,[id]klasörü veawait paramsile her şampiyona ayrı adres verebilirsinmetadata,generateMetadata,not-found.tsxvenotFound()ile sekme başlığını ve 404 sayfasını yönetebilirsinnext/imagevenext/fontile resimleri ve yazı tiplerini, sayfadaki yazılar yerinden kaymadan ve hızlı yükleyebilirsin
Bu sayfada
- Next.js neden var?
- Proje açmak: create-next-app
- page.tsx: ilk sayfan
- layout.tsx: bütün sayfaların çerçevesi
- Link ile gezinme
- Veriden sayfa: sunucu bileşeni
- Dinamik route: her şampiyona ayrı adres
- İstemci bileşeni: "use client"
- Metadata ve not-found
- Görseller ve yazı tipleri
- Sık hatalar
- Görevler
- Kendini sına
- Yayın derlemesi ve kayıt noktası
Üç derstir React ile arayüz kuruyorsun: bileşenler, state, effect. Ama gerçek bir site tek bir ekrandan ibaret değil: birden çok sayfası, her sayfanın kendi adresi, sekmede bir başlığı, bulunamayan adresler için bir 404 sayfası olur. Bu derste React'in üstüne kurulan Next.js çatısıyla tanışıyorsun. Dersin sonunda Şampiyon Rehberi adında çok sayfalı bir siten olacak: ana sayfa, şampiyon listesi, her şampiyon için ayrı bir adres (/sampiyonlar/1, /sampiyonlar/2…), kendi 404 sayfan, sekme başlıkları, hızlı yüklenen resimler ve Türkçe harfleri sorunsuz gösteren bir yazı tipi.
Next.js neden var?
Ders 40'tan beri yazdığın React uygulamalarının hepsi aynı yoldan açılıyor: sunucu tarayıcıya içi boş bir HTML ve bir yığın JavaScript dosyası gönderiyor, tarayıcı JavaScript'i çalıştırıyor, React sayfayı orada kuruyor. Veri gerekiyorsa sayfa önce "yükleniyor" diyor, fetch cevap verince doluyor. Bu düzen çalışır, ama üç derdi var:
- İlk görüntü geç gelir. Telefon yavaşsa kullanıcı, JavaScript inip çalışana kadar boş bir sayfaya bakar.
- Arama motorları ve paylaşım önizlemeleri boş sayfa görür. Google'ın, WhatsApp'ın bağlantı önizlemesinin okuduğu ilk HTML'de yalnız
<div id="root"></div>vardır. - Sayfa ve adres kavramı yoktur. Vite projesi tek bir
index.html'den ibaret./sampiyonlar/ahrigibi adresler, sayfalar arası geçiş, sekme başlığı, 404 sayfası… hepsini kendin kurman gerekir.
Next.js bu üçünü birden çözer. Bileşenlerini önce sunucuda çalıştırır ve tarayıcıya dolu bir HTML gönderir. Adresleri de klasörlerinden çıkarır: app/sampiyonlar/page.tsx dosyasını oluşturursan /sampiyonlar adresi kendiliğinden olur. React'i bırakmıyorsun; Next.js, React'i bu işler için düzenleyen bir çatı. Ders 28'de Express de Node için bir çatıydı.
Proje açmak: create-next-app
Next.js projesini Vite'taki gibi bir sihirbazla açıyorsun: create-next-app. Bu derste Next.js 16.4 kullanıyoruz (npm view next version bu dersi yazarken 16.4.0 diyordu). Sihirbaz, komuta --ts gibi -- ile başlayan seçenekler yazılmazsa soru sorar. Bu seçeneklere bayrak (İngilizce flag) da denir. Biz cevapları bayraklarla komuta yazıyoruz ki herkesin projesi aynı olsun. Önceki derslerin projeleriyle karışmasın diye yeni bir klasör açacağız: next-rehber.
VS Code'da bir terminal aç, cd C:\kod ile kod klasörüne geç ve yaz (tek satır; uzun olduğu için kopyalayabilirsin):
PS C:\kod> npx create-next-app@latest next-rehber --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*" --no-react-compiler --no-cache-components --use-npm| Bayrak | Anlamı |
|---|---|
--ts | TypeScript kullan (.tsx dosyaları). Varsayılan da bu. |
--app | App Router: sayfalar app klasöründe. Next'in eski düzeni Pages Router (pages klasörü); yeni projeler App Router ile açılıyor. |
--tailwind | Tailwind CSS kurulu gelsin. Onu Ders 45'te öğreneceksin; bu derste kendi CSS'imizi yazacağız. |
--eslint | ESLint kod denetleyicisi kurulsun (npm run lint). Ders 40'taki Oxlint'in yaptığı işi yapar. |
--no-src-dir | app klasörü doğrudan projenin kökünde dursun. --src-dir deseydin src/app olurdu. |
--import-alias "@/*" | @/ "projenin kökü" demek olsun. Birazdan import … from "@/lib/sampiyonlar" yazacaksın. |
--no-react-compiler | React Compiler kapalı. Bileşenleri kendiliğinden hızlandıran bir derleme aracı; şimdilik gerek yok. |
--no-cache-components | Cache Components kapalı. Aşağıda neden. |
--use-npm | Paketleri npm kursun. |
İlk çalıştırmada npm, sihirbazı indirmek için izin ister; y yaz ve Enter'a bas. Sonra kurulum yaklaşık yarım dakika sürer. Ekran şöyle akar:
PS C:\kod> npx create-next-app@latest next-rehber --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*" --no-react-compiler --no-cache-components --use-npmNeed to install the following packages:create-next-app@16.4.0Ok to proceed? (y) y Using defaults for unprovided options: --agents-md AGENTS.md (use --no-agents-md for No AGENTS.md) --no-agent-feedback No agent feedback (use --agent-feedback for Agent feedback) Creating a new Next.js app in C:\kod\next-rehber. Using npm. Initializing project with template: app-tw Installing dependencies:- next- react- react-dom Installing devDependencies:- @tailwindcss/turbopack- @types/node- @types/react- @types/react-dom- eslint- eslint-config-next- tailwindcss- typescript npm warn deprecated eslint@9.39.5: This version is no longer supported. Please see https://eslint.org/version-support for other options. added 357 packages, and audited 358 packages in 34s 145 packages are looking for funding run `npm fund` for details 5 high severity vulnerabilities To address all issues (including breaking changes), run: npm audit fix --force Run `npm audit` for details.npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:npm warn install-scripts unrs-resolver@1.12.2 (postinstall: node postinstall.js)npm warn install-scriptsnpm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow. Generating route types...✓ Types generated successfully Initialized a git repository. Success! Created next-rehber at C:\kod\next-rehberUzun ama korkutucu değil. Satır satır:
Using defaults for unprovided options: yazmadığın iki seçenek için varsayılanı kullandı.AGENTS.md, yapay zekâ kod yardımcılarına "bu Next sürümünün belgelerinode_modules/next/dist/docsiçinde, önce onları oku" diyen kısa bir not. Blok 7'de anlamı netleşecek; dokunmana gerek yok.npm warnile başlayan satırlar uyarı, hata değil. Proje kuruldu.5 high severity vulnerabilities:npm auditbunların ESLint'in yardımcı paketlerinde olduğunu söylüyor, yani yalnız senin bilgisayarında çalışan geliştirme araçlarında; sitene giren kodda değil.npm audit fix --forceçalıştırma: bu projedeeslint-config-next'i 14.2.35'e indirmeyi öneriyor, yani Next 16 için yazılmış ayarı iki ana sürüm geriye götürüp projeyi bozar. Paket yazarları düzeltme yayımlayınca normal birnpm updateyeter.Initialized a git repository: sihirbazgit inityaptı ve ilk kaydı (Initial commit from Create Next App) kendisi aldı. Dersin sonunda bu yüzdengit inityazmayacaksın.
Bu ders create-next-app 16.4.0 ile denendi. Daha yeni bir sürümde birkaç satır değişebilir ya da altta npm notice ile başlayan bir yeni sürüm duyurusu görebilirsin; görmezden gel.
Geliştirme sunucusunu başlat
Klasörü VS Code'da aç (güven sorusu gelirse Yes, I trust the authors), terminali aç ve geliştirme sunucusunu başlat:
PS C:\kod> code next-rehberPS C:\kod\next-rehber> npm run dev > next-rehber@0.1.0 dev> next dev ▲ Next.js 16.4.0 (Turbopack)- Local: http://localhost:3000- Network: http://192.168.1.35:3000✓ Ready in 1978ms✓ Running next.config.ts took 66ms ○ Compiling / ... GET / 200 in 495ms (next.js: 319ms, application-code: 176ms)Next'in geliştirme sunucusu 3000 numaralı portu dinler (Vite 5173'tü). Turbopack, Next'in Vite'a benzeyen derleme aracı: .tsx dosyalarını çevirir, kaydettikçe sayfayı tazeler. - Network: satırındaki adres sende farklı olur; aynı Wi-Fi'deki telefonundan siteye bu adresle bakabilirsin (bunun için Ders 18'deki güvenlik duvarı penceresinde node.exe'ye izin vermiş olman gerekir; localhost izinsiz de çalışır). Son iki satırı ancak tarayıcıda http://localhost:3000'i açınca görürsün: Next bir sayfayı ilk istendiğinde derler (Compiling /), sonra her isteği bir GET satırıyla yazar. 200 durum kodu, parantezdekiler sürenin nereye gittiği. Arada Turbopack has seen frequent file updates… diye bir satır görürsen zararsız: Turbopack, node_modules'taki dosyaların oturmasını bekliyor.

Sol alttaki yuvarlak N düğmesi Next'in geliştirme araçları. Yalnız npm run dev ile çalışırken görünür; bir hata olunca kırmızıya döner ve kaç sorun olduğunu yazar. Sunucuyu durdurmak için terminalde Ctrl+C.
Klasörün içi
Vite projesindeki index.html'i arama: Next projesinde yok. Sayfaların HTML'ini Next her istekte (ya da derlemede) kendisi üretiyor. Önemli dosyalar:
app/: sitenin kendisi. İçindelayout.tsx(bütün sayfaların çerçevesi),page.tsx(ana sayfa),globals.css(bütün siteye uygulanan CSS) ve sekmedeki küçük simgefavicon.icovar. Bu derste neredeyse hep burada çalışacaksın.public/: olduğu gibi sunulan dosyalar.public/kapak.pngdosyası sitede/kapak.pngadresinden açılır. Şablonun koyduğu beş SVG burada.next.config.ts: Next'in ayarları. Resimler bölümünde buna bir satır ekleyeceksin.package.json: betikler.dev(geliştirme sunucusu),build(yayın için derleme),start(derlenmiş siteyi çalıştırma),lint(ESLint).tsconfig.json: TypeScript ayarları."paths": { "@/*": ["./*"] }satırı,@/takma adının anlamını burada tanımlıyor.eslint.config.mjs,AGENTS.md,README.md: ESLint ayarı, yapay zekâ notu ve projeyi tanıtan yazı.next-env.d.tsve.next/: Next'in kendi ürettiği dosyalar..nextderleme çıktısı; ikisi de.gitignore'da, Git'e girmez. Dokunma.
page.tsx: ilk sayfan
Şablonun sayfası işini gördü. Önce stilleri hazırla: app/globals.css'in ilk satırını (@import "tailwindcss";) bırak, altındaki her şeyi sil ve yerine şunu koy. CSS bu dersin konusu değil; bu kez kopyalayabilirsin. Dersin ilerisinde kullanacağın bütün sınıflar şimdiden burada:
@import "tailwindcss"; body { margin: 0; background: #16141f; color: #e8e6f0;} .ust { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 20px; border-bottom: 1px solid #34304a; background: #1c1928;} .logo { font-size: 20px; font-weight: 800;} .ust nav { display: flex; gap: 18px;} .ust nav a { color: #a9a4bd;} .ust nav a:hover,.ust nav a.aktif { color: #ffffff;} main { max-width: 760px; margin: 0 auto; padding: 24px 16px;} h1 { margin: 0 0 12px; font-size: 30px; font-weight: 800;} p { margin: 0 0 12px; line-height: 1.6;} .soluk { color: #a9a4bd;} .kapak { width: 100%; height: auto; margin-bottom: 20px; border-radius: 14px;} .dugme { display: inline-block; padding: 10px 18px; border-radius: 999px; background: #6d4bd8; color: #ffffff; font-weight: 700;} .kartlar { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px;} .kart { display: block; padding: 16px; border: 1px solid #34304a; border-radius: 12px; background: #211e2e;} .kart:hover { border-color: #6d4bd8;} .kart h2 { margin: 10px 0 2px; font-size: 18px; font-weight: 700;} .kart p { margin: 0; color: #a9a4bd;} .harf { display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: #6d4bd8; font-size: 24px; font-weight: 800;} .ikon { border-radius: 50%;} .harita { width: 240px; height: auto; margin-bottom: 16px; border-radius: 12px;} .geri { display: inline-block; margin-bottom: 16px; color: #a9a4bd;} .favori { margin-top: 8px; padding: 8px 14px; border: 1px solid #6d4bd8; border-radius: 999px; background: transparent; color: #e8e6f0; font: inherit; cursor: pointer;} .favori.secili { background: #6d4bd8;}Tailwind satırı neden kalıyor? Ders 45'te kullanacaksın. Şimdilik bir yan etkisini bil: Tailwind, tarayıcının varsayılan stillerini sıfırlar. Başlıklar büyük ve kalın görünmez, bağlantıların altı çizilmez; bu yüzden h1'in boyutunu yukarıda kendimiz verdik.
Şimdi app/page.tsx'in içini tamamen sil ve şunu kendin yaz:
export default function Home() { return ( <main> <h1>Şampiyon Rehberi</h1> <p className="soluk">Hangi şampiyonla başlasan? Rollerine ve zorluklarına bak, sana uyanı seç.</p> </main> )}PS C:\kod\next-rehber> npm run dev > next-rehber@0.1.0 dev> next dev ▲ Next.js 16.4.0 (Turbopack)- Local: http://localhost:3000- Network: http://192.168.1.35:3000✓ Ready in 1978ms✓ Running next.config.ts took 66ms ○ Compiling / ... GET / 200 in 495ms (next.js: 319ms, application-code: 176ms)Ana sayfa app/page.tsx. Kaydedince tarayıcı kendiliğinden tazelenir.
Kaydet ve tarayıcıya bak: koyu zeminde başlık ve soluk bir cümle. Bu, Ders 40'taki App gibi bir bileşen; tek farkı onu kimin çağırdığı. Vite'ta main.tsx içinde <App /> yazıyordun. Burada kimseye söylemiyorsun: Next app/page.tsx dosyasını bulur, varsayılan dışa aktarmasını alır ve / adresinin sayfası olarak çizer. Fonksiyonun adı (Home) önemsiz; dosyanın adı ve yeri önemli.
Klasör = adres
İkinci bir sayfa için yapman gereken tek şey bir klasör açmak. VS Code'un dosya listesinde app klasörüne sağ tıkla, New Folder ile hakkinda adında bir klasör aç, içine page.tsx adında bir dosya oluştur:
export default function Hakkinda() { return ( <main> <h1>Hakkında</h1> <p>Bu site, Next.js öğrenirken yaptığım bir alıştırma projesi.</p> </main> )}Kaydet ve tarayıcıda http://localhost:3000/hakkinda adresine git. Sayfa orada. Ne import yazdın, ne bir yönlendirme listesi: klasörün adı adresin kendisi oldu. Ders 27'de req.url === "/sampiyonlar" gibi if'lerle elle yazdığın yönlendirmeyi, Ders 28'de Express'te app.get("/", …) satırlarıyla kurduğunu Next klasör ağacından kendisi çıkarıyor.
Kurallar az ve kesin:
- Adres yalnız klasör adlarından oluşur.
app/hakkinda/page.tsx→/hakkinda.app/sampiyonlar/yeni/page.tsx→/sampiyonlar/yeni. - Sayfa dosyasının adı her zaman
page.tsx, tamamı küçük harf.Page.tsx,index.tsx,hakkinda.tsxsayfa olmaz. - Bir klasörde
page.tsxyoksa o adreste sayfa yoktur, ama klasör yine de alt klasörlerine yol olur. - Klasöre
page.tsxdışında dosya da koyabilirsin (birazdan bir düğme bileşeni koyacaksın). Onlar adres olmaz. - Klasör adlarında Türkçe harf ve boşluk kullanma; küçük harf yaz, sözcükleri tireyle ayır:
yama-notlari. Türkçe harf yasağının sebebi değişken adlarındakiyle aynı; tire ise adreslerin alışılmış yazımı (adreslerde camelCase yerine tire tercih edilir).
page.tsx dosyaları adres olur; köşeli parantezli klasör, adresin değişen parçasıdır.404Ne zaman çıkar? Adresle klasör adı birebir tutmayınca ya da sayfa dosyasının adı page.tsx olmayınca. Türkçe klavyede en sık yapılan: klasörü alışkanlıkla hakkında diye (ı ile) açmak. Adres çubuğuna /hakkinda yazarsın, klasörün adı ise hakkında; ikisi aynı değil. Terminalde:
GET /hakkinda 404 in 29ms (next.js: 11ms, application-code: 19ms)Sayfada Next'in hazır 404 sayfası açılır: 404 | This page could not be found. Denediğimizde Next, hakkında klasörünü tarayıcının /hakk%C4%B1nda diye yazdığı adreste bile bulamadı. Klasör doğru ama dosya index.tsx ya da Page.tsx ise de sonuç aynı 404.
Nasıl düzelir? Klasör adını adresle harfi harfine aynı yap (İngilizce harfler, küçük harf, tire) ve sayfa dosyasının adının page.tsx olduğundan emin ol. Hata mesajı yok, yalnız 404 var; bu yüzden ilk bakacağın yer VS Code'un dosya listesi.
The default export is not a React Component in "/hakkinda/page"Ne zaman çıkar? page.tsx'te export default yazmayı unutunca. Örneğin Ders 40'taki adlı dışa aktarma alışkanlığıyla export function Hakkinda() yazınca. Sayfa yerine hata katmanı açılır, terminalde:
⨯ Error: The default export is not a React Component in "/hakkinda/page" at ignore-listed frames"/hakkinda/page'in varsayılan dışa aktarması bir React bileşeni değil." Next sayfanı adıyla değil, varsayılan dışa aktarmasıyla bulur; varsayılan dışa aktarma yoksa elinde bileşen yoktur. Tarayıcıda durum kodu da 500 olur.
Nasıl düzelir? export default function Hakkinda(). page.tsx ve layout.tsx gibi Next'in tanıdığı dosyalarda bu kural değişmez; kendi bileşen dosyalarında ise iki yolu da kullanabilirsin.
layout.tsx: bütün sayfaların çerçevesi
İki sayfan var ama aralarında gidip gelmenin yolu yok: adresi elle yazıyorsun. Her sayfanın üstüne bir menü koymak istiyorsun. Menüyü iki sayfaya ayrı ayrı yazarsan, üçüncü sayfada da yazman, menü değişince hepsini düzeltmen gerekir. Next'in çözümü layout: sayfaları saran ve bütün sayfalarda aynı kalan bir çerçeve.
app/layout.tsx zaten var; şablon onu koydu. Şu an böyle görünüyor:
import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"],}); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"],}); export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app",}; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > <body className="min-h-full flex flex-col">{children}</body> </html> );}Yazı tipi (Geist) ve metadata kısımlarına dersin sonunda döneceğiz. Önemli olan en alttaki fonksiyon:
RootLayoutbir bileşen;<html>ve<body>etiketlerini o çiziyor. Vite'takiindex.html'in işini artık bu dosya görüyor.{ children }: o an açılan sayfa./adresindeykenapp/page.tsx'in çizdiği şey,/hakkinda'daykenapp/hakkinda/page.tsx'inki. Props'un özel bir adı olanchildren, "bu bileşenin içine konan şey" demek.LayoutProps<"/">: Next'in senin klasörlerine bakarak kendisi ürettiği bir tip; içindechildrenvar.importetmeden kullanabilirsin.
Dosyayı tamamen sil ve şunu yaz. Şimdilik sade tutuyoruz; menüde düz <a> bağlantıları var:
import "./globals.css" export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="tr"> <body> <header className="ust"> <a href="/" className="logo">Şampiyon Rehberi</a> <nav> <a href="/sampiyonlar">Şampiyonlar</a> <a href="/hakkinda">Hakkında</a> </nav> </header> {children} </body> </html> )}lang="tr" tarayıcıya ve ekran okuyuculara sayfanın Türkçe olduğunu söyler; şablonda en yazıyordu. Kaydet: iki sayfanın da üstünde aynı menü var. Menüdeki Hakkında'ya tıkla, sonra logoya: sayfalar arasında gezebiliyorsun. Şampiyonlar şimdilik 404 verir; o sayfayı birazdan yazacaksın.
{children}'ın yerine adrese göre o klasörün page.tsx'i konur.Link ile gezinme
Menü çalışıyor ama bir sorun var: her tıklamada sayfa baştan yükleniyor. Sekmedeki yükleme halkası dönüyor, tarayıcı HTML'i, CSS'i, bütün JavaScript dosyalarını yeniden istiyor. Projenin bitmiş hâlinde denedik: menüdeki düz <a>'ya tıklayınca tarayıcı 19 istek gönderdi (bir HTML, iki yazı tipi, bir CSS, 15 JavaScript dosyası), sayfadaki JavaScript'in hafızası da sıfırlandı. Bir state'in tuttuğu her şey, örneğin açık bir menü ya da yazılmış bir arama, bu yeniden yüklemede kaybolur.
Next'in Link bileşeni bunu çözer. app/layout.tsx'te <a>'ları Link yap:
import Link from "next/link"import "./globals.css" export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="tr"> <body> <header className="ust"> <Link href="/" className="logo">Şampiyon Rehberi</Link> <nav> <Link href="/sampiyonlar">Şampiyonlar</Link> <Link href="/hakkinda">Hakkında</Link> </nav> </header> {children} </body> </html> )}Aynı denemeyi Link ile yaptık: tıklama sayfa için tek bir istek gönderdi (/hakkinda?_rsc=…) ve sayfadaki JavaScript'in hafızası yerinde kaldı. Next yalnız yeni sayfanın içeriğini istedi ve {children}'ın yerine koydu; layout'a, menüye, yazı tipine, CSS'e dokunmadı. Adres çubuğu yine değişir, geri tuşu yine çalışır.
Ana sayfaya da şampiyonlara giden bir düğme koy:
import Link from "next/link" export default function Home() { return ( <main> <h1>Şampiyon Rehberi</h1> <p className="soluk">Hangi şampiyonla başlasan? Rollerine ve zorluklarına bak, sana uyanı seç.</p> <Link href="/sampiyonlar" className="dugme">Şampiyonlara göz at</Link> </main> )}Link HTML'de yine bir <a href="…"> olarak çıkar. Bu yüzden sağ tıklayıp "yeni sekmede aç" çalışır, arama motorları bağlantıyı izler. className gibi nitelikleri de <a>'daki gibi verirsin.
Yayındaki sitede (dersin sonunda npm run build ile deneyeceksin) Link bir şey daha yapar: ekranda görünen bağlantıların sayfalarını, sen tıklamadan önce arka planda hazırlamaya başlar. Tıkladığında sayfa çoğu zaman çoktan gelmiştir.
Ne zaman düz <a>? Sitenin dışına giden bağlantılarda: <a href="https://nextjs.org">. Link yalnız kendi sitenin sayfaları arasında işe yarar.
Veriden sayfa: sunucu bileşeni
Şimdi asıl sayfaya, şampiyon listesine geçelim. Önce veri: proje kökünde (app'in yanında, içinde değil) lib adında bir klasör aç ve içine sampiyonlar.ts dosyasını yaz. JSX yok, bu yüzden uzantı .ts:
export type Sampiyon = { id: number isim: string rol: string zorluk: number tanitim: string} export const sampiyonlar: Sampiyon[] = [ { id: 1, isim: "Ahri", rol: "Büyücü", zorluk: 2, tanitim: "Çevik bir büyücü. Düşmanın arasına dalıp çıkmayı sever." }, { id: 2, isim: "Garen", rol: "Dövüşçü", zorluk: 1, tanitim: "Dayanıklı bir savaşçı. Öğrenmesi kolay, alt etmesi zor." }, { id: 3, isim: "Jinx", rol: "Nişancı", zorluk: 2, tanitim: "Uzaktan vuran bir nişancı. Takım savaşında coşar." }, { id: 4, isim: "Lux", rol: "Büyücü", zorluk: 1, tanitim: "Işık büyüleriyle uzaktan oyunu yöneten bir büyücü." }, { id: 5, isim: "Yasuo", rol: "Dövüşçü", zorluk: 3, tanitim: "Rüzgârı kullanan bir kılıç ustası. Ustalaşması uzun sürer." }, { id: 6, isim: "Teemo", rol: "Nişancı", zorluk: 1, tanitim: "Küçük ama sinir bozucu bir izci. Haritaya mantar eker." },]Gerçek bir sitede bu veri bir veritabanından gelir; onu Ders 44'te bağlayacaksın. lib klasörü adres olmaz, çünkü app'in dışında; Next yalnız app'in içine bakar. Sayfa olmayan ortak kodu (veri, yardımcı fonksiyonlar) koymak için yaygın bir ad.
Sonra app/sampiyonlar/page.tsx dosyasını oluştur:
import Link from "next/link"import { sampiyonlar } from "@/lib/sampiyonlar" export default function SampiyonListesi() { console.log("Şampiyon listesi hazırlanıyor") return ( <main> <h1>Şampiyonlar</h1> <p className="soluk">{sampiyonlar.length} şampiyon var. Birine tıkla, ayrıntısını gör.</p> <div className="kartlar"> {sampiyonlar.map((s) => ( <Link key={s.id} href={`/sampiyonlar/${s.id}`} className="kart"> <div className="harf">{s.isim[0]}</div> <h2>{s.isim}</h2> <p>{s.rol}</p> </Link> ))} </div> </main> )}
map, key ve süslü parantezler Ders 40'tan tanıdık. Yeni olan iki şey var.
Birincisi "@/lib/sampiyonlar". Proje kurulurken --import-alias "@/*" demiştin: @/ her yerden proje kökü demek. Bu dosya app/sampiyonlar içinde; takma ad olmasaydı "../../lib/sampiyonlar" yazman gerekirdi ve dosyayı başka klasöre taşıyınca ../ sayısını düzeltmen gerekirdi. @/lib/sampiyonlar her dosyada aynı.
İkincisi console.log. Sayfayı aç ve terminale bak:
Şampiyon listesi hazırlanıyor GET /sampiyonlar 200 in 57ms (next.js: 10ms, application-code: 47ms)Mesaj, Vite'ta olduğu gibi tarayıcının konsoluna değil, npm run dev'in çalıştığı terminale düştü. Çünkü bu bileşen sunucuda çalıştı. Next, app klasöründeki her bileşeni varsayılan olarak sunucuda çalıştırır, çıkan HTML'i tarayıcıya gönderir. Sayfada sağ tıklayıp Sayfa kaynağını görüntüle'yi (İngilizce tarayıcıda View page source; kısayolu Ctrl+U) seçersen kartları ilk HTML'in içinde görürsün. Kısaltılmış hâli:
<main><h1>Şampiyonlar</h1><p class="soluk">6<!-- --> şampiyon var. Birine tıkla, ayrıntısını gör.</p><div class="kartlar"><a class="kart" href="/sampiyonlar/1"><div class="harf">A</div><h2>Ahri</h2><p>Büyücü</p></a>…Ders 40'taki <div id="root"></div>'in yerinde artık içerik var. (<!-- --> React'in metin parçalarını ayırmak için bıraktığı bir işaret; zararsız.)
Sunucuda çalışmanın sana kattığı şu: bir sonraki derste bu bileşenin içine doğrudan bir veritabanı sorgusu yazacaksın. Ders 38'deki gibi ayrı bir API yazıp onu tarayıcıdan fetch ile çağırmana, yükleniyor durumuyla uğraşmana gerek kalmayacak; veri sayfayla birlikte gelecek.
Dinamik route: her şampiyona ayrı adres
Kartlar /sampiyonlar/1, /sampiyonlar/2… adreslerine gidiyor ama o sayfalar yok. Altı şampiyon için altı klasör (app/sampiyonlar/1, app/sampiyonlar/2…) açmak olmaz; şampiyon sayısı 170 olunca hiç olmaz. Ders 28'de Express'te aynı sorunu /api/sampiyonlar/:id gibi bir yol parametresiyle çözmüştün. Next'teki karşılığı köşeli parantezli bir klasör.
app/sampiyonlar içinde [id] adında bir klasör aç (köşeli parantezler adın parçası) ve içine page.tsx yaz:
import Link from "next/link"import { sampiyonlar } from "@/lib/sampiyonlar" type Props = { params: Promise<{ id: string }>} export default async function SampiyonSayfasi({ params }: Props) { const { id } = await params const sampiyon = sampiyonlar.find((s) => s.id === Number(id)) if (!sampiyon) { return ( <main> <h1>Böyle bir şampiyon yok</h1> </main> ) } return ( <main> <Link href="/sampiyonlar" className="geri">← Bütün şampiyonlar</Link> <h1>{sampiyon.isim}</h1> <p className="soluk">{sampiyon.rol} · Zorluk {sampiyon.zorluk}/3</p> <p>{sampiyon.tanitim}</p> </main> )}Listeden bir karta tıkla: Ahri'nin, Jinx'in kendi sayfası açılıyor. Tek bir dosya, her id için ayrı bir sayfa. Adım adım:
[id]klasörü "bu parça değişebilir, adıidolsun" demek./sampiyonlar/3açılınca Next bu dosyayı seçer veid'ye"3"değerini verir.- Next sayfa bileşenine
paramsadında bir prop verir. Next 16'daparamsbir Promise: değeri hemen değil,awaitile alınır. Bu yüzden fonksiyonun başındaasyncvar ve ilk satırconst { id } = await params. - Tip notu bunu söylüyor:
params: Promise<{ id: string }>.idher zaman metin, çünkü adres bir metin. Dizidekiid'ler ise sayı; karşılaştırmadan önceNumber(id)ile sayıya çeviriyoruz. - Şampiyon bulunamazsa (ör.
/sampiyonlar/99) kısa bir mesaj döndürüyoruz.if'ten sonra TypeScriptsampiyon'un artıkundefinedolamayacağını bilir (daraltma).
Promise olduğunu kendi gözünle gör. Fonksiyonun başına geçici olarak iki satır ekle:
export default async function SampiyonSayfasi({ params }: Props) { console.log(params) const { id } = await params console.log(id, typeof id)/sampiyonlar/3'ü açınca terminal:
Promise { { id: '3' }, Symbol(async_id_symbol): 52741, Symbol(trigger_async_id_symbol): 52740}3 string GET /sampiyonlar/3 200 in 52ms (next.js: 7ms, application-code: 45ms)params bir Promise; içinde { id: '3' } bekliyor. Symbol(…) satırları Node'un kendi iç kayıtları, sayıları sende farklı olur; geç. await'ten sonra id "3" metni. Denemeden sonra iki console.log'u sil.
Aynı şeyi Next olmadan, düz Node'da da görebilirsin. C:\kod\ogren klasöründe ders43.js dosyasını aç ve yaz. paramsHazirla, Next'in yaptığını taklit ediyor: async bir fonksiyon her zaman bir Promise döndürür (Ders 15).
async function paramsHazirla() { return { id: "3" }} async function sayfa() { const params = paramsHazirla() console.log(params) console.log(params.id) const { id } = await params console.log(id, typeof id)} sayfa()Promise { { id: '3' } }undefined3 stringPromise'in kendisinde id diye bir alan yok; params.id bu yüzden undefined. Değer ancak await'ten sonra eline geçiyor. Birazdan bu undefined'ın Next'te nasıl bir hataya dönüştüğünü göreceksin.
Property 'id' does not exist on type 'Promise<{ id: string; }>'Ne zaman çıkar? params'ı await etmeden içinden id almaya çalışınca: eski örneklerden kalma bir alışkanlık.
export default async function SampiyonSayfasi({ params }: Props) { const id = params.id const sampiyon = sampiyonlar.find((s) => s.id === Number(id))Sayfa çökmez; her şampiyon için "Böyle bir şampiyon yok" der. Çünkü params.id undefined, Number(undefined) ise NaN ve hiçbir şampiyonun id'si NaN değil. Next terminale ne olduğunu açıkça yazar:
Error: Route "/sampiyonlar/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis at SampiyonSayfasi (app\sampiyonlar\[id]\page.tsx:9:23) 7 | 8 | export default async function SampiyonSayfasi({ params }: Props) {> 9 | const id = params.id | ^ 10 | const sampiyon = sampiyonlar.find((s) => s.id === Number(id)) 11 | 12 | if (!sampiyon) {"params bir Promise; özelliklerine erişmeden önce await ile açılmalı." Next bu mesajı aynı satır için sunucu açık kaldıkça bir kez yazar; sayfayı yenileyince tekrar görmezsen hata düzelmiş sanma, sayfa hâlâ "Böyle bir şampiyon yok" diyordur. Tip denetimi de aynı satırı yakalar. Next projesinde npx tsc --noEmit doğrudan çalışır (Vite'taki -b gerekmez; Next'in tsconfig.json'ı bütün dosyaları içerir):
PS C:\kod\next-rehber> npx tsc --noEmitapp/sampiyonlar/[id]/page.tsx:9:23 - error TS2339: Property 'id' does not exist on type 'Promise<{ id: string; }>'. 9 const id = params.id ~~ app/sampiyonlar/[id]/page.tsx:9:23 9 const id = params.id ~~ Did you forget to use 'await'? Found 1 error in app/sampiyonlar/[id]/page.tsx:9TypeScript ipucunu da veriyor: Did you forget to use 'await'?, yani "await kullanmayı mı unuttun?"
Nasıl düzelir? const { id } = await params. Fonksiyonun async olduğundan da emin ol; async olmayan fonksiyonda await yazılamaz.
This comparison appears to be unintentional because the types 'number' and 'string' have no overlap.Ne zaman çıkar? Adresten gelen id'yi sayıya çevirmeden dizideki sayı id'lerle karşılaştırınca: s.id === id.
PS C:\kod\next-rehber> npx tsc --noEmitapp/sampiyonlar/[id]/page.tsx:10:46 - error TS2367: This comparison appears to be unintentional because the types 'number' and 'string' have no overlap. 10 const sampiyon = sampiyonlar.find((s) => s.id === id) ~~~~~~~~~~~ Found 1 error in app/sampiyonlar/[id]/page.tsx:10"Bu karşılaştırma yanlışlıkla yazılmış gibi duruyor: number ile string'in ortak hiçbir değeri yok." === tip de karşılaştırır (Ders 2); 1 === "1" her zaman false. Sayfa yine çökmez, her adreste "Böyle bir şampiyon yok" der; yanlışı yalnız tip denetimi söyler.
Nasıl düzelir? s.id === Number(id). Ya da tersinden: String(s.id) === id.
İstemci bileşeni: "use client"
Şampiyon sayfasına bir Favorilere ekle düğmesi koymak istiyorsun: tıklayınca yıldız dolsun, yazı değişsin. Ders 41'den biliyorsun, bu iş useState ve onClick ister. app/sampiyonlar/[id] klasörüne FavoriDugmesi.tsx adında bir dosya ekle. Bilerek eksik yazıyoruz:
import { useState } from "react" export default function FavoriDugmesi({ isim }: { isim: string }) { const [favori, setFavori] = useState(false) return ( <button className={favori ? "favori secili" : "favori"} onClick={() => setFavori(!favori)}> {favori ? "★ " + isim + " favorilerinde" : "☆ Favorilere ekle"} </button> )}Sonra sayfada kullan: page.tsx'in başına import FavoriDugmesi from "./FavoriDugmesi" ekle ve tanıtım paragrafının altına <FavoriDugmesi isim={sampiyon.isim} /> yaz. Kaydedip bir şampiyon sayfası açınca:

You're importing a module that depends on `useState` into a React Server Component module.Ne zaman çıkar? useState, useEffect gibi bir hook kullanan bir bileşeni "use client" yazmadan bir sayfada kullanınca. Terminalde de aynısı:
⨯ ./app/sampiyonlar/[id]/FavoriDugmesi.tsx:1:10Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive. Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client> 1 | import { useState } from "react" | ^^^^^^^^ 2 | 3 | export default function FavoriDugmesi({ isim }: { isim: string }) { 4 | const [favori, setFavori] = useState(false) Ecmascript file had an error Import trace: Server Component: ./app/sampiyonlar/[id]/FavoriDugmesi.tsx ./app/sampiyonlar/[id]/page.tsxParça parça: "useState'e bağlı bir modülü bir React sunucu bileşeni modülüne aktarıyorsun. Bu API yalnız istemci bileşenlerinde var. Düzeltmek için dosyayı (ya da onu aktaran dosyayı) "use client" yönergesiyle işaretle." Import trace dosyanın oraya nereden geldiğini gösteriyor: page.tsx bir sunucu bileşeni ve FavoriDugmesi.tsx'i içe aktarıyor; işaret olmadığı için o da sunucu bileşeni sayıldı. Ardından aynı mesaj [browser] önekiyle bir kez daha yazılır: Next, tarayıcıdaki hataları da terminale taşıyor.
Nasıl düzelir? FavoriDugmesi.tsx'in en üst satırına, import'lardan önce "use client" yaz.
Mesajın dediğini yap. Dosyanın ilk satırına tırnaklarıyla birlikte şunu ekle:
"use client" import { useState } from "react" export default function FavoriDugmesi({ isim }: { isim: string }) { const [favori, setFavori] = useState(false) return ( <button className={favori ? "favori secili" : "favori"} onClick={() => setFavori(!favori)}> {favori ? "★ " + isim + " favorilerinde" : "☆ Favorilere ekle"} </button> )}
Hata katmanı kapandı, düğme çalışıyor. "use client" bir değişken ya da fonksiyon değil, bir yönerge: Next'e "bu dosya ve içe aktardığı her şey tarayıcıda da çalışacak" der. Next bu bileşenin kodunu tarayıcıya gönderilecek JavaScript'e ekler.
İlginç bir ayrıntı: istemci bileşeni de ilk HTML'de yer alır. Sayfa kaynağına bakarsan <button class="favori">☆ Favorilere ekle</button> satırını görürsün. Next onu sunucuda bir kez çizip HTML'e koydu; tarayıcıda JavaScript yüklenince React aynı düğmeyi bulup ona onClick'i bağladı. Bu "uyandırma" işinin adı canlandırma. Düğme canlanmadan önce görünür ama tıklamaya tepki vermez; hızlı bir bağlantıda bu an fark edilmeyecek kadar kısadır.
Hangisi ne zaman?
Kural kısa: önce sunucu bileşeni, yalnız gerektiğinde istemci. Gerektiği an şu: bileşen kullanıcıyla etkileşiyor ya da yalnız tarayıcıda olan bir şeyi kullanıyorsa.
"use client" ile istemci bileşeni olur; onu küçük tut ve sunucu bileşeninin içinde kullan.| Bileşen | Hangisi? | Neden |
|---|---|---|
Şampiyon listesi (map ile kartlar) | Sunucu | Yalnız veriyi gösteriyor. |
Şampiyon sayfası (params, find) | Sunucu | Veriyi okuyup gösteriyor; async. |
| Favori düğmesi | İstemci | useState ve onClick. |
| Arama kutusu | İstemci | Yazdıkça değişen state. |
| Hakkında yazısı | Sunucu | Hiç değişmiyor. |
İki alışkanlık edin:
- İstemci bileşenini küçük tut, "yaprağa" koy. Bütün şampiyon sayfasını
"use client"yapsaydın düğme yine çalışırdı, ama sayfanın bütün kodu tarayıcıya inerdi ve sayfaasyncolamazdı. Biz yalnız düğmeyi istemci yaptık; sayfa sunucuda kaldı ve düğmeye yalnız bir isim verdi. - Sunucudan istemciye düz veri ver.
isim="Ahri"gibi metin, sayı, obje, dizi geçer. Fonksiyon geçmez: sunucudaki bir fonksiyon tarayıcıya taşınamaz. Aşağıdaki hata tam bundan doğar.
Event handlers cannot be passed to Client Component props.Ne zaman çıkar? Bir sunucu bileşeninde bir öğeye onClick gibi bir olay işleyicisi verince. Örneğin şampiyon sayfasına doğrudan <button onClick={() => console.log("Paylaşıldı")}>Paylaş</button> yazınca. Sayfa 500 verir, terminalde:
⨯ Error: Event handlers cannot be passed to Client Component props. <button onClick={function onClick} children=...> ^^^^^^^^^^^^^^^^^^If you need interactivity, consider converting part of this to a Client Component. at ignore-listed frames { digest: '176785635'}"Olay işleyicileri istemci bileşeni props'larına verilemez. Etkileşim gerekiyorsa bir kısmını istemci bileşenine çevirmeyi düşün." <button> tarayıcıda canlanacak bir öğe; onClick'e verdiğin fonksiyon ise sunucuda yaşıyor ve oradan tarayıcıya gönderilemiyor. digest satırındaki sayı sende farklı olur.
Nasıl düzelir? Düğmeyi kendi dosyasına taşı, o dosyanın başına "use client" yaz ve sayfada bileşen olarak kullan. FavoriDugmesi tam olarak budur.
Metadata ve not-found
Sekme başlığı ve açıklama
Tarayıcı sekmesine bak: başlıkta site adı değil, adres yazıyor. Şablondaki metadata'yı layout'u sadeleştirirken silmiştin. Vite'taki bir React uygulamasında ilk HTML'deki başlık hep index.html'de ne yazıyorsa odur; sayfaya göre değiştirmek (örneğin useEffect içinde document.title'a değer atamak) ancak tarayıcıda, JavaScript çalışınca olur. Next'te bunu bir objeyle söylersin ve başlık ilk HTML'de gelir. app/layout.tsx'e ekle:
import type { Metadata } from "next"import Link from "next/link"import "./globals.css" export const metadata: Metadata = { title: { default: "Şampiyon Rehberi", template: "%s · Şampiyon Rehberi", }, description: "Şampiyonları tanı: rolleri, zorlukları ve kısa tanıtımları.",} export default function RootLayout({ children }: LayoutProps<"/">) {(Fonksiyonun gövdesi değişmedi; yalnız üst kısmı gösterdik.) metadata adında, Metadata tipinde bir obje dışa aktarıyorsun. Ad önemli: Next tam olarak bu adı arar.
title.default: sayfa kendi başlığını vermezse kullanılacak başlık.title.template: sayfa başlık verirse kalıp.%ssayfanın başlığıyla değişir.description: arama sonuçlarında başlığın altında görünen açıklama.
Sayfalara kendi başlıklarını ver. app/sampiyonlar/page.tsx'in başına:
import type { Metadata } from "next"import Link from "next/link"import { sampiyonlar } from "@/lib/sampiyonlar" export const metadata: Metadata = { title: "Şampiyonlar",}Sekmede artık Şampiyonlar · Şampiyon Rehberi yazıyor. Sayfa kaynağında:
<title>Şampiyonlar · Şampiyon Rehberi</title><meta name="description" content="Şampiyonları tanı: rolleri, zorlukları ve kısa tanıtımları."/>Açıklamayı sayfa vermediği için layout'unki geldi. Aynı şekilde app/hakkinda/page.tsx'e title: "Hakkında" ver; bu kez kendin yaz.
You are attempting to export "metadata" from a component marked with "use client"Ne zaman çıkar? "use client" yazan bir dosyadan metadata dışa aktarınca. Örneğin bir sayfanın tamamını istemci bileşeni yapıp ona başlık vermeye çalışınca.
⨯ ./app/hakkinda/page.tsx:5:14Error: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. "metadata" must be resolved on the server before the page component is rendered. Keep your page as a Server Component and move Client Component logic to a separate file. Read more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#why-generatemetadata-is-server-component-only 3 | import type { Metadata } from "next" 4 |> 5 | export const metadata: Metadata = { | ^^^^^^^^ 6 | title: "Hakkında", 7 | } 8 |"metadata'yı sayfa çizilmeden önce sunucuda çözmek gerekir. Sayfanı sunucu bileşeni olarak tut, istemci bileşeni mantığını ayrı bir dosyaya taşı."
Nasıl düzelir? Mesajın dediği gibi: sayfadan "use client"'ı kaldır; etkileşim gereken parçayı (FavoriDugmesi gibi) ayrı bir dosyaya al.
Dinamik sayfanın başlığı: generateMetadata
Şampiyon sayfasının başlığı her şampiyonda farklı olmalı: Ahri · Şampiyon Rehberi. Sabit bir obje bunu yapamaz; başlık params'a bağlı. Bunun için metadata yerine generateMetadata adında bir async fonksiyon dışa aktarırsın. Next onu sayfayla aynı params'la çağırır.
Ama önce bir tekrarı önle: şampiyonu bulan satır hem sayfada hem başlıkta gerekecek. Kendini tekrar etme: lib/sampiyonlar.ts'in sonuna bir fonksiyon ekle:
export function sampiyonBul(id: string) { return sampiyonlar.find((s) => s.id === Number(id))}Şimdi app/sampiyonlar/[id]/page.tsx'e geç. En üste import type { Metadata } from "next" ekle, sampiyonlar yerine yeni fonksiyonu içe aktar (import { sampiyonBul } from "@/lib/sampiyonlar") ve Props tipinin altına şunu yaz:
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params const sampiyon = sampiyonBul(id) return { title: sampiyon ? sampiyon.isim : "Bulunamadı" }}Sayfa fonksiyonundaki sampiyonlar.find(…) satırını da const sampiyon = sampiyonBul(id) yap; artık iki yer aynı fonksiyonu kullanıyor.
generateMetadatadaparams'ıawaiteder ve birMetadataobjesi döndürür. Dönüş tipiPromise<Metadata>, çünkü fonksiyonasync.- Şampiyon bulunamazsa başlık
Bulunamadıolur (Ders 40'taki üçlü ifade).
Ahri'nin sayfasını aç: sekmede Ahri · Şampiyon Rehberi yazıyor. Kalıp layout'tan geldi; sayfa yalnız %s'in yerine geçecek adı verdi.
not-found.tsx ve notFound()
Bir sorun daha var. /sampiyonlar/7'yi aç, F12 → Network'te ilk satırın durum koduna bak: 200. Terminal de öyle diyor:
GET /sampiyonlar/7 200 in 34ms (next.js: 1.7ms, application-code: 33ms)Sayfa "Böyle bir şampiyon yok" diyor, ama tarayıcıya ve arama motoruna "her şey yolunda, işte sayfa" diyor. Ders 26'dan biliyorsun: olmayan bir şey için doğru cevap 404. Next'te bunun yolu next/navigation'dan gelen notFound() fonksiyonu: çağırdığın an sayfanın çizimi durur, Next 404 durum koduyla bir "bulunamadı" sayfası gösterir.
Şampiyon sayfasının son hâli. Yeni olan yalnız notFound'un içe aktarılması ve if'in içi:
import type { Metadata } from "next"import Link from "next/link"import { notFound } from "next/navigation"import { sampiyonBul } from "@/lib/sampiyonlar"import FavoriDugmesi from "./FavoriDugmesi" type Props = { params: Promise<{ id: string }>} export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params const sampiyon = sampiyonBul(id) return { title: sampiyon ? sampiyon.isim : "Bulunamadı" }} export default async function SampiyonSayfasi({ params }: Props) { const { id } = await params const sampiyon = sampiyonBul(id) if (!sampiyon) { notFound() } return ( <main> <Link href="/sampiyonlar" className="geri">← Bütün şampiyonlar</Link> <h1>{sampiyon.isim}</h1> <p className="soluk">{sampiyon.rol} · Zorluk {sampiyon.zorluk}/3</p> <p>{sampiyon.tanitim}</p> <FavoriDugmesi isim={sampiyon.isim} /> </main> )}notFound()hiç geri dönmez; TypeScript bunu bilir. Bu yüzdenif'ten sonrasampiyonyine kesin olarak birSampiyon.- Eski "Böyle bir şampiyon yok" bloğu gitti; artık o işi
notFound()görüyor.
Şimdi /sampiyonlar/99 404 dönüyor ve Next'in İngilizce hazır sayfası açılıyor: 404 | This page could not be found. Kendi sayfanı yazmak için app klasörünün köküne not-found.tsx dosyasını ekle:
import Link from "next/link" export default function BulunamadiSayfasi() { return ( <main> <h1>Bu sayfa haritada yok</h1> <p className="soluk">Aradığın adres ya hiç olmadı ya da taşındı.</p> <Link href="/" className="dugme">Ana sayfaya dön</Link> </main> )}
Bu sayfa iki durumda çıkar: notFound() çağrıldığında ve hiçbir klasörün karşılamadığı bir adres açıldığında (/yok-boyle-bir-yer). Layout'un içinde çizildiği için menü yerinde; kullanıcı kaybolmaz.
Görseller ve yazı tipleri
next/image: kapak resmi
Ana sayfaya bir kapak resmi koyalım. Şu resmi kullanacaksın (kendi çizimimiz). Üstüne sağ tıkla, Resmi farklı kaydet…'i seç (İngilizce tarayıcıda Save image as…) ve C:\kod\next-rehber\public klasörüne kapak.png adıyla kaydet:
Resim 1200 piksel genişliğinde, 400 piksel yüksekliğinde. Düz <img src="/kapak.png"> yazabilirdin, ama Next'in Image bileşeni birkaç işi senin yerine yapar. app/page.tsx'i şöyle değiştir:
import Image from "next/image"import Link from "next/link" export default function Home() { return ( <main> <Image src="/kapak.png" alt="Üç koridorlu küçük bir harita ve yan yana üç şampiyon kartı" width={1200} height={400} className="kapak" loading="eager" /> <h1>Şampiyon Rehberi</h1> <p className="soluk">Hangi şampiyonla başlasan? Rollerine ve zorluklarına bak, sana uyanı seç.</p> <Link href="/sampiyonlar" className="dugme">Şampiyonlara göz at</Link> </main> )}
src="/kapak.png":publicklasörü sitenin köküdür;public/kapak.pngadreste/kapak.pngolur. Adresepublicyazılmaz.alt: resmi göremeyen biri (ekran okuyucu kullanan, resmi yüklenemeyen) için resmin ne olduğu. Zorunlu.widthveheight: resmin gerçek piksel boyutu. Next bunlardan en-boy oranını hesaplar ve resim inmeden önce sayfada yerini ayırır; resim gelince yazılar aşağı kaymaz. Ekrandaki boyutu CSS verir:.kapaksınıfıwidth: 100%; height: autodiyor, resim kutusuna sığıyor.loading="eager": "hemen yükle".Imagevarsayılan olarak resmi ancak ekrana yaklaşınca yükler (Ders 24'teki geç yükleme); sayfa açılır açılmaz görünen büyük bir resim için bu beklemeyi kaldırıyoruz.
Peki Image arkada ne yaptı? Sayfa kaynağına bak:
<img alt="Üç koridorlu küçük bir harita ve yan yana üç şampiyon kartı" loading="eager" width="1200" height="400" decoding="async" data-nimg="1" class="kapak" style="color:transparent" srcSet="/_next/image?url=%2Fkapak.png&w=1200&q=75 1x, /_next/image?url=%2Fkapak.png&w=3840&q=75 2x" src="/_next/image?url=%2Fkapak.png&w=3840&q=75"/>Tarayıcı resmi artık doğrudan /kapak.png'den değil, Next'in resim servisinden (/_next/image) istiyor. srcSet iki adres veriyor: sıradan ekranlar 1x'i, piksel yoğunluğu yüksek ekranlar (çoğu telefon) 2x'i seçer. Servis resmi istenen genişliğe (w) göre hazırlıyor, kalitesini (q=75) ayarlıyor ve tarayıcı destekliyorsa daha küçük bir biçime, WebP'ye çeviriyor. Biz ölçtük: kapak.png 21.114 bayt, servisin verdiği 1200 piksellik WebP hâli 8.132 bayt; aynı resim, üçte birinden biraz fazla yer. Resim büyüdükçe (bir fotoğraf gibi) kazanç da büyür.
Image with src "/kapak.png" is missing required "width" property.Ne zaman çıkar? Image'e width ve height vermeyince. Sayfa 500 verir, terminalde:
⨯ Error: Image with src "/kapak.png" is missing required "width" property. at ignore-listed frames { digest: '2684950454'}"/kapak.png kaynaklı resimde zorunlu width niteliği eksik." Bu hatayı npx tsc --noEmit yakalamaz; tipe göre width isteğe bağlı, çünkü kutusunu dolduran resimler için (fill) başka bir yol var.
Nasıl düzelir? Resmin gerçek boyutlarını ver: width={1200} height={400}. Bilmiyorsan dosyaya Windows'ta sağ tıkla → Özellikler → Ayrıntılar sekmesi (İngilizce Windows'ta Properties → Details): orada Genişlik ve Yükseklik (Width, Height) yazar.
Başka siteden resim: remotePatterns
Kartlardaki harfli yuvarlakların yerine şampiyonların gerçek resimlerini koyalım. Ders 15'te tanıdığın Data Dragon, her şampiyonun kare resmini herkese açık bir adreste yayımlıyor. lib/sampiyonlar.ts'e adresi kuran bir fonksiyon ekle:
export function ikonAdresi(isim: string) { return `https://ddragon.leagueoflegends.com/cdn/16.20.1/img/champion/${isim}.png`}16.20.1 bu dersi yazarken oyunun son sürümüydü; güncelini https://ddragon.leagueoflegends.com/api/versions.json adresindeki listenin ilk elemanından öğrenirsin. Sonra app/sampiyonlar/page.tsx'te harfli div'in yerine bir Image koy:
<Link key={s.id} href={`/sampiyonlar/${s.id}`} className="kart"> <Image src={ikonAdresi(s.isim)} alt="" width={56} height={56} className="ikon" /> <h2>{s.isim}</h2>(Dosyanın başına import Image from "next/image" eklemeyi ve ikonAdresi'ni de içe aktarmayı unutma: import { ikonAdresi, sampiyonlar } from "@/lib/sampiyonlar".) Burada alt="" boş, çünkü resmin hemen altında şampiyonun adı yazıyor; ekran okuyucu adı iki kez okumasın. Kaydet:
hostname "ddragon.leagueoflegends.com" is not configured under images in your `next.config.js` Ne zaman çıkar? Image'e başka bir siteden resim verip o siteyi next.config.ts'te tanıtmayınca.
⨯ Error: Invalid src prop (https://ddragon.leagueoflegends.com/cdn/16.20.1/img/champion/Ahri.png) on `next/image`, hostname "ddragon.leagueoflegends.com" is not configured under images in your `next.config.js`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host at ignore-listed frames { digest: '359635635'}"next/image'e verilen src geçersiz: ddragon.leagueoflegends.com sunucusu next.config.js'te images altında tanımlı değil." Mesaj next.config.js diyor; senin dosyan .ts, aynı şey. Next'in resim servisi her resmi indirip küçültür; herkes senin sunucunu bedava resim küçültücü olarak kullanamasın diye yalnız izin verdiğin sitelerin resimlerine dokunur.
Nasıl düzelir? next.config.ts'e izin listesini ekle (aşağıda).
next.config.ts'i aç ve images bölümünü ekle. Şablonun dosyası noktalı virgüllü ve iki boşluk girintili; biz de ona uyduk:
import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ images: { remotePatterns: [new URL("https://ddragon.leagueoflegends.com/cdn/**")], }, turbopack: { rules: { "*.css": { loaders: ["@tailwindcss/turbopack"], as: "*.css", }, }, },}; export default nextConfig;remotePatterns "şu kalıba uyan adreslere izin ver" demek; sondaki ** "/cdn/'den sonra ne gelirse". İzni dar tut: bütün siteye değil, yalnız resimlerin olduğu yola. Kaydettiğin anda terminal şunu yazar ve sunucu kendini yeniden başlatır:
⚠ Found a change in next.config.ts. Restarting the server to apply the changes...
Şampiyon sayfasına da büyük bir resim koy. app/sampiyonlar/[id]/page.tsx'te import Image from "next/image" ekle, sampiyonBul'un yanına ikonAdresi'ni de içe aktar ve geri bağlantısının altına şunu yaz:
<Image src={ikonAdresi(sampiyon.isim)} alt={sampiyon.isim} width={96} height={96} className="ikon" loading="eager" />loading="eager"'i yazmasaydın tarayıcı konsolu seni uyarırdı. Önce onsuz denedik:
Image with src "https://ddragon.leagueoflegends.com/cdn/16.20.1/img/champion/Ahri.png" was detected as the Largest Contentful Paint (LCP). Please add the `loading="eager"` property if this image is above the fold."Bu resim, sayfanın en büyük görünen parçası (Largest Contentful Paint, kullanıcının sayfanın açıldığını hissettiği an) olarak algılandı. Sayfanın ilk ekranındaysa loading="eager" ekle." Next, sayfanın ilk ekranındaki büyük resmin geç yüklenip açılışı yavaşlattığını fark etti. Kural: sayfa açılınca hemen görünen ana resme eager, aşağıda kalanlar varsayılanda (lazy) kalsın.
next/font: yazı tipi
Son olarak yazı tipi. Şablon Geist adında bir Google yazı tipi kullanıyordu; biz yuvarlak hatlı, Türkçe harfleri olan Nunito'yu seçiyoruz. app/layout.tsx'in son hâli:
import type { Metadata } from "next"import { Nunito } from "next/font/google"import Link from "next/link"import "./globals.css" const nunito = Nunito({ subsets: ["latin", "latin-ext"],}) export const metadata: Metadata = { title: { default: "Şampiyon Rehberi", template: "%s · Şampiyon Rehberi", }, description: "Şampiyonları tanı: rolleri, zorlukları ve kısa tanıtımları.",} export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="tr"> <body className={nunito.className}> <header className="ust"> <Link href="/" className="logo">Şampiyon Rehberi</Link> <nav> <Link href="/sampiyonlar">Şampiyonlar</Link> <Link href="/hakkinda">Hakkında</Link> </nav> </header> {children} </body> </html> )}import { Nunito } from "next/font/google": Google Fonts'taki her yazı tipi burada bir fonksiyon. Adında boşluk olanlar alt çizgiyle yazılır (şablondakiGeist_Monogibi).Nunito({ subsets: [...] }): yazı tipini hazırlar.subsets, hangi harf gruplarının sayfa açılırken önceden yükleneceği.latinİngilizce harfleri veı'yı içerir;ş,ğveİiselatin-ext'te. İkisini birden yazınca Türkçe metin ilk anda doğru yazı tipiyle çıkar.- Kalınlık (
weight) yazmadık, çünkü Nunito bütün kalınlıkları tek dosyada taşıyan bir değişken yazı tipi (variable font). Yalnız bir ya da birkaç kalınlığı olan yazı tiplerinde hangisini istediğiniweight: "400"gibi yazman gerekir; yazmazsan Next hata verir. className={nunito.className}: Next'in ürettiği sınıf;<body>'ye verince bütün site bu yazı tipini kullanır. Sınıfı tek bir öğeye de verebilirsin; o zaman yalnız o öğe bu yazı tipiyle yazılır.
Burada Next görünmeyen iki iş yapıyor. Birincisi, yazı tipi dosyalarını Google'dan bir kez indirip kendi sitenden sunuyor: tarayıcı Google'a hiç gitmiyor. Biz sayfanın isteklerine baktık: yazı tipleri /_next/static/media/…woff2 adreslerinden geliyordu. Ziyaretçinin bilgisi üçüncü bir siteye gitmez, tarayıcı da yazı tipi için ayrı bir sunucuya bağlanmakla vakit kaybetmez. İkincisi, yazı tipi inene kadar gösterilen yedek yazı tipini (Arial) Nunito'nun ölçülerine göre ayarlıyor; yazı tipi gelince satırlar zıplamıyor.
Sık hatalar
Bu derste bölümlerin içinde gördüğün kartlara ek olarak iki hata daha. Hepsine hata rehberinden de ulaşabilirsin.
Another next dev server is already running.Ne zaman çıkar? Aynı proje klasöründe ikinci bir geliştirme sunucusu açmaya çalışınca. Çoğu zaman öteki terminalde unuttuğun bir npm run dev açıktır. Sunucu 3000'de açıkken ikinci terminalde npx next dev -p 3001 dediğimizde:
PS C:\kod\next-rehber> npx next dev -p 3001▲ Next.js 16.4.0 (Turbopack)- Local: http://localhost:3001- Network: http://192.168.1.35:3001✓ Ready in 546ms✓ Running next.config.ts took 45ms⨯ Another next dev server is already running. - Local: http://localhost:3000- PID: 32364- Dir: C:\kod\next-rehber- Log: .next\dev\logs\next-development.log You can access the existing server at http://localhost:3000,or run taskkill /PID 32364 /F to stop it and start a new one."Başka bir next dev sunucusu zaten çalışıyor." Next açık olanın adresini, süreç numarasını (PID) ve klasörünü yazıyor, ikinciyi de kapatıyor.
Nasıl düzelir? Açık olanı kullan (http://localhost:3000). Yeniden başlatmak istiyorsan önce onun terminalinde Ctrl+C'ye bas. Terminalini bulamıyorsan mesajın önerdiği taskkill /PID … /F komutunu, mesajda yazan numarayla çalıştır: o süreç senin unuttuğun sunucudur.
'`' can be escaped with `'` Ne zaman çıkar? JSX içindeki düz metne kesme işareti (') yazıp npm run lint çalıştırınca. Türkçede çok sık: Teemo'nun, Ahri'nin. Örneğin görevlerdeki yama notları sayfasına şu satırı yazınca:
<p>Garen biraz güçlendi, Teemo'nun mantarları biraz zayıfladı.</p>Sayfa sorunsuz görünür; itiraz eden ESLint:
PS C:\kod\next-rehber> npm run lint > next-rehber@0.1.0 lint> eslint C:\kod\next-rehber\app\yama-notlari\page.tsx 11:43 error `'` can be escaped with `'`, `‘`, `'`, `’` react/no-unescaped-entities ✖ 1 problem (1 error, 0 warnings)"' işareti ' gibi bir kaçış koduyla yazılabilir." Kural (react/no-unescaped-entities), JSX metnindeki tırnakların yanlışlıkla bir niteliğin tırnağıyla karışmasını önlemek için var.
Nasıl düzelir? En okunaklı yol metni süslü parantez içinde bir JavaScript metni olarak yazmak: <p>{"Teemo'nun mantarları biraz zayıfladı."}</p>. Kesme işaretinin yerine tipografik kesme ’ (Teemo’nun) yazmak da kuralı susturur.
Görevler
Görevleri aynı next-rehber projesinde yap. Her görevden sonra tarayıcıda dene, en sonda bir kez npx tsc --noEmit ve npm run lint çalıştır; ikisi de hata yazmamalı. Önce kendin dene, takılırsan ipuçlarını sırayla aç.
Yama notları sayfası
/yama-notlari adresinde açılan bir sayfa yap. Başlığı "Yama Notları" olsun, altında iki cümle yazsın (ne yazacağın sana kalmış). Sekme başlığı Yama Notları · Şampiyon Rehberi olmalı. Menüye de sayfayı açan bir bağlantı ekle; tıklayınca sayfa baştan yüklenmemeli.
Sınırı da dene: /yama_notlari (alt çizgiyle) 404 vermeli.
İpucu 1
Adres /yama-notlari ise klasör app/yama-notlari, dosya page.tsx. Sekme başlığı için metadata objesine yalnız title: "Yama Notları" yazman yeter; gerisini layout'taki kalıp ekler.
İpucu 2
Menüdeki bağlantı app/layout.tsx'te, öteki Link'lerin yanına: <Link href="/yama-notlari">Yama Notları</Link>.
Çözümü göster
import type { Metadata } from "next" export const metadata: Metadata = { title: "Yama Notları",} export default function YamaNotlari() { return ( <main> <h1>Yama Notları</h1> <p>{"Garen biraz güçlendi, Teemo'nun mantarları biraz zayıfladı."}</p> <p className="soluk">Bu sayfa her yamada güncellenecek.</p> </main> )}app/layout.tsx'teki menüye <Link href="/yama-notlari">Yama Notları</Link> satırını ekle. Denediğimizde /yama-notlari 200 ve Yama Notları · Şampiyon Rehberi başlığıyla, /yama_notlari ise 404 ve "Bu sayfa haritada yok" sayfasıyla açıldı. İlk paragraftaki süslü parantez, kesme işareti yüzünden ESLint'in itirazını önlemek için (Sık hatalar).
Eşya listesi
Dükkândaki eşyaları gösteren /esyalar sayfasını yap. Veri için lib/esyalar.ts dosyasını oluştur (veriyi kopyalayabilirsin):
export type Esya = { id: number ad: string fiyat: number aciklama: string} export const esyalar: Esya[] = [ { id: 1, ad: "Uzun Kılıç", fiyat: 250, aciklama: "Saldırı gücünü biraz artırır." }, { id: 2, ad: "Yakut Kristali", fiyat: 400, aciklama: "Can puanını artırır." }, { id: 3, ad: "Ağır Zırh", fiyat: 1100, aciklama: "Fiziksel hasara karşı korur." },]Sayfada başlık, Dükkânda 3 eşya var. cümlesi (sayıyı elle yazma) ve her eşya için adı ile fiyatını gösteren, /esyalar/1 gibi adreslere giden bir kart olsun. Sekme başlığı Eşyalar · Şampiyon Rehberi. Sayfanın içine console.log("Eşya listesi hazırlanıyor") koy ve mesajın nereye düştüğünü yaz.
İpucu 1
Şampiyon listesiyle aynı kalıp: esyalar.map((e) => <Link key={e.id} href={...} className="kart">…</Link>). İçe aktarmayı @/lib/esyalar ile yap.
İpucu 2
Fiyat bir sayı; metinle yan yana koymak için {e.fiyat} altın yazman yeter.
Çözümü göster
import type { Metadata } from "next"import Link from "next/link"import { esyalar } from "@/lib/esyalar" export const metadata: Metadata = { title: "Eşyalar",} export default function EsyaListesi() { console.log("Eşya listesi hazırlanıyor") return ( <main> <h1>Eşyalar</h1> <p className="soluk">Dükkânda {esyalar.length} eşya var.</p> <div className="kartlar"> {esyalar.map((e) => ( <Link key={e.id} href={`/esyalar/${e.id}`} className="kart"> <h2>{e.ad}</h2> <p>{e.fiyat} altın</p> </Link> ))} </div> </main> )}Mesaj, npm run dev'in terminaline düşer: bu bir sunucu bileşeni. (Geliştirirken tarayıcı konsolunda Server etiketli bir kopyasını da görürsün.) Kartlar istediğin gibi değilse sorun büyük ihtimalle key ya da href'in ters tırnağındadır. Kartlara tıklayınca şimdilik 404 alırsın; o sayfaları bir sonraki görevde yapacaksın.
Eşya sayfası
Her eşyanın kendi sayfası olsun: /esyalar/1, /esyalar/2, /esyalar/3. Sayfada eşyanın adı, fiyatı, açıklaması ve eşya listesine dönen bir bağlantı olsun. Sekme başlığı eşyanın adını taşısın (Uzun Kılıç · Şampiyon Rehberi).
Sınır değerleri dene: /esyalar/3 (son eşya) açılmalı; /esyalar/0, /esyalar/4 (sonuncudan bir fazla) ve /esyalar/abc 404 dönmeli ve senin "Bu sayfa haritada yok" sayfan çıkmalı. Durum kodunu F12 → Network'te ya da terminaldeki GET satırında doğrula.
İpucu 1
Klasör app/esyalar/[id], dosya page.tsx. params'ın tipi Promise<{ id: string }>; fonksiyon async olmalı ve id'yi await ile almalısın.
İpucu 2
Eşyayı bulan satır hem sayfada hem generateMetadata'da gerekecek. Bir esyaBul(id: string) fonksiyonu yaz. Bulamazsan sayfada notFound() çağır (next/navigation'dan).
İpucu 3
/esyalar/abc için Number("abc") NaN olur; NaN hiçbir sayıya eşit değildir, find undefined döner ve notFound() çalışır. Ayrıca bir şey yapmana gerek yok.
Çözümü göster
import type { Metadata } from "next"import Link from "next/link"import { notFound } from "next/navigation"import { esyalar } from "@/lib/esyalar" type Props = { params: Promise<{ id: string }>} function esyaBul(id: string) { return esyalar.find((e) => e.id === Number(id))} export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params const esya = esyaBul(id) return { title: esya ? esya.ad : "Bulunamadı" }} export default async function EsyaSayfasi({ params }: Props) { const { id } = await params const esya = esyaBul(id) if (!esya) { notFound() } return ( <main> <Link href="/esyalar" className="geri">← Bütün eşyalar</Link> <h1>{esya.ad}</h1> <p className="soluk">{esya.fiyat} altın</p> <p>{esya.aciklama}</p> </main> )}Denediğimizde: /esyalar/1 → 200, Uzun Kılıç · Şampiyon Rehberi; /esyalar/3 → 200, Ağır Zırh · …; /esyalar/0, /esyalar/4 ve /esyalar/abc → 404, Bulunamadı · Şampiyon Rehberi. esyaBul'u bu dosyada tuttuk, çünkü yalnız burada kullanılıyor; lib/esyalar.ts'e taşımak da doğru olur.
Satın al düğmesi
Eşya sayfasına bir Satın al düğmesi ekle. Oyuncunun 1000 altını var. Düğmenin üstünde Altının: 1000 · Aldığın: 0 yazsın. Tıklayınca, altın yetiyorsa eşyanın fiyatı düşsün ve aldığın sayısı bir artsın. Altın yetmiyorsa düğmede Altın yetmiyor yazsın ve düğme tıklanamasın (disabled).
Sınırlar (hepsini dene):
- Uzun Kılıç (250): dört kez alınabilmeli; dördüncüden sonra
Altının: 0 · Aldığın: 4veAltın yetmiyor. Altın tam fiyata eşitken alabilmelisin. - Yakut Kristali (400): iki kez; sonra
Altının: 200veAltın yetmiyor. - Ağır Zırh (1100): sayfa açılır açılmaz
Altın yetmiyor.
İpucu 1
Düğme useState ve onClick istiyor, sayfa ise sunucu bileşeni ve öyle kalmalı. Düğmeyi app/esyalar/[id]/SatinAlDugmesi.tsx adında ayrı bir dosyaya yaz; ilk satırı ne olmalı?
İpucu 2
Sayfa düğmeye yalnız fiyatı versin: <SatinAlDugmesi fiyat={esya.fiyat} />. Düğmede iki state yeter: altin (1000) ve adet (0).
İpucu 3
"Yetiyor mu?" sorusu altin >= fiyat. > yazarsan altın tam fiyata eşitken alamazsın: sınır hatası. disabled={altin < fiyat} düğmeyi kapatır.
Çözümü göster
"use client" import { useState } from "react" export default function SatinAlDugmesi({ fiyat }: { fiyat: number }) { const [altin, setAltin] = useState(1000) const [adet, setAdet] = useState(0) function satinAl() { if (altin >= fiyat) { setAltin(altin - fiyat) setAdet(adet + 1) } } return ( <div> <p>Altının: {altin} · Aldığın: {adet}</p> <button className="favori" onClick={satinAl} disabled={altin < fiyat}> {altin >= fiyat ? "Satın al (" + fiyat + " altın)" : "Altın yetmiyor"} </button> </div> )}Sayfada import SatinAlDugmesi from "./SatinAlDugmesi" ekle ve açıklama paragrafının altına <SatinAlDugmesi fiyat={esya.fiyat} /> yaz. Denediğimizde Uzun Kılıç'ta sırasıyla 1000 · 0, 750 · 1, 500 · 2, 250 · 3, 0 · 4 göründü ve son durumda düğme Altın yetmiyor diyerek kapandı. Yakut Kristali 1000 → 600 → 200'de, Ağır Zırh ilk anda durdu. Başka bir eşyanın sayfasına geçince altın yeniden 1000 olur; state o sayfanın düğmesine ait. Bütün sitede ortak bir altın, state'i yukarı taşımayı ya da bir veritabanını gerektirir.
Yama notlarına harita ve yazı tipi
Görev 1'deki yama notları sayfasını süsle:
- Başlığın üstünde Sihirdar Vadisi'nin mini haritası dursun. Resim Data Dragon'da:
https://ddragon.leagueoflegends.com/cdn/16.20.1/img/map/map11.png. Gerçek boyutu 512×512 piksel, ama ekranda 240 piksel genişlikte görünmeli;globals.css'teki.haritasınıfı hazır. - Yalnız başlık (
h1) Google Fonts'taki Russo One yazı tipiyle yazılsın; sayfanın geri kalanı Nunito'da kalsın.
Sayfa açılınca tarayıcı konsolunda (F12 → Console) LCP uyarısı olmamalı. Başlamadan düşün: next.config.ts'e dokunman gerekiyor mu?
Sınırı da dene: başlığı geçici olarak Ağır Zırh İndirimi Şimdi Başladı yap. ğ, ı, İ, ş, Ş dahil bütün harfler aynı kalın yazı tipiyle çıkmalı. Sonra kodda Russo_One'ı Lilita_One yap (onun da tek kalınlığı var: 400) ve aynı başlığa yeniden bak. Ne değişti?
İpucu 1
Resme width={512} height={512} ver: gerçek boyut. Ekrandaki boyutu className="harita" belirler. Resim sayfanın en üstünde, ilk ekranda; LCP uyarısını hangi nitelik susturuyordu? next.config.ts'e dokunmana gerek yok: remotePatterns'e yazdığın /cdn/** kalıbı /cdn/16.20.1/img/map/… yolunu da kapsıyor.
İpucu 2
Yazı tipini layout'taki Nunito gibi hazırla, ama bu sayfanın dosyasında: import { Russo_One } from "next/font/google" (adındaki boşluk alt çizgi oluyor). Sınıfı <body>'ye değil, yalnız başlığa ver: <h1 className={russo.className}>.
İpucu 3
Russo One değişken bir yazı tipi değil, tek kalınlığı var. weight yazmazsan VS Code Russo_One({…})'un altını çizer (Property 'weight' is missing…) ve sayfa açılmaz; terminalde Missing weight for Russo One. Available weights: 400 yazar. Doğrusu: Russo_One({ weight: "400", subsets: ["latin", "latin-ext"] }).
Çözümü göster
import type { Metadata } from "next"import { Russo_One } from "next/font/google"import Image from "next/image" const russo = Russo_One({ weight: "400", subsets: ["latin", "latin-ext"],}) export const metadata: Metadata = { title: "Yama Notları",} export default function YamaNotlari() { return ( <main> <Image src="https://ddragon.leagueoflegends.com/cdn/16.20.1/img/map/map11.png" alt="Sihirdar Vadisi'nin mini haritası" width={512} height={512} className="harita" loading="eager" /> <h1 className={russo.className}>Yama Notları</h1> <p>{"Garen biraz güçlendi, Teemo'nun mantarları biraz zayıfladı."}</p> <p className="soluk">Bu sayfa her yamada güncellenecek.</p> </main> )}Denediğimizde harita 240 piksel genişlikte çıktı; loading="eager" silinince konsolda Image with src "https://ddragon.leagueoflegends.com/cdn/16.20.1/img/map/map11.png" was detected as the Largest Contentful Paint (LCP) uyarısı belirdi, geri yazınca kayboldu. alt niteliğindeki kesme işareti ESLint'e takılmaz; kural yalnız etiketlerin arasındaki metne bakar.
Sınır denemesinde Russo One bütün harfleri çizdi. Lilita One ise ı'yı çizdi ama ğ, İ, Ş ve ş'yi ince, başka bir yazı tipiyle gösterdi: Lilita One'ın çiziminde bu harfler yok, tarayıcı onları yedek yazı tipinden aldı. subsets'e latin-ext yazmak bunu çözmez; yazı tipini seçmeden önce Türkçe harflerini dene. Denemeden sonra Russo One'a dön.

Menüde bulunduğun sayfa
Menüde hangi sayfadaysan o bağlantı beyaz görünsün (CSS'te .ust nav a.aktif hazır). Kurallar:
/sampiyonlar'da da/sampiyonlar/3'te de Şampiyonlar aktif olsun;/esyalar/2'de Eşyalar.- Ana sayfada (
/) hiçbir menü bağlantısı aktif olmasın. /sampiyonlar-eskigibi olmayan bir adreste (404 sayfası da menüyü gösterir) hiçbiri aktif olmasın.- Bağlantıya tıklayınca, sayfa yenilenmeden, aktif bağlantı da değişsin.
Menüdeki bağlantılar: Şampiyonlar, Eşyalar, Yama Notları, Hakkında.
İpucu 1
Şu anki adresi Next'in usePathname kancası verir: import { usePathname } from "next/navigation", sonra const yol = usePathname(). Adı use ile başlıyor; Ders 41'deki kural geçerli, yalnız istemci bileşeninde çalışır. Menüyü app/Menu.tsx adında bir istemci bileşenine taşı ve layout'ta <nav>…</nav>'ın yerine <Menu /> yaz.
İpucu 2
Bağlantıları bir dizide tut ({ href: "/sampiyonlar", ad: "Şampiyonlar" } gibi) ve map ile çiz. Her bağlantı için aktif diye bir boolean hesapla, className={aktif ? "aktif" : ""} ver.
İpucu 3
yol.startsWith(b.href) ilk bakışta yeter gibi görünür; /sampiyonlar-eski'de ne olur? Bağlantının adresine / ekleyerek bak: yol === b.href || yol.startsWith(b.href + "/").
Çözümü göster
"use client" import Link from "next/link"import { usePathname } from "next/navigation" const baglantilar = [ { href: "/sampiyonlar", ad: "Şampiyonlar" }, { href: "/esyalar", ad: "Eşyalar" }, { href: "/yama-notlari", ad: "Yama Notları" }, { href: "/hakkinda", ad: "Hakkında" },] export default function Menu() { const yol = usePathname() return ( <nav> {baglantilar.map((b) => { const aktif = yol === b.href || yol.startsWith(b.href + "/") return ( <Link key={b.href} href={b.href} className={aktif ? "aktif" : ""}> {b.ad} </Link> ) })} </nav> )}app/layout.tsx'te import Menu from "./Menu" ekle ve <nav>…</nav> bloğunun yerine <Menu /> yaz. Layout sunucu bileşeni olarak kalır; yalnız menü istemci. app/Menu.tsx bir adres olmaz, çünkü adı page.tsx değil.
Denediğimizde aktif bağlantı /'da yoktu, /sampiyonlar ve /sampiyonlar/3'te Şampiyonlar, /esyalar/2'de Eşyalar, /sampiyonlar-eski'de (404) yoktu; ana sayfadan Eşyalar'a tıklayınca da Eşyalar'a geçti. Yalnız yol.startsWith(b.href) yazdığımızda ise /sampiyonlar-eski'de Şampiyonlar yanlışlıkla aktif oldu: "/sampiyonlar-eski" metni "/sampiyonlar" ile başlıyor. Sondaki / bu yüzden var. Ana sayfa menüde olmadığı için / özel bir durum yaratmıyor; olsaydı her adres / ile başladığından onu yol === "/" ile ayrıca denetlemen gerekirdi.
Kendini sına
Soru 1
Bu klasör ağacında hangi adresler var?
app/├─ page.tsx├─ layout.tsx├─ esyalar/│ ├─ page.tsx│ ├─ EsyaKarti.tsx│ └─ [id]/│ └─ page.tsx└─ profil/ └─ ayarlar/ └─ page.tsxCevabı göster
/, /esyalar, /esyalar/1 gibi her id için bir adres ve /profil/ayarlar. EsyaKarti.tsx adres değil, çünkü adı page.tsx değil. /profil adresinde sayfa yok: app/profil klasöründe page.tsx olmadığı için orası 404 verir, ama klasör /profil/ayarlar'a yol olur. layout.tsx de adres değil; bütün sayfaların çerçevesi.
Soru 2
Sunucu mu, istemci mi? (a) Veritabanından son maçları okuyup listeleyen bileşen. (b) Yazdıkça şampiyonları süzen arama kutusu. (c) Sayfanın altındaki telif yazısı. (d) Tıklayınca açılıp kapanan bir menü.
Cevabı göster
(a) Sunucu: veriyi sunucuda okur, async olabilir, veritabanı bilgisi tarayıcıya gitmez. (b) İstemci: yazdıkça değişen state ve onChange ister. (c) Sunucu: hiç değişmeyen bir yazı. (d) İstemci: useState ve onClick. Kural: varsayılan sunucu; etkileşim gerekiyorsa o parçayı küçük bir istemci bileşenine ayır.
Soru 3
Bu sayfada ne ters gider? Kim haber verir?
export default async function Sayfa({ params }: { params: Promise<{ id: string }> }) { const { id } = params return <h1>Eşya {id}</h1>}Cevabı göster
params bir Promise; await edilmeden içinden id alınıyor. id undefined olur ve sayfa Eşya yazıp gerisini boş bırakır. Next geliştirme sunucusunun terminaline (sunucu açık kaldıkça bir kez) `params` is a Promise and must be unwrapped with `await` diye yazar; npx tsc --noEmit de Property 'id' does not exist on type 'Promise<{ id: string; }>' der. Düzeltme: const { id } = await params.
Soru 4
Menüde <a href="/esyalar"> ile <Link href="/esyalar"> arasındaki fark ne? Hangi durumda düz <a> doğrudur?
Cevabı göster
<a> tıklanınca tarayıcı sayfayı baştan yükler: HTML, CSS, bütün JavaScript yeniden iner, sayfadaki state sıfırlanır. Link yalnız yeni sayfanın içeriğini ister ve layout'u olduğu gibi bırakır; yayındaki sitede görünen bağlantıların sayfalarını önceden de hazırlar. İkisi de HTML'de <a> olarak çıkar. Düz <a> başka bir siteye giden bağlantılar için doğrudur: <a href="https://nextjs.org">.
Soru 5
Bulunamayan bir şampiyon için return <h1>Böyle bir şampiyon yok</h1> yazmak ile notFound() çağırmak arasındaki fark ne? Ekranda ikisi de "bulunamadı" diyorsa neden önemli?
Cevabı göster
İlki sayfayı 200 durum koduyla gönderir: tarayıcıya ve arama motoruna "her şey yolunda" der. Google o adresi gerçek bir sayfa sanıp dizine ekleyebilir, sitenin istatistikleri hatayı göremez. notFound() ise çizimi durdurur, 404 döndürür ve not-found.tsx'i gösterir. Ekrandaki yazı aynı olsa da makinelere söylenen farklı (Ders 26).
Soru 6
<Image src="https://ornek.com/harita.png" alt="Harita" width={800} height={600} /> sayfayı çökertiyor. Mesajda ne yazar, nasıl düzeltirsin?
Cevabı göster
hostname "ornek.com" is not configured under images in your next.config.js. Next'in resim servisi yalnız izin verilen sitelerin resimlerini işler. next.config.ts'te images: { remotePatterns: [new URL("https://ornek.com/**")] } ekle; mümkünse izni resimlerin olduğu yolla daralt. Ayar dosyası değişince sunucu kendini yeniden başlatır.
Soru 7
Bir arkadaşın tıklanınca açılan bir SSS listesi için sayfanın tamamını "use client" yapmış, sonra export const metadata ekleyince hata almış. Neden? Ne önerirsin?
Cevabı göster
metadata sayfa çizilmeden önce sunucuda okunur; "use client" yazan dosyadan dışa aktarılamaz (You are attempting to export "metadata" from a component marked with "use client"). Öneri: sayfayı sunucu bileşeni olarak bırak, metadata orada kalsın; açılıp kapanan kısmı ayrı bir dosyaya (SssMaddesi.tsx gibi) al ve yalnız ona "use client" yaz. Bonus: sayfanın geri kalanının kodu tarayıcıya inmez.
Yayın derlemesi ve kayıt noktası
Commit'ten önce sitenin yayına hazır hâlini bir kez derle. Geliştirme sunucusunu Ctrl+C ile durdur ve:
PS C:\kod\next-rehber> npm run build > next-rehber@0.1.0 build> next build ▲ Next.js 16.4.0 (Turbopack)✓ Running next.config.ts took 37ms Creating an optimized production build ...✓ Compiled successfully in 2.3s Running TypeScript ... Finished TypeScript in 1649ms ... Collecting page data using 11 workers ... Generating static pages using 11 workers (0/8) ...Eşya listesi hazırlanıyorŞampiyon listesi hazırlanıyor Generating static pages using 11 workers (2/8) Generating static pages using 11 workers (4/8) Generating static pages using 11 workers (6/8)✓ Generating static pages using 11 workers (8/8) in 640ms Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /esyalar├ ƒ /esyalar/[id]├ ○ /hakkinda├ ○ /sampiyonlar├ ƒ /sampiyonlar/[id]└ ○ /yama-notlari ○ (Static) prerendered as static contentƒ (Dynamic) server-rendered on demandBu, bütün görevler yapılmış hâl; bir kısmını yapmadıysan listen daha kısa olur. Okuması kolay ve öğretici:
Running TypeScript: derleme önce tipleri denetler. Bir tip hatası olsaydı derleme burada dururdu.Şampiyon listesi hazırlanıyorveEşya listesi hazırlanıyor: seninconsole.log'ların, derleme sırasında birer kez. Next, iki liste sayfasının hiç değişmediğini anladı ve onları şimdiden HTML'e çevirdi.○ (Static): önceden hazırlanmış sayfalar. Ziyaretçi geldiğinde hazır dosya gönderilir; bileşen yeniden çalışmaz.ƒ (Dynamic): her istekte sunucuda yeniden çizilen sayfalar./sampiyonlar/[id]ve/esyalar/[id]hangiid'lerle açılacakları önceden bilinmediği için dinamik.11 workersbilgisayarının işlemci çekirdeğine göre değişir; süreler de.
Yayındaki hâli denemek için npm run start yaz ve http://localhost:3000'i aç. Sayfalar arasında gez: geliştirme sunucusundakinden hızlı ve konsolda Server kopyaları yok. İşin bitince Ctrl+C.
PS C:\kod\next-rehber> npm run start > next-rehber@0.1.0 start> next start ▲ Next.js 16.4.0- Local: http://localhost:3000- Network: http://192.168.1.35:3000✓ Ready in 187ms✓ Running next.config.ts took 44msŞimdi kayıt. create-next-app projede Git'i başlatıp ilk kaydı aldığı için git init yazmıyorsun:
Sıradaki derste bu siteyi tam yığın bir uygulamaya çeviriyorsun: Route Handler ile API yazacak, sunucu bileşeninde doğrudan veritabanından okuyacak ve Server Actions ile formdan gelen veriyi kaydedeceksin.