İçeriğe geç
XDD.baby Yazılım Öğrenme
Sözlük Hata rehberi 0/56

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-app ile TypeScript ve App Router kullanan bir Next.js projesi açıp klasörlerin ne işe yaradığını anlatabilirsin
  • app klasöründe page.tsx ve layout.tsx dosyaları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
  • Link ile sayfa yenilemeden gezinebilir, [id] klasörü ve await params ile her şampiyona ayrı adres verebilirsin
  • metadata, generateMetadata, not-found.tsx ve notFound() ile sekme başlığını ve 404 sayfasını yönetebilirsin
  • next/image ve next/font ile resimleri ve yazı tiplerini, sayfadaki yazılar yerinden kaymadan ve hızlı yükleyebilirsin
Bu sayfada
  1. Next.js neden var?
  2. Proje açmak: create-next-app
  3. page.tsx: ilk sayfan
  4. layout.tsx: bütün sayfaların çerçevesi
  5. Link ile gezinme
  6. Veriden sayfa: sunucu bileşeni
  7. Dinamik route: her şampiyona ayrı adres
  8. İstemci bileşeni: "use client"
  9. Metadata ve not-found
  10. Görseller ve yazı tipleri
  11. Sık hatalar
  12. Görevler
  13. Kendini sına
  14. 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/ahri gibi 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ı.

Vite + React (Ders 40–42) Sunucu dosyaları olduğu gibi gönderir İlk HTML <div id="root"> içi boş Tarayıcı JS'i çalıştırır, veriyi çeker, sayfayı kurar Next.js (bu ders) Sunucu bileşeni çalıştırır, veriyi okur İlk HTML <h2>Ahri</h2>… içi dolu Tarayıcı hemen gösterir, sonra düğmeleri canlandırır
Şekil 43.1 Sayfanın ilk yanıtında ne gelir? Vite'ta HTML boştur, sayfayı tarayıcı kurar. Next.js'te HTML sunucuda doldurulmuş gelir.

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):

Terminal — PowerShell
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
BayrakAnlamı
--tsTypeScript kullan (.tsx dosyaları). Varsayılan da bu.
--appApp Router: sayfalar app klasöründe. Next'in eski düzeni Pages Router (pages klasörü); yeni projeler App Router ile açılıyor.
--tailwindTailwind CSS kurulu gelsin. Onu Ders 45'te öğreneceksin; bu derste kendi CSS'imizi yazacağız.
--eslintESLint kod denetleyicisi kurulsun (npm run lint). Ders 40'taki Oxlint'in yaptığı işi yapar.
--no-src-dirapp 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-compilerReact Compiler kapalı. Bileşenleri kendiliğinden hızlandıran bir derleme aracı; şimdilik gerek yok.
--no-cache-componentsCache Components kapalı. Aşağıda neden.
--use-npmPaketleri 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:

Terminal — PowerShell
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-rehber

Uzun 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 belgeleri node_modules/next/dist/docs içinde, önce onları oku" diyen kısa bir not. Blok 7'de anlamı netleşecek; dokunmana gerek yok.
  • npm warn ile başlayan satırlar uyarı, hata değil. Proje kuruldu.
  • 5 high severity vulnerabilities: npm audit bunları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 projede eslint-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 bir npm update yeter.
  • Initialized a git repository: sihirbaz git init yaptı ve ilk kaydı (Initial commit from Create Next App) kendisi aldı. Dersin sonunda bu yüzden git init yazmayacaksı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:

Terminal — PowerShell
PS C:\kod> code next-rehber
Terminal — PowerShell
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)

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.

localhost:3000
Next.js şablon sayfası: siyah zeminde kesik çizgili bir logo yeri, To get started, edit the page.tsx file başlığı, Templates ve Learning bağlantıları, Deploy Now ve Documentation düğmeleri, sol altta içinde N harfi olan yuvarlak düğme.
create-next-app'in hazır sayfası. Kesik çizgili kutuların yerinde sende Next.js ve Vercel logoları görünür; tarayıcın açık temadaysa sayfa beyaz olur.

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. İçinde layout.tsx (bütün sayfaların çerçevesi), page.tsx (ana sayfa), globals.css (bütün siteye uygulanan CSS) ve sekmedeki küçük simge favicon.ico var. Bu derste neredeyse hep burada çalışacaksın.
  • public/: olduğu gibi sunulan dosyalar. public/kapak.png dosyası sitede /kapak.png adresinden 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.ts ve .next/: Next'in kendi ürettiği dosyalar. .next derleme çı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:

app/globals.css
@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:

app/page.tsx — next-rehber — Visual Studio Code
app/page.tsx
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>    )}
PROBLEMSOUTPUTDEBUG CONSOLETERMINAL
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)
Ln 1, Col 1Spaces: 4UTF-8TSX

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:

app/hakkinda/page.tsx
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.tsx sayfa olmaz.
  • Bir klasörde page.tsx yoksa o adreste sayfa yoktur, ama klasör yine de alt klasörlerine yol olur.
  • Klasöre page.tsx dışı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).
app/ ├─ layout.tsx bütün sayfaların çerçevesi ├─ page.tsx / ├─ not-found.tsx olmayan adreslerde gösterilir ├─ hakkinda/ │ └─ page.tsx /hakkinda └─ sampiyonlar/ ├─ page.tsx /sampiyonlar └─ [id]/ ├─ page.tsx /sampiyonlar/1, /2… └─ FavoriDugmesi.tsx adres değil, yalnız bileşen
Şekil 43.2 Klasör ağacı ve sitenin adresleri. Yalnız page.tsx dosyaları adres olur; köşeli parantezli klasör, adresin değişen parçasıdır.
Sık hataYanlış klasör ya da dosya adı: 404

Ne 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:

Terminal — PowerShell
 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.

Sık hataThe 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:

Terminal — PowerShell
⨯ 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:

app/layout.tsx
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:

  • RootLayout bir bileşen; <html> ve <body> etiketlerini o çiziyor. Vite'taki index.html'in işini artık bu dosya görüyor.
  • { children }: o an açılan sayfa. / adresindeyken app/page.tsx'in çizdiği şey, /hakkinda'dayken app/hakkinda/page.tsx'inki. Props'un özel bir adı olan children, "bu bileşenin içine konan şey" demek.
  • LayoutProps<"/">: Next'in senin klasörlerine bakarak kendisi ürettiği bir tip; içinde children var. import etmeden kullanabilirsin.

Dosyayı tamamen sil ve şunu yaz. Şimdilik sade tutuyoruz; menüde düz <a> bağlantıları var:

app/layout.tsx
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.

localhost:3000/ Şampiyon Rehberi menü app/page.tsx Şampiyon Rehberi localhost:3000/hakkinda Şampiyon Rehberi menü app/hakkinda/page.tsx Hakkında layout.tsx: her sayfada aynı kalan çerçeve {children}: adrese göre değişen page.tsx
Şekil 43.3 Layout her sayfada aynı kalır; {children}'ın yerine adrese göre o klasörün page.tsx'i konur.

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:

app/layout.tsx
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:

app/page.tsx
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:

lib/sampiyonlar.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:

app/sampiyonlar/page.tsx
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>    )}
localhost:3000/sampiyonlar
Şampiyon Rehberi menüsünün altında Şampiyonlar başlığı, 6 şampiyon var yazısı ve iki sıra halinde altı kart: mor yuvarlakta baş harfiyle Ahri Büyücü, Garen Dövüşçü, Jinx Nişancı, Lux Büyücü, Yasuo Dövüşçü, Teemo Nişancı.

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:

Terminal — PowerShell
Ş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:

HTML
<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:

app/sampiyonlar/[id]/page.tsx
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:

  1. [id] klasörü "bu parça değişebilir, adı id olsun" demek. /sampiyonlar/3 açılınca Next bu dosyayı seçer ve id'ye "3" değerini verir.
  2. Next sayfa bileşenine params adında bir prop verir. Next 16'da params bir Promise: değeri hemen değil, await ile alınır. Bu yüzden fonksiyonun başında async var ve ilk satır const { id } = await params.
  3. Tip notu bunu söylüyor: params: Promise<{ id: string }>. id her zaman metin, çünkü adres bir metin. Dizideki id'ler ise sayı; karşılaştırmadan önce Number(id) ile sayıya çeviriyoruz.
  4. Şampiyon bulunamazsa (ör. /sampiyonlar/99) kısa bir mesaj döndürüyoruz. if'ten sonra TypeScript sampiyon'un artık undefined olamayacağını bilir (daraltma).

Promise olduğunu kendi gözünle gör. Fonksiyonun başına geçici olarak iki satır ekle:

app/sampiyonlar/[id]/page.tsx
export default async function SampiyonSayfasi({ params }: Props) {    console.log(params)    const { id } = await params    console.log(id, typeof id)

/sampiyonlar/3'ü açınca terminal:

Terminal — PowerShell
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).

ders43.js
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()
Çıktı
Promise { { id: '3' } }undefined3 string

Promise'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.

Sık hataProperty '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.

app/sampiyonlar/[id]/page.tsx✗ Yanlış
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:

Terminal — PowerShell
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):

Terminal — PowerShell
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:9

TypeScript 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.

Sık hataThis 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.

Terminal — PowerShell
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:

app/sampiyonlar/[id]/FavoriDugmesi.tsx✗ Yanlış
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:

localhost:3000/sampiyonlar/1
Next.js hata katmanı: Build Error etiketi, kırmızı yazıyla 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. Altında FavoriDugmesi.tsx dosyasının 1. satırı useState'in altı işaretli, en altta Import trace: Server Component, FavoriDugmesi.tsx ve page.tsx. Sol altta kırmızı 1 Issue düğmesi.
Next'in hata katmanı: Build Error. Kodu düzeltip kaydedince kendiliğinden kapanır.
Sık hataYou'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ı:

Terminal — PowerShell
⨯ ./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.tsx

Parç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:

app/sampiyonlar/[id]/FavoriDugmesi.tsx
"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>    )}
localhost:3000/sampiyonlar/1
Ahri'nin sayfası: üstte Bütün şampiyonlar geri bağlantısı, Ahri başlığı, Büyücü, Zorluk 2/3, tanıtım cümlesi ve mor zeminli Ahri favorilerinde düğmesi.
Düğmeye bir kez tıklandıktan sonra.

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.

Bileşen kullanıcıyla etkileşiyor mu? tıklama, yazma, useState, useEffect, localStorage… Hayır Evet Sunucu bileşeni varsayılan, bir şey yazmazsın • yalnız sunucuda çalışır • veri, dosya, gizli anahtar okur • async olabilir • kodu tarayıcıya gitmez İstemci bileşeni "use client" ile başlar • tarayıcıda da çalışır • useState, onClick kullanır • kodu tarayıcıya iner • gizli bilgi koyma Örnek: [id]/page.tsx içinde kullanır FavoriDugmesi.tsx
Şekil 43.4 Bir bileşen kullanıcıyla etkileşmiyorsa sunucu bileşeni kalır. Etkileşiyorsa "use client" ile istemci bileşeni olur; onu küçük tut ve sunucu bileşeninin içinde kullan.
BileşenHangisi?Neden
Şampiyon listesi (map ile kartlar)SunucuYalnız veriyi gösteriyor.
Şampiyon sayfası (params, find)SunucuVeriyi okuyup gösteriyor; async.
Favori düğmesiİstemciuseState ve onClick.
Arama kutusuİstemciYazdıkça değişen state.
Hakkında yazısıSunucuHiç 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 sayfa async olamazdı. 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.
Sık hataEvent 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:

Terminal — PowerShell
⨯ 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:

app/layout.tsx
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. %s sayfanı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:

app/sampiyonlar/page.tsx
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:

HTML
<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.

Sık hataYou 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.

Terminal — PowerShell
⨯ ./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:

lib/sampiyonlar.ts
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:

app/sampiyonlar/[id]/page.tsx
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.

  • generateMetadata da params'ı await eder ve bir Metadata objesi döndürür. Dönüş tipi Promise<Metadata>, çünkü fonksiyon async.
  • Ş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:

Terminal — PowerShell
 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:

app/sampiyonlar/[id]/page.tsx
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üzden if'ten sonra sampiyon yine kesin olarak bir Sampiyon.
  • 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:

app/not-found.tsx
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>    )}
localhost:3000/sampiyonlar/99
Menünün altında Bu sayfa haritada yok başlığı, Aradığın adres ya hiç olmadı ya da taşındı yazısı ve mor Ana sayfaya dön düğmesi.
Durum kodu 404, sekme başlığı Bulunamadı · Şampiyon Rehberi.

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:

Kapak resmi: solda üç koridorlu, köşelerinde mavi ve kırmızı üs olan küçük bir harita, sağda yıldız, kalkan ve kılıç simgeli üç kart.

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:

app/page.tsx
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>    )}
localhost:3000
Ana sayfa: menünün altında kapak resmi, altında Şampiyon Rehberi başlığı, açıklama cümlesi ve mor Şampiyonlara göz at düğmesi.
  • src="/kapak.png": public klasörü sitenin köküdür; public/kapak.png adreste /kapak.png olur. Adrese public yazılmaz.
  • alt: resmi göremeyen biri (ekran okuyucu kullanan, resmi yüklenemeyen) için resmin ne olduğu. Zorunlu.
  • width ve height: 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: .kapak sınıfı width: 100%; height: auto diyor, resim kutusuna sığıyor.
  • loading="eager": "hemen yükle". Image varsayı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:

HTML
<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&amp;w=1200&amp;q=75 1x, /_next/image?url=%2Fkapak.png&amp;w=3840&amp;q=75 2x" src="/_next/image?url=%2Fkapak.png&amp;w=3840&amp;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.

Sık hataImage with src "/kapak.png" is missing required "width" property.

Ne zaman çıkar? Image'e width ve height vermeyince. Sayfa 500 verir, terminalde:

Terminal — PowerShell
⨯ 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:

lib/sampiyonlar.ts
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:

app/sampiyonlar/page.tsx
                    <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:

Sık hatahostname "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.

Terminal — PowerShell
⨯ 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:

next.config.ts
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:

Terminal — PowerShell
⚠ Found a change in next.config.ts. Restarting the server to apply the changes...
localhost:3000/sampiyonlar
Şampiyonlar sayfası: altı kartın her birinde yuvarlak bir resim yeri, altında şampiyonun adı ve rolü.
Telif nedeniyle şampiyon resimlerinin yerine gri bir kutu koyduk; sende Data Dragon'daki gerçek kare resimler görünür.

Ş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:

app/sampiyonlar/[id]/page.tsx
            <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:

Konsol (F12 → Console)
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:

app/layout.tsx
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 (şablondaki Geist_Mono gibi).
  • 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 İ ise latin-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ğini weight: "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.

Sık hataAnother 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:

Terminal — PowerShell
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.

Sık hata'`' can be escaped with `&apos;`

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:

app/yama-notlari/page.tsx✗ Yanlış
            <p>Garen biraz güçlendi, Teemo'nun mantarları biraz zayıfladı.</p>

Sayfa sorunsuz görünür; itiraz eden ESLint:

Terminal — PowerShell
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 `&apos;`, `&lsquo;`, `&#39;`, `&rsquo;`  react/no-unescaped-entities ✖ 1 problem (1 error, 0 warnings)

"' işareti &apos; 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ç.

Görev 1

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
app/yama-notlari/page.tsx
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).

Görev 2

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):

lib/esyalar.ts
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
app/esyalar/page.tsx
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.

Görev 3

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
app/esyalar/[id]/page.tsx
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.

Görev 4

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: 4 ve Altın yetmiyor. Altın tam fiyata eşitken alabilmelisin.
  • Yakut Kristali (400): iki kez; sonra Altının: 200 ve Altı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
app/esyalar/[id]/SatinAlDugmesi.tsx
"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.

Görev 5

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 .harita sı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
app/yama-notlari/page.tsx
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.

Aynı başlık iki yazı tipiyle: üstte Russo One'da bütün harfler aynı kalınlıkta; altta Lilita One'da ğ, İ, Ş ve ş harfleri belirgin biçimde ince ve farklı bir yazı tipinde.
Görev 6

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-eski gibi 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
app/Menu.tsx
"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?

Metin
app/├─ page.tsx├─ layout.tsx├─ esyalar/│  ├─ page.tsx│  ├─ EsyaKarti.tsx│  └─ [id]/│     └─ page.tsx└─ profil/   └─ ayarlar/      └─ page.tsx
Cevabı 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?

TSX
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:

Terminal — PowerShell
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 demand

Bu, 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ıyor ve Eşya listesi hazırlanıyor: senin console.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] hangi id'lerle açılacakları önceden bilinmediği için dinamik.
  • 11 workers bilgisayarı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.

Terminal — PowerShell
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.

İşaret yalnızca bu tarayıcıda saklanır, kimseye gönderilmez.