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

Blok 6 · Ders 44

Next.js ile tam yığın (full-stack)

Route Handler ile API, Server Actions ile form, veritabanına bağlanmak ve sayfayı güncel tutmak.

Bu dersin sonunda

  • Bir Next.js sunucu bileşeninde pg havuzuyla PostgreSQL'den okuyup sayfayı veriyle çizebilirsin
  • app/api/.../route.ts dosyasında GET ve POST fonksiyonlarıyla JSON döndüren bir API yazabilirsin
  • \"use server\" ile bir Server Action yazıp bir formu veritabanına kaydedebilirsin
  • Yayın derlemesinde hangi sayfanın önbelleğe alındığını okuyup revalidatePath ile sayfayı güncel tutabilirsin
  • Gizli bilgiyi sunucuda tutup NEXT_PUBLIC_ önekinin tarayıcıya neyi gönderdiğini açıklayabilirsin
  • loading.tsx, error.tsx ve zod ile yükleniyor, hata ve geçersiz girdi durumlarını yönetebilirsin
Bu sayfada
  1. Tam yığın: tek projede üç kapı
  2. Veritabanını hazırla
  3. Sunucu bileşeninde veritabanından okumak
  4. Route Handler: başka programlar için API
  5. Server Actions: formdan veritabanına
  6. Önbellek: yayında neden eski veri?
  7. Doğrulama: zod
  8. Ortam değişkenleri: sunucu ile istemci sınırı
  9. Yükleniyor ve hata sayfaları
  10. Sık hatalar
  11. Görevler
  12. Kendini sına
  13. Kayıt noktası

Ders 43'te çok sayfalı bir site kurdun, ama verisi koddaki bir diziden geliyordu: yeni bir şampiyon eklemek için kodu değiştirip siteyi yeniden yayınlaman gerekirdi. Bu derste Şampiyon Rehberi'ni gerçek bir uygulamaya çeviriyorsun. Liste Ders 37'deki gibi PostgreSQL'den gelecek, bir formla yeni şampiyon eklenecek, başka programlar için bir JSON API'si olacak. Yanlış girdiyi zod yakalayacak, veritabanına ulaşılamazsa kullanıcı düzgün bir hata sayfası görecek. Hepsi tek bir Next.js projesinde.

Tam yığın: tek projede üç kapı

Şimdiye kadar bir uygulamanın iki yarısını ayrı ayrı yazdın. Ders 38'de Express ile bir API kurup Render'a koydun; Ders 40–42'de Vite ile bir React arayüzü yazdın. İkisini birleştirmek isteseydin iki proje, iki yayın, iki ayrı adres olurdu. Arayüz API'yi fetch ile çağırır, araya CORS ayarı girer, her sayfa önce "yükleniyor" der.

Next.js'te iki yarı aynı projede durur. Tarayıcının görmediği bir sunucu tarafın zaten var: Ders 43'te sunucu bileşenlerinin console.log'u terminale düşüyordu. Bu derste o sunucu tarafını üç farklı kapıdan kullanacaksın:

  1. Sunucu bileşeni sayfayı çizerken veritabanından okur. Veri sayfanın ilk HTML'iyle gelir.
  2. Server Action formdan gelen veriyi alıp veritabanına yazar.
  3. Route Handler başka programlara (bir mobil uygulamaya, bir Discord botuna, curl.exe'ye) JSON verir.
Tarayıcı sayfaları açar, formu gönderir Başka program curl, mobil uygulama, bot Next.js sunucusu page.tsx sunucu bileşeni: okur actions.ts Server Action: yazar api/…/route.ts Route Handler: JSON verir PostgreSQL Neon
Şekil 44.1 Next.js projesinin sunucu tarafı üç kapıdan çalışır. Üçü de veritabanına aynı sunucudan bağlanır; tarayıcı veritabanını hiç görmez.

Veritabanını hazırla

Neon'da yeni bir veritabanı

Ders 37'de pgapi adında, derse ayrılmış bir veritabanı açmıştın. Bu proje için de temiz bir tane aç:

  1. Neon'da projeni aç, SQL Editor'de CREATE DATABASE rehber; yaz ve Run'a bas.
  2. SQL Editor'ün üstündeki veritabanı seçicisinden rehber'i seç (arayüz değişmiş olabilir).
  3. Aşağıdaki betiği yapıştırıp çalıştır. Aynı metni projenin köküne schema.sql adıyla da kaydet; tabloların nasıl kurulduğu kodunla birlikte dursun (Ders 37'deki şema dosyası fikri).
schema.sql
CREATE TABLE sampiyonlar (    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    isim text NOT NULL UNIQUE,    rol text NOT NULL CHECK (rol IN ('Büyücü', 'Dövüşçü', 'Nişancı')),    zorluk integer NOT NULL CHECK (zorluk BETWEEN 1 AND 3),    tanitim text NOT NULL); INSERT INTO sampiyonlar (isim, rol, zorluk, tanitim) VALUES    ('Ahri', 'Büyücü', 2, 'Çevik bir büyücü. Düşmanın arasına dalıp çıkmayı sever.'),    ('Garen', 'Dövüşçü', 1, 'Dayanıklı bir savaşçı. Öğrenmesi kolay, alt etmesi zor.'),    ('Jinx', 'Nişancı', 2, 'Uzaktan vuran bir nişancı. Takım savaşında coşar.'),    ('Lux', 'Büyücü', 1, 'Işık büyüleriyle uzaktan oyunu yöneten bir büyücü.'),    ('Yasuo', 'Dövüşçü', 3, 'Rüzgârı kullanan bir kılıç ustası. Ustalaşması uzun sürer.'),    ('Teemo', 'Nişancı', 1, 'Küçük ama sinir bozucu bir izci. Haritaya mantar eker.'); CREATE TABLE esyalar (    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    ad text NOT NULL UNIQUE,    fiyat integer NOT NULL CHECK (fiyat >= 0),    aciklama text NOT NULL); INSERT INTO esyalar (ad, fiyat, aciklama) VALUES    ('Uzun Kılıç', 250, 'Saldırı gücünü biraz artırır.'),    ('Yakut Kristali', 400, 'Can puanını artırır.'),    ('Ağır Zırh', 1100, 'Fiziksel hasara karşı korur.');

Veri Ders 43'teki dizilerin aynısı; esyalar tablosunu görevlerde kullanacaksın. CHECK kısıtları (Ders 35) rolü üç değerle, zorluğu 1–3 arasıyla, fiyatı sıfır ve üstüyle sınırlıyor. Bir denetim sorgusu çalıştır:

SQL
SELECT id, isim, rol, zorluk FROM sampiyonlar ORDER BY id;
idisimrolzorluk
1AhriBüyücü2
2GarenDövüşçü1
3JinxNişancı2
4LuxBüyücü1
5YasuoDövüşçü3
6TeemoNişancı1

Paketler ve .env.local

Geliştirme sunucusu çalışıyorsa Ctrl+C ile durdur. Veritabanı sürücüsü pg'yi ve dersin sonunda kullanacağın doğrulama kütüphanesi zod'u kur; pg'nin TypeScript tipleri ayrı bir pakette, onu geliştirme bağımlılığı olarak kur:

Terminal — PowerShell
PS C:\kod\next-rehber> npm install pg zod added 14 packages, and audited 372 packages in 1s 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.PS C:\kod\next-rehber> npm install -D @types/pg added 1 package, and audited 373 packages in 1s...

İkinci komutun çıktısının geri kalanı birincininkiyle aynı, kısalttık. Bu ders pg 8.23.1 ve zod 4.6.6 ile denendi (npm view zod version bu dersi yazarken 4.6.6 diyordu). 5 high severity vulnerabilities Ders 43'te gördüğün ESLint uyarısı; yeni paketlerden gelmiyor, npm audit fix --force çalıştırma. npm warn install-scripts satırları da Ders 43'teki gibi zararsız.

Şimdi bağlantı dizesi. Neon'da Connect düğmesine bas, veritabanı olarak rehber'i seç ve dizeyi kopyala. Ders 37'de dizeyi .env dosyasına yazıp programı node --env-file=.env ile çalıştırmıştın. Next.js bu dosyaları kendisi okur; --env-file gerekmez. Proje kökünde (app'in yanında) .env.local adında bir dosya aç ve tek satır yaz. sslmode=require'ı Ders 37'deki gibi verify-full yap:

.env.local
DATABASE_URL=postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/rehber?sslmode=verify-full&channel_binding=require

Değerler Neon belgesindeki örnek; seninki farklı. Dosyanın adı neden .env.local? Next bir değişkeni sırayla birkaç yerde arar ve ilk bulduğunu kullanır: önce bilgisayarın kendi ortam değişkenleri, sonra .env.development.local, sonra .env.local, sonra .env.development, en son .env. Adında development geçen dosyalar yalnız npm run dev'de okunur; npm run build ve npm run start onların yerine .env.production.local ve .env.production'a bakar. .env.local ikisinde de okunur. .local ile biten dosyalar "yalnız bu bilgisayara ait" demek; Neon'un Next.js rehberi de bağlantı dizesini bu dosyaya koyuyor. create-next-app'in .gitignore'unda .env* satırı var: .env ile başlayan hiçbir dosya Git'e girmez.

npm run dev ile sunucuyu başlat. Başlangıç satırlarına yeni bir satır eklendi:

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- Environments: .env.local✓ Ready in 1788ms✓ Running next.config.ts took 71ms

- Environments: .env.local: Next dosyayı buldu ve okudu. Bu satırı görmüyorsan dosyanın adı ya da yeri yanlış (.env.local.txt olmasın, Ders 0).

lib/db.ts: tek havuz

lib klasörüne db.ts adında bir dosya aç. Ders 37'deki sağlam db.js'in TypeScript hâli, üstünde bir satır fazlasıyla:

lib/db.ts
import "server-only"import { Pool } from "pg" if (!process.env.DATABASE_URL) {    throw new Error("DATABASE_URL tanımlı değil: .env.local dosyası proje kökünde mi?")} export const pool = new Pool({    connectionString: process.env.DATABASE_URL,    connectionTimeoutMillis: 5000,}) pool.on("error", (hata) => {    console.error("Havuzdaki boşta bekleyen bir bağlantı koptu:", hata.message)})
  • import "server-only": "bu dosya yalnız sunucuda çalışır" işareti. Bir istemci bileşeni bu dosyayı (ya da onu içe aktaran bir dosyayı) içe aktarmaya kalkarsa derleme durur. Ne işe yaradığını ortam değişkenleri bölümünde kendi gözünle göreceksin. Next bu adı kendisi tanır; paketi kurmana gerek yok.
  • Pool, connectionTimeoutMillis, pool.on("error", …): Ders 37'den. Havuz modül yüklenirken bir kez kurulur, her sayfa ve her istek aynı havuzu kullanır.
  • export const pool: başka dosyalar import { pool } from "@/lib/db" ile alır.
Sık hataDATABASE_URL tanımlı değil: .env.local dosyası proje kökünde mi?

Ne zaman çıkar? .env.local yoksa, yanlış klasördeyse (ör. app içinde) ya da içindeki ad farklı yazılmışsa (DATABSE_URL). Bu mesajı biz yazdık; lib/db.ts'in ilk denetimi çalıştı. Sayfa 500 verir, terminalde:

Terminal — PowerShell
⨯ Error: DATABASE_URL tanımlı değil: .env.local dosyası proje kökünde mi?    at <unknown> (lib\db.ts:5:11)  3 |  4 | if (!process.env.DATABASE_URL) {> 5 |     throw new Error("DATABASE_URL tanımlı değil: .env.local dosyası proje kökünde mi?")    |           ^  6 | }  7 |  8 | export const pool = new Pool({ GET /sampiyonlar 500 in 560ms (next.js: 409ms, application-code: 151ms)

Başlangıç satırlarında - Environments: .env.local da yoktur: ilk ipucu orada.

Nasıl düzelir? Dosyayı proje köküne, package.json'ın yanına taşı ve adını harfi harfine .env.local yap. Sunucuyu durdurup npm run dev ile yeniden başlat: havuz modül ilk yüklendiğinde kurulduğu için dosyayı sonradan düzeltmek açık sunucuya her zaman yetmez.

Sunucu bileşeninde veritabanından okumak

Ders 42'de veriyi bir bileşene getirmek için üç state, bir useEffect ve bir fetch yazmıştın; sayfa önce "yükleniyor" diyordu. Ders 43'te sunucu bileşenlerinin async olabildiğini gördün. İkisini birleştirince iş kısalıyor: sunucu bileşeni veritabanını doğrudan sorgular, sonucu bekler ve sayfayı veriyle çizer.

Önce veri katmanı. lib/sampiyonlar.ts'teki diziyi ve sampiyonBul'u sil; dosyanın son hâli:

lib/sampiyonlar.ts
import "server-only"import { pool } from "@/lib/db" export type Sampiyon = {    id: number    isim: string    rol: string    zorluk: number    tanitim: string} export async function tumSampiyonlar(): Promise<Sampiyon[]> {    const { rows } = await pool.query<Sampiyon>(        "SELECT id, isim, rol, zorluk, tanitim FROM sampiyonlar ORDER BY isim"    )    return rows} export async function sampiyonBul(id: string): Promise<Sampiyon | undefined> {    const { rows } = await pool.query<Sampiyon>(        "SELECT id, isim, rol, zorluk, tanitim FROM sampiyonlar WHERE id = $1",        [id]    )    return rows[0]} export function ikonAdresi(isim: string) {    return `https://ddragon.leagueoflegends.com/cdn/16.20.1/img/champion/${isim}.png`}
  • pool.query<Sampiyon>(…): açılı parantezdeki Sampiyon bir tip parametresi. pg'ye "satırlar bu şekilde gelecek" diyorsun; rows artık Sampiyon[]. Bu bir söz, denetim değil: sütun adlarını yanlış yazarsan TypeScript fark etmez.
  • sampiyonBul async oldu ve $1 ile parametreli sorgu kullanıyor. Adresten gelen metni SQL'e yapıştırmıyoruz.
  • rows[0]: satır yoksa undefined. Dönüş tipine Sampiyon | undefined yazdık ki sayfa bu ihtimali unutmasın.

Kaydedip npx tsc --noEmit çalıştırırsan TypeScript iki sayfada da hata bulur, çünkü ikisi de eski dosyaya göre yazılmış. Şampiyon sayfasındaki mesaj öğretici:

Sık hataProperty 'isim' does not exist on type 'Promise<Sampiyon | undefined>'.

Ne zaman çıkar? async bir fonksiyonu await yazmadan kullanınca. sampiyonBul artık değeri değil, değerin Promise'ini döndürüyor; şampiyon sayfasındaki eski satır const sampiyon = sampiyonBul(id) duruyor:

Terminal — PowerShell
PS C:\kod\next-rehber> npx tsc --noEmitapp/sampiyonlar/[id]/page.tsx:15:41 - error TS2339: Property 'isim' does not exist on type 'Promise<Sampiyon | undefined>'. 15     return { title: sampiyon ? sampiyon.isim : "Bulunamadı" }                                           ~~~~

"Promise<Sampiyon | undefined> tipinde isim diye bir alan yok." Aynı mesaj sayfanın gövdesindeki her sampiyon. için tekrarlanır. Tip denetimi olmasaydı daha sinsi bir şey olurdu: Promise bir obje olduğu için if (!sampiyon) hiç doğru olmaz, notFound() hiç çalışmaz.

Nasıl düzelir? const sampiyon = await sampiyonBul(id). Çağıran fonksiyonun async olduğundan emin ol; generateMetadata ve sayfa zaten async.

Şimdi sayfaları güncelle. Şampiyon sayfasında sampiyonBul'u çağıran iki satıra da await ekle:

app/sampiyonlar/[id]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {    const { id } = await params    const sampiyon = await sampiyonBul(id)    return { title: sampiyon ? sampiyon.isim : "Bulunamadı" }} export default async function SampiyonSayfasi({ params }: Props) {    const { id } = await params    const sampiyon = await sampiyonBul(id)

Liste sayfasında içe aktarmayı değiştir, fonksiyonu async yap ve veriyi oku. Bir de yeni şampiyon formuna gidecek bir bağlantı ekle; o sayfayı birazdan yazacaksın:

app/sampiyonlar/page.tsx
import type { Metadata } from "next"import Image from "next/image"import Link from "next/link"import { ikonAdresi, tumSampiyonlar } from "@/lib/sampiyonlar" export const metadata: Metadata = {    title: "Şampiyonlar",} export default async function SampiyonListesi() {    console.log("Şampiyon listesi hazırlanıyor")    const sampiyonlar = await tumSampiyonlar()     return (        <main>            <h1>Şampiyonlar</h1>            <p className="soluk">{sampiyonlar.length} şampiyon var. Birine tıkla, ayrıntısını gör.</p>            <p>                <Link href="/sampiyonlar/yeni" className="dugme">+ Yeni şampiyon</Link>            </p>            <div className="kartlar">                {sampiyonlar.map((s) => (                    <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>                        <p>{s.rol}</p>                    </Link>                ))}            </div>        </main>    )}

/sampiyonlar'ı ve bir şampiyonu aç. Ekran Ders 43'tekiyle aynı; değişen, verinin nereden geldiği:

Terminal — PowerShell
○ Compiling /sampiyonlar ...Şampiyon listesi hazırlanıyor GET /sampiyonlar 200 in 791ms (next.js: 476ms, application-code: 315ms) GET /sampiyonlar/3 200 in 669ms (next.js: 603ms, application-code: 66ms)

Neyin olmadığına dikkat et: useEffect yok, "yükleniyor" state'i yok, ayrı bir API ve fetch yok. Sorgu sunucuda çalıştı, kartlar ilk HTML'in içinde geldi. SQL metni, bağlantı dizesi, pg paketi tarayıcıya hiç inmedi; tarayıcının gördüğü yalnız sonuç.

Ders 42 (Vite + React)Bu ders (Next.js)
Veri nerede çekiliyor?Tarayıcıda, useEffect içindeSunucuda, bileşenin içinde
İlk HTML'de veri var mı?Yok, önce "yükleniyor"Var
Veritabanına nasıl ulaşılıyor?Ayrı bir API üzerinden (fetch)Doğrudan (pool.query)
Gizli bilgi nerede?Tarayıcıya konamaz, API'de kalmalıSunucu bileşeninde kalır

Adresten gelen metin: "abc"

Sınır değerlerini dene: /sampiyonlar/99 "Bu sayfa haritada yok" der, doğru. Peki /sampiyonlar/abc?

Sık hatainvalid input syntax for type integer: "abc"

Ne zaman çıkar? Adresten gelen metni denetlemeden integer bir sütunla karşılaştırınca. Ders 37'den tanıdık: Postgres "abc"'yi sayıya çeviremez. Sayfa 500 verir, terminalde aynı hata iki kez yazılır (bir generateMetadata, bir sayfa için):

Terminal — PowerShell
⨯ error: invalid input syntax for type integer: "abc"    at async sampiyonBul (lib\sampiyonlar.ts:20:22)    at async Module.generateMetadata (app\sampiyonlar\[id]\page.tsx:14:22)  18 |  19 | export async function sampiyonBul(id: string): Promise<Sampiyon | undefined> {> 20 |     const { rows } = await pool.query<Sampiyon>(     |                      ^  21 |         "SELECT id, isim, rol, zorluk, tanitim FROM sampiyonlar WHERE id = $1",  22 |         [id]  23 |     ) {  length: 146,  severity: 'ERROR',  code: '22P02',  ...  digest: '1561649027'} GET /sampiyonlar/abc 500 in 81ms (next.js: 9ms, application-code: 71ms)

Parametreli sorgu seni SQL injection'dan korur, ama geçersiz veriden korumaz. 22P02 "hatalı veri" sınıfından bir SQLSTATE. digest satırındaki sayı sende farklı olur.

Nasıl düzelir? Sorgudan önce metnin bir sayı olduğunu denetle (aşağıda).

sampiyonBul'un başına bir denetim ekle:

lib/sampiyonlar.ts
export async function sampiyonBul(id: string): Promise<Sampiyon | undefined> {    if (!/^\d{1,9}$/.test(id)) {        return undefined    }     const { rows } = await pool.query<Sampiyon>(

/^\d{1,9}$/ Ders 23'teki düzenli ifade: "baştan sona yalnız rakam; en az 1, en çok 9 tane". Neden 9? Postgres'in integer'ı en çok 2.147.483.647 alır (10 basamak). Dokuz basamaklı her sayı bu sınırın altında kalır; /sampiyonlar/3000000000 gibi taşan bir adres de sorguya hiç gitmez. Denedik: abc ve 3000000000 artık 404 dönüyor, 3 yine Jinx'i açıyor.

Route Handler: başka programlar için API

Sayfalar insanlar için HTML döndürür. Ama yarın şampiyon listesini bir Discord botunda ya da telefon uygulamasında göstermek isteyebilirsin; onlar HTML değil, JSON ister. Ders 28'de bunun için Express ile app.get("/api/takimlar", …) yazmıştın. Next'teki karşılığı Route Handler: page.tsx yerine route.ts adında bir dosya.

app içinde api klasörü, onun içinde sampiyonlar klasörü aç ve route.ts dosyasını yaz:

app/api/sampiyonlar/route.ts
import { tumSampiyonlar } from "@/lib/sampiyonlar" export async function GET() {    const sampiyonlar = await tumSampiyonlar()    return Response.json(sampiyonlar)}

Kurallar az:

  • Dosyanın adı route.ts. Adres yine klasörlerden çıkar: app/api/sampiyonlar/route.ts → /api/sampiyonlar.
  • Dışa aktardığın fonksiyonun adı HTTP metodudur: GET, POST, PUT, PATCH, DELETE (Ders 26). Hepsi büyük harf, hepsi adlı dışa aktarma. Sayfalardaki export default burada yok.
  • Fonksiyon bir Response döndürür. Response, tarayıcıdaki fetch'in döndürdüğü nesnenin aynısı; burada sen kuruyorsun. Response.json(veri) veriyi JSON'a çevirir ve content-type: application/json başlığını koyar.

Sunucu açıkken ikinci bir terminal aç ve dene:

İkinci terminal
PS C:\kod\next-rehber> curl.exe http://localhost:3000/api/sampiyonlar[{"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":6,"isim":"Teemo","rol":"Nişancı","zorluk":1,"tanitim":"Küçük ama sinir bozucu bir izci. Haritaya mantar eker."},{"id":5,"isim":"Yasuo","rol":"Dövüşçü","zorluk":3,"tanitim":"Rüzgârı kullanan bir kılıç ustası. Ustalaşması uzun sürer."}]

Aynı tumSampiyonlar fonksiyonu hem sayfayı hem API'yi besliyor. Veri katmanını lib'e ayırmanın kazancı bu: sorgu tek yerde.

Tek şampiyon: [id] ve 404

Express'teki /api/takimlar/:id'nin karşılığı yine köşeli parantezli klasör. app/api/sampiyonlar içinde [id] klasörü aç, içine route.ts:

app/api/sampiyonlar/[id]/route.ts
import { sampiyonBul } from "@/lib/sampiyonlar" export async function GET(istek: Request, { params }: RouteContext<"/api/sampiyonlar/[id]">) {    const { id } = await params    const sampiyon = await sampiyonBul(id)     if (!sampiyon) {        return Response.json({ hata: "Şampiyon bulunamadı" }, { status: 404 })    }     return Response.json(sampiyon)}
  • Fonksiyon iki parametre alır. İlki gelen istek (Request); bu fonksiyonda kullanmıyoruz ama ikinciye ulaşmak için yazmak zorundayız. İkincisinin içindeki params, sayfalardaki gibi bir Promise.
  • RouteContext<"/api/sampiyonlar/[id]">: Next'in klasörlerine bakıp ürettiği tip, Ders 43'teki LayoutProps gibi. İçinde params: Promise<{ id: string }> var.
  • Response.json(veri, { status: 404 }): ikinci argümanla durum kodunu verirsin. Express'teki res.status(404).json(…)'un karşılığı.
İkinci terminal
PS C:\kod\next-rehber> curl.exe -i http://localhost:3000/api/sampiyonlar/3HTTP/1.1 200 OKvary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-type: application/jsonDate: Mon, 12 Oct 2026 02:04:26 GMTConnection: keep-aliveKeep-Alive: timeout=5Transfer-Encoding: chunked {"id":3,"isim":"Jinx","rol":"Nişancı","zorluk":2,"tanitim":"Uzaktan vuran bir nişancı. Takım savaşında coşar."}PS C:\kod\next-rehber> curl.exe -i http://localhost:3000/api/sampiyonlar/99HTTP/1.1 404 Not Foundvary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-type: application/jsonDate: Mon, 12 Oct 2026 02:04:26 GMTConnection: keep-aliveKeep-Alive: timeout=5Transfer-Encoding: chunked {"hata":"Şampiyon bulunamadı"}PS C:\kod\next-rehber> curl.exe -i -X DELETE http://localhost:3000/api/sampiyonlar/3HTTP/1.1 405 Method Not Allowedvary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchDate: Mon, 12 Oct 2026 02:04:26 GMTConnection: keep-aliveKeep-Alive: timeout=5Transfer-Encoding: chunked

Son istek önemli: dosyada DELETE diye bir fonksiyon yok, Next kendisi 405 Method Not Allowed döndü. vary satırı Next'in kendi başlığı; geç. abc gibi bir adres de sampiyonBul'daki denetim sayesinde 404 döner.

Express (Ders 28)Next.js Route Handler
app.get("/api/sampiyonlar", (req, res) => …)app/api/sampiyonlar/route.ts içinde export async function GET()
req.params.idconst { id } = await params
res.json(veri)return Response.json(veri)
res.status(404).json(…)return Response.json(…, { status: 404 })
Tanımsız metot için kendin yazarsınNext 405 döndürür
Sık hataDetected lowercase method 'get' in '[project]/app/api/sampiyonlar/route.ts'.

Ne zaman çıkar? route.ts'teki fonksiyonun adını küçük harfle (get) yazınca ya da onu export default ile dışa aktarınca. Tarayıcıda ya da curl.exe'de yalnız 405 Method Not Allowed görürsün; sebebi terminalde:

Terminal — PowerShell
⨯ Detected lowercase method 'get' in '[project]/app/api/sampiyonlar/route.ts'. Export the uppercase 'GET' method name to fix this error.⨯ No HTTP methods exported in '[project]/app/api/sampiyonlar/route.ts'. Export a named export for each HTTP method. GET /api/sampiyonlar 405 in 8ms (next.js: 1.8ms, application-code: 6ms)

export default async function GET() yazınca ilk satır değişir: Detected default export in '[project]/app/api/sampiyonlar/route.ts'. Export a named export for each HTTP method instead. İkisinde de anlam aynı: "her HTTP metodu için adlı bir dışa aktarma bekliyorum; hiç bulamadım". npx tsc --noEmit de durumu sezer ama mesajı karışıktır: has no properties in common with type 'RouteHandlerConfig<"/api/sampiyonlar">'.

Nasıl düzelir? export async function GET(): adlı dışa aktarma, tamamı büyük harf. Sayfalarda export default şart, Route Handler'da yasak; ikisini karıştırma.

Server Actions: formdan veritabanına

Şimdi yazma. Siteye bir Yeni şampiyon formu koymak istiyorsun. Şimdiye kadarki araçlarla düşün: Ders 38'deki gibi bir POST uç noktası yaz, formda onSubmit ile preventDefault yap, verileri state'ten toplayıp fetch ile JSON gönder, cevabı bekle, hata ve yükleniyor durumlarını yönet. Next.js bunun kısasını sunar: sunucuda çalışan bir fonksiyonu doğrudan formun action'ına vermek.

Önce fonksiyon. app/sampiyonlar klasörüne actions.ts adında bir dosya aç:

app/sampiyonlar/actions.ts
"use server" import { redirect } from "next/navigation"import { pool } from "@/lib/db" export async function sampiyonEkle(formData: FormData) {    const isim = formData.get("isim")    const rol = formData.get("rol")    const zorluk = formData.get("zorluk")    const tanitim = formData.get("tanitim")     await pool.query(        "INSERT INTO sampiyonlar (isim, rol, zorluk, tanitim) VALUES ($1, $2, $3, $4)",        [isim, rol, zorluk, tanitim]    )     redirect("/sampiyonlar")}
  • "use server": dosyanın en üstünde, tırnaklarıyla. Ders 43'teki "use client"'ın kardeşi bir yönerge: "bu dosyanın dışa aktardığı fonksiyonlar sunucuda çalışır ve tarayıcıdan çağrılabilir".
  • formData: tarayıcının formdan topladığı veri, bir FormData nesnesi. formData.get("isim"), name="isim" olan kutunun içindekini verir. Gelen her şey metindir; zorluk bile "2" gibi bir metin.
  • redirect("/sampiyonlar"): iş bitince kullanıcıyı listeye gönder. next/navigation'dan gelir.

Sonra form. app/sampiyonlar/yeni klasörünü aç, page.tsx yaz. Önce app/globals.css'in sonuna formun stillerini ekle (CSS bu dersin konusu değil, kopyalayabilirsin):

app/globals.css
.form {    display: grid;    gap: 14px;    max-width: 420px;} .form label {    display: grid;    gap: 6px;    font-weight: 700;} .form input,.form select,.form textarea {    padding: 10px 12px;    border: 1px solid #34304a;    border-radius: 10px;    background: #211e2e;    color: #e8e6f0;    font: inherit;    font-weight: 400;} .form button {    justify-self: start;    border: 0;    font: inherit;    font-weight: 700;    cursor: pointer;} .form button:disabled {    opacity: 0.6;    cursor: wait;} .alan-hatasi {    color: #ff8a8a;    font-size: 14px;    font-weight: 400;} .iskelet {    height: 130px;    border-radius: 12px;    background: #211e2e;}
app/sampiyonlar/yeni/page.tsx — next-rehber — Visual Studio Code
app/sampiyonlar/yeni/page.tsx
import type { Metadata } from "next"import { sampiyonEkle } from "../actions" export const metadata: Metadata = {    title: "Yeni şampiyon",} export default function YeniSampiyon() {    return (        <main>            <h1>Yeni şampiyon</h1>            <form action={sampiyonEkle} className="form">                <label>                    İsim                    <input name="isim" />                </label>                <label>                    Rol                    <select name="rol">                        <option>Büyücü</option>                        <option>Dövüşçü</option>                        <option>Nişancı</option>                    </select>                </label>                <label>                    Zorluk (1–3)                    <input name="zorluk" type="number" />                </label>                <label>                    Tanıtım                    <textarea name="tanitim" rows={3} />                </label>                <button type="submit" className="dugme">                    Kaydet                </button>            </form>        </main>    )}
PROBLEMSOUTPUTDEBUG CONSOLETERMINAL
 GET /sampiyonlar/yeni 200 in 63ms (next.js: 16ms, application-code: 47ms)Şampiyon listesi hazırlanıyor GET /sampiyonlar 200 in 36ms (next.js: 1.9ms, application-code: 34ms) POST /sampiyonlar/yeni 200 in 73ms (next.js: 1.6ms, application-code: 72ms)  └─ ƒ sampiyonEkle({}) in 3ms app/sampiyonlar/actions.ts
⎇ masterLn 1, Col 1Spaces: 4UTF-8TSX

Form bir sunucu bileşeninde, action'ında sunucu fonksiyonu var. Terminal, gönderimden sonra çalışan Server Action'ı ayrıca yazıyor.

<form action={sampiyonEkle}>: Ders 41'de onSubmit'e bir fonksiyon veriyordun. Burada action'a veriyorsun ve fonksiyon tarayıcıda değil, sunucuda çalışacak. yeni klasörü [id]'nin yanında duruyor ama karışmaz: Next sabit adı (yeni) köşeli parantezli klasörden önce dener.

/sampiyonlar/yeni'yi aç ve Annie'yi ekle: Büyücü, zorluk 1, tanıtım "Ayısı Tibbers ile ateş saçan küçük bir büyücü." Kaydet'e bas:

localhost:3000/sampiyonlar/yeni
Yeni şampiyon sayfası: İsim kutusunda Annie, Rol listesinde Büyücü, Zorluk kutusunda 1, Tanıtım kutusunda Ayısı Tibbers ile ateş saçan küçük bir büyücü yazıyor; altta mor Kaydet düğmesi.

Sayfa listeye döner ve Annie oradadır: 7 şampiyon var. Ne oldu, sırayla:

  1. Next derlerken sampiyonEkle'ye gizli bir kimlik verdi ve formun içine bu kimliği koydu. Fonksiyonun kodu tarayıcıya gitmedi.
  2. Kaydet'e basınca tarayıcı formdaki dört kutuyu bir POST isteğiyle sayfanın kendi adresine (/sampiyonlar/yeni) gönderdi. Terminaldeki POST satırı bu.
  3. Next kimliğe bakıp sampiyonEkle'yi sunucuda çalıştırdı ve form verisini formData olarak verdi. INSERT çalıştı.
  4. redirect cevaba "şimdi /sampiyonlar'a geç" bilgisini koydu ve Next listenin yeni hâlini aynı cevabın içinde gönderdi. Tarayıcı sayfayı baştan yüklemeden listeye geçti. Terminalde GET /sampiyonlar satırının POST satırından önce yazılması bu yüzden: liste, POST'un cevabı bitmeden çizildi.

Bir yan ürün daha var: bu form JavaScript'siz de çalışır. Tarayıcı JavaScript'i daha indirmemişse ya da kapalıysa form sıradan bir HTML formu gibi gönderilir, sunucu yine sampiyonEkle'yi çalıştırır. Ders 41'deki formu ise React tarayıcıda JavaScript'le kuruyordu; JavaScript olmadan form hiç yoktu.

Sık hataIt is not allowed to define inline "use server" annotated Server Actions in Client Components.

Ne zaman çıkar? Bir istemci bileşeninin ("use client" yazan dosyanın) içinde "use server" ile bir fonksiyon tanımlayınca. Bunu bir deneme sayfasında yaptık: app/deneme/HizliEkle.tsx istemci bileşeni, içindeki ekle fonksiyonunun ilk satırı "use server". Sayfa 500 verir:

Terminal — PowerShell
⨯ ./app/deneme/HizliEkle.tsx:5:9Error: It is not allowed to define inline "use server" annotated Server Actions in Client Components.    To use Server Actions in a Client Component, you can either export them from a separate file with "use server" at the top, or pass them down through props from a Server Component.     Read more: https://nextjs.org/docs/app/api-reference/directives/use-server#using-server-functions-in-a-client-component  3 | export default function HizliEkle() {  4 |     async function ekle(formData: FormData) {> 5 |         "use server"    |         ^^^^^^^^^^^^  6 |         console.log("Eklenecek:", formData.get("isim"))  7 |     }  8 |

"İstemci bileşenlerinde satır içi "use server" fonksiyonu tanımlamak yasak. Bir istemci bileşeninde Server Action kullanmak için onu başında "use server" yazan ayrı bir dosyadan dışa aktar ya da bir sunucu bileşeninden props ile ver." İstemci bileşeninin kodu tarayıcıya iner; sunucu kodu oraya yazılamaz.

Nasıl düzelir? Fonksiyonu actions.ts gibi ayrı bir dosyaya taşı, dosyanın en üstüne "use server" yaz ve istemci bileşeninde import ile kullan. Bu dersteki düzen tam olarak bu.

Önbellek: yayında neden eski veri?

Geliştirme sunucusunda her şey çalıştı: Annie eklendi, liste güncellendi. Ders 43'ün sonunda yaptığın gibi siteyi bir de yayındaki hâliyle çalıştır. Sunucuyu Ctrl+C ile durdur ve derle:

Terminal — PowerShell
PS C:\kod\next-rehber> npm run build > next-rehber@0.1.0 build> next build ▲ Next.js 16.4.0 (Turbopack)- Environments: .env.local✓ Running next.config.ts took 54ms   Creating an optimized production build ...✓ Compiled successfully in 3.7s  Running TypeScript ...  Finished TypeScript in 1987ms ...  Collecting page data using 11 workers ...  Generating static pages using 11 workers (0/10) ...Eşya listesi hazırlanıyorŞampiyon listesi hazırlanıyor  Generating static pages using 11 workers (2/10)  Generating static pages using 11 workers (4/10)  Generating static pages using 11 workers (7/10)✓ Generating static pages using 11 workers (10/10) in 303ms  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/sampiyonlar├ ƒ /api/sampiyonlar/[id]├ ○ /esyalar├ ƒ /esyalar/[id]├ ○ /hakkinda├ ○ /sampiyonlar├ ƒ /sampiyonlar/[id]├ ○ /sampiyonlar/yeni└ ○ /yama-notlari  ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand

İki satıra dikkat et. Şampiyon listesi hazırlanıyor derleme sırasında yazıldı: Next sayfayı şimdi çizdi, yani veritabanını şimdi sorguladı. ○ /sampiyonlar da sonucun saklandığını söylüyor. Next'in mantığı şu: sayfa çerez, başlık ya da adres parametresi gibi isteğe bağlı hiçbir şey okumuyor, o hâlde her ziyaretçiye aynı HTML gidebilir; bir kez hazırla, sakla, hep onu gönder. Ders 43'teki dizi için bu mükemmeldi. Şimdi dene:

  1. npm run start ile yayın sunucusunu başlat.
  2. /sampiyonlar/yeni'den Vex'i ekle: Büyücü, zorluk 2, tanıtım "Her şeyden sıkılan, gölgesiyle saldıran küçük bir büyücü."
  3. Liste açılınca say: 7 şampiyon var. Vex yok. Sayfayı F5 ile yenile: yine 7.
  4. İkinci terminalde curl.exe http://localhost:3000/api/sampiyonlar iste: Vex orada. Veritabanında 8 şampiyon var.

Kayıt yazıldı ama sayfa eski. Sayfanın yanıt başlıklarına bak:

İkinci terminal
PS C:\kod\next-rehber> curl.exe -i http://localhost:3000/sampiyonlarHTTP/1.1 200 OKVary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cache: HITx-nextjs-prerender: 1x-nextjs-prerender: 1x-nextjs-stale-time: 300X-Powered-By: Next.jsCache-Control: s-maxage=31536000ETag: "of2vh28qxgamn"Content-Type: text/html; charset=utf-8Content-Length: 13876Date: Mon, 12 Oct 2026 02:05:24 GMTConnection: keep-aliveKeep-Alive: timeout=5
  • x-nextjs-cache: HIT: "bu cevap saklı kopyadan geldi"; sayfa bileşeni hiç çalışmadı, sorgu hiç gitmedi.
  • Cache-Control: s-maxage=31536000: araya giren önbelleklere (Ders 20'deki fikir) "bunu bir yıl saklayabilirsin" diyor. 31.536.000 saniye bir yıl.

API neden güncel? Route Handler'lar varsayılan olarak saklanmaz: derleme çıktısında ƒ /api/sampiyonlar, her istekte çalışır. Geliştirme sunucusunda da hiçbir sayfa saklanmaz; hata bu yüzden ancak yayında çıktı. "Bende çalışıyordu" cümlesinin en sık sebeplerinden biri budur.

revalidatePath yok Derleme 7 şampiyonlu HTML saklanır Formdan Vex veritabanında artık 8 şampiyon Sonraki istek saklı HTML gelir: 7 şampiyon ✗ revalidatePath var Derleme 8 şampiyonlu HTML saklanır Formdan Sona revalidatePath saklı kopya bayat Sonraki istek yeniden çizilir: 9 şampiyon ✓
Şekil 44.2 Derlemede saklanan HTML, veritabanı değişince kendiliğinden yenilenmez. revalidatePath saklı kopyayı "bayat" diye işaretler; sonraki istek sayfayı yeniden çizer.

revalidatePath: "bu sayfa bayatladı"

Çözüm tek satır. actions.ts'te, redirect'ten önce revalidatePath'i çağır:

app/sampiyonlar/actions.ts
"use server" import { revalidatePath } from "next/cache"import { redirect } from "next/navigation"import { pool } from "@/lib/db" export async function sampiyonEkle(formData: FormData) {    const isim = formData.get("isim")    const rol = formData.get("rol")    const zorluk = formData.get("zorluk")    const tanitim = formData.get("tanitim")     await pool.query(        "INSERT INTO sampiyonlar (isim, rol, zorluk, tanitim) VALUES ($1, $2, $3, $4)",        [isim, rol, zorluk, tanitim]    )     revalidatePath("/sampiyonlar")    redirect("/sampiyonlar")}

revalidatePath("/sampiyonlar") Next'e "/sampiyonlar'ın saklı kopyası artık geçersiz" der. Kopya hemen silinmez; bir sonraki istek geldiğinde sayfa yeniden çizilir, yeni HTML saklanır ve ondan sonrakiler yine saklı kopyadan beslenir. Sıra önemli: redirect bir hata fırlatarak çalışır, ondan sonraki satırlar hiç çalışmaz. revalidatePath'i altına yazarsan hiç çağrılmaz.

Durdur, npm run build, npm run start. Liste bu kez 8 şampiyon var diye açılır (derleme Vex'i gördü). Sona'yı ekle (Büyücü, zorluk 1, "Müziğiyle takımını iyileştiren sessiz bir büyücü."): liste hemen 9 şampiyon var der.

Sık hataYayında eski veri: revalidatePath unutuldu

Ne zaman çıkar? Bir Server Action ya da Route Handler veritabanını değiştirdiği hâlde, o veriyi gösteren statik (○) sayfayı yeniden doğrulamayınca. Hata mesajı yoktur; yalnız yanlış ekran vardır. Geliştirmede görünmez, çünkü npm run dev hiçbir sayfayı saklamaz. Yayında yeni kayıt sayfaya gelmez, F5 de işe yaramaz; curl.exe -i ile bakarsan x-nextjs-cache: HIT görürsün.

Nasıl düzelir? Veriyi değiştiren her fonksiyonda, değişikliğin göründüğü her sayfa için revalidatePath çağır. Bir şampiyonun sayfası da değişiyorsa onu da yeniden doğrula. Hepsini düşünmek zor geliyorsa önce derleme çıktısına bak: ○ olan ve veritabanı okuyan her sayfa adaydır.

Uygulamanın dışından gelen değişiklik

revalidatePath yalnız senin kodun çağırınca çalışır. Neon'un SQL Editor'ünden doğrudan bir satır eklersen Next'in haberi olmaz. Denedik: SQL Editor'de Brand'i ekledik, yayındaki sayfa 9 şampiyon var demeye devam etti ve başlık yine x-nextjs-cache: HIT idi. Veri senin uygulaman dışından da değişiyorsa iki yol var, ikisi de sayfa dosyasının üstüne tek satır:

app/sampiyonlar/page.tsx
export const revalidate = 60

revalidate = 60 "saklı kopya 60 saniye taze sayılsın" demek. Süre dolduktan sonraki ilk istek yine eski kopyayı alır ama arka planda yeniden çizimi başlatır; ondan sonrakiler yeni kopyayı görür. Beklememek için 10 saniyeyle denedik; derleme çıktısında sütunlar belirdi:

Terminal — PowerShell
Route (app)                Revalidate  Expire┌ ○ /├ ○ /_not-found├ ƒ /api/sampiyonlar├ ƒ /api/sampiyonlar/[id]├ ○ /esyalar├ ƒ /esyalar/[id]├ ○ /hakkinda├ ○ /sampiyonlar                  10s      1y├ ƒ /sampiyonlar/[id]├ ○ /sampiyonlar/yeni└ ○ /yama-notlari

Sonra SQL Editor'den Zyra'yı ekleyip sayfayı arka arkaya istedik:

Ne zamanx-nextjs-cacheSayfadaki sayı
Zyra eklenmeden önceHIT10
Ekledikten hemen sonraHIT10
11 saniye sonra, ilk istekSTALE10
Bir saniye sonraHIT11

STALE "bayat kopyayı verdim, yenisini hazırlıyorum" demek. İkinci yol her istekte yeniden çizmek: export const dynamic = "force-dynamic". Denediğimizde derleme çıktısında satır ƒ /sampiyonlar oldu ve Şampiyon listesi hazırlanıyor derlemede yazılmadı.

Üç yoldan hangisini ne zaman seçeceğin:

  • revalidatePath(...): veri yalnız senin uygulamandan değişiyorsa. Bedeli neredeyse yok; sayfa yalnız gerektiğinde yeniden çizilir.
  • export const revalidate = 60: veri dışarıdan da değişiyor ve birkaç dakikalık gecikme sorun değilse. Bedeli: ziyaretçi en fazla o süre kadar eski veri görebilir.
  • export const dynamic = "force-dynamic": her istekte en güncel veri şartsa. Bedeli: her ziyarette bir veritabanı sorgusu.

Bu derste listeyi ○ bırakıp revalidatePath kullanıyoruz; denemeleri yaptıysan revalidate satırını sil. Bir bonus: saklı sayfa veritabanına ulaşılamadığında bile açılır. Bunu dersin sonunda göreceksin.

Doğrulama: zod

Formu bir de kötü niyetle dene. Geliştirme sunucusunu başlat (npm run dev) ve iki gönderim yap.

Birincisi: isim Zed, zorluk 9. Sayfa hata katmanıyla durur, terminalde:

Terminal — PowerShell
⨯ error: new row for relation "sampiyonlar" violates check constraint "sampiyonlar_zorluk_check"    at async sampiyonEkle (app\sampiyonlar\actions.ts:13:5)  11 |     const tanitim = formData.get("tanitim")  12 |> 13 |     await pool.query(     |     ^  14 |         "INSERT INTO sampiyonlar (isim, rol, zorluk, tanitim) VALUES ($1, $2, $3, $4)",  15 |         [isim, rol, zorluk, tanitim]  16 |     ) {  length: 267,  severity: 'ERROR',  code: '23514',  ...  constraint: 'sampiyonlar_zorluk_check',  ...} POST /sampiyonlar/yeni 500 in 381ms (next.js: 1.4ms, application-code: 380ms)

Veritabanının CHECK kısıtı seni korudu, ama kullanıcı yalnız bir hata katmanı ve 500 gördü. Hangi alanın neden yanlış olduğunu söyleyen yok.

İkincisi daha kötü: isim ve tanıtım boş, zorluk 1. Gönderim başarılı oldu; listede adı olmayan bir kart çıktı. NOT NULL boş metni engellemez, çünkü boş metin NULL değildir (Ders 34). "Çalışıyor" ama doğru değil. Bu kaydı SQL Editor'de sil:

SQL
DELETE FROM sampiyonlar WHERE isim = '';

Kural Ders 13'ten beri aynı: dışarıdan gelen veri, programına girdiği kapıda doğrulanır. Form da, API'ye gelen JSON da, adres çubuğu da dışarısıdır. Ders 39'da bunun için sampiyonOku adında, typeof ve in ile on beş satırlık bir fonksiyon yazmıştın ve "gerçek projelerde bunu zod gibi kütüphanelerle kısaltırsın" demiştik. Sıra geldi.

İlk şema

zod'da verinin olması gereken şeklini bir şema olarak yazarsın, sonra gelen veriyi şemaya sorarsın. Ders 39'daki örneğin aynısını proje kökünde zod-dene.ts adlı geçici bir dosyada dene:

zod-dene.ts
import { z } from "zod" const KartSemasi = z.object({    isim: z.string(),    hp: z.number(),}) type Kart = z.infer<typeof KartSemasi> const teemo: Kart = KartSemasi.parse(JSON.parse('{"isim": "Teemo", "hp": 80}'))console.log(teemo) const sonuc = KartSemasi.safeParse(JSON.parse('{"isim": "Teemo", "hp": "80"}'))if (!sonuc.success) {    console.log(sonuc.error.issues)} KartSemasi.parse(JSON.parse('{"isim": "Teemo", "hpp": 80}'))
Terminal — PowerShell
PS C:\kod\next-rehber> node zod-dene.ts(node:9732) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///C:/kod/next-rehber/zod-dene.ts is not specified and it doesn't parse as CommonJS.Reparsing as ES module because module syntax was detected. This incurs a performance overhead.To eliminate this warning, add "type": "module" to \\?\C:\kod\next-rehber\package.json.(Use `node --trace-warnings ...` to show where the warning was created){ isim: 'Teemo', hp: 80 }[  {    expected: 'number',    code: 'invalid_type',    path: [ 'hp' ],    message: 'Invalid input: expected number, received string'  }]file:///C:/kod/next-rehber/zod-dene.ts:18KartSemasi.parse(JSON.parse('{"isim": "Teemo", "hpp": 80}'))           ^ ZodError: [  {    "expected": "number",    "code": "invalid_type",    "path": [      "hp"    ],    "message": "Invalid input: expected number, received undefined"  }]    at file:///C:/kod/next-rehber/zod-dene.ts:18:12    ... Node.js v24.20.0

İlk dört satır yalnız bir uyarı: Next projesinin package.json'ında "type": "module" yok, Node dosyayı önce eski modül sistemiyle okumayı deneyip vazgeçiyor (Ders 14). Bu deneme için zararsız; Next'in dosyalarını bozmamak için package.json'a dokunma. Sonra:

  • z.object({ ... }) bir şema kurar: "içinde metin olan bir isim ve sayı olan bir hp alanı olan obje". z.string() ve z.number() alanların kuralları.
  • KartSemasi.parse(veri) veriyi denetler. Veri şemaya uyuyorsa temiz bir kopyasını döndürür, uymuyorsa ZodError fırlatır. Son satırdaki hpp yazım hatası programı durdurdu.
  • KartSemasi.safeParse(veri) hiç fırlatmaz; bir sonuç objesi döndürür: ya { success: true, data } ya { success: false, error }. Metin olarak gelen "80" reddedildi; error.issues her sorunu yolu (path) ve mesajıyla listeliyor. Bir form ya da istek için çoğu zaman bu daha kullanışlı: programı durdurmak değil, kullanıcıya ne yanlış olduğunu söylemek istersin.
  • z.infer<typeof KartSemasi> şemadan bir TypeScript tipi çıkarır. Tipi ayrıca yazmazsın; şema değişince tip de değişir. parse'tan dönen değerin tipi zaten Kart'tır. Buradaki typeof, Ders 23'te çalışırken soru soran typeof değil: tip yazılan bir yerde "bu değişkenin TypeScript tipi" demek. Şimdilik kalıp olarak yaz: z.infer<typeof SemaninAdi>.

Denemeden sonra zod-dene.ts'i sil.

Şampiyon şeması

Formun şeması lib/sampiyonSemasi.ts dosyasında dursun; hem formda hem birazdan API'de kullanacaksın:

lib/sampiyonSemasi.ts
import { z } from "zod" export const SampiyonSemasi = z.object({    isim: z.string().trim().min(2, "İsim en az 2 harf olmalı.").max(20, "İsim en fazla 20 harf olabilir."),    rol: z.enum(["Büyücü", "Dövüşçü", "Nişancı"], "Rol listeden seçilmeli."),    zorluk: z.coerce.number("Zorluk bir sayı olmalı.").int("Zorluk tam sayı olmalı.").min(1, "Zorluk 1, 2 ya da 3 olmalı.").max(3, "Zorluk 1, 2 ya da 3 olmalı."),    tanitim: z.string().trim().min(10, "Tanıtım en az 10 harf olmalı.").max(200, "Tanıtım en fazla 200 harf olabilir."),}) export type YeniSampiyon = z.infer<typeof SampiyonSemasi> export type FormDurumu = {    hatalar?: Record<string, string[] | undefined>    degerler?: Record<string, string>}
  • Kurallar zincir hâlinde eklenir (Ders 9). .trim() baştaki ve sondaki boşlukları atar, sonra .min(2, "…") uzunluğu denetler: yalnız boşluk yazan biri de "en az 2 harf" uyarısını alır.
  • Her kuralın ikinci argümanı, kural bozulunca gösterilecek mesaj. Vermezsen zod İngilizce bir mesaj yazar (Too small: expected string to have >=2 characters).
  • z.enum([...]) yalnız listedeki değerlerden birini kabul eder. Biri tarayıcının geliştirici araçlarıyla <option>'ı değiştirip "Tank" gönderse bile geçemez.
  • z.coerce.number(): formdan her şey metin gelir, zorluk da "2". coerce (zorla çevir) değeri önce Number(...) ile sayıya çevirir, sonra sayı kurallarını uygular. "abc" çevrilince NaN olur ve "Zorluk bir sayı olmalı." mesajı çıkar.
  • FormDurumu: Server Action'ın forma geri vereceği cevabın tipi. Birazdan kullanacaksın. Record<string, …> Ders 42'den tanıdık: anahtarları metin olan obje.

Server Action'ı doğrulamaya bağla

Önce yazma işini veri katmanına taşı; birazdan API de aynı fonksiyonu kullanacak. lib/sampiyonlar.ts'in başına bir tip içe aktarması ekle:

lib/sampiyonlar.ts
import "server-only"import { pool } from "@/lib/db"import type { YeniSampiyon } from "@/lib/sampiyonSemasi"

ve ikonAdresi'nin üstüne yeni bir fonksiyon:

lib/sampiyonlar.ts
export async function sampiyonKaydet(veri: YeniSampiyon): Promise<Sampiyon> {    const { rows } = await pool.query<Sampiyon>(        "INSERT INTO sampiyonlar (isim, rol, zorluk, tanitim) VALUES ($1, $2, $3, $4) RETURNING id, isim, rol, zorluk, tanitim",        [veri.isim, veri.rol, veri.zorluk, veri.tanitim]    )    return rows[0]}

RETURNING eklenen satırı geri verir (Ders 35); API eklenen şampiyonu cevap olarak gösterecek. Şimdi actions.ts'in yeni hâli:

app/sampiyonlar/actions.ts
"use server" import { revalidatePath } from "next/cache"import { redirect } from "next/navigation"import { DatabaseError } from "pg"import { z } from "zod"import { sampiyonKaydet } from "@/lib/sampiyonlar"import { SampiyonSemasi, type FormDurumu } from "@/lib/sampiyonSemasi" function metin(formData: FormData, ad: string) {    const deger = formData.get(ad)    return typeof deger === "string" ? deger : ""} export async function sampiyonEkle(onceki: FormDurumu, formData: FormData): Promise<FormDurumu> {    const degerler = {        isim: metin(formData, "isim"),        rol: metin(formData, "rol"),        zorluk: metin(formData, "zorluk"),        tanitim: metin(formData, "tanitim"),    }     const sonuc = SampiyonSemasi.safeParse(degerler)    if (!sonuc.success) {        return { hatalar: z.flattenError(sonuc.error).fieldErrors, degerler }    }     try {        await sampiyonKaydet(sonuc.data)    } catch (hata) {        if (hata instanceof DatabaseError && hata.code === "23505") {            return { hatalar: { isim: [sonuc.data.isim + " zaten listede."] }, degerler }        }        throw hata    }     revalidatePath("/sampiyonlar")    redirect("/sampiyonlar")}

Yukarıdan aşağı:

  • metin: formData.get metin, dosya ya da null döndürebilir. Metin değilse (kutu hiç gönderilmediyse) boş metin sayıyoruz; böylece eksik alan da .min(...) kuralına takılır.
  • Fonksiyonun iki parametresi var: onceki (formun bir önceki cevabı) ve formData. Birazdan kullanacağın useActionState hook'u Server Action'ı bu biçimde çağırır. onceki'yi kullanmıyoruz ama sıra yüzünden yazmak zorundayız.
  • safeParse başarısızsa fonksiyon bir obje döndürür: hatalar ve kullanıcının yazdıkları. z.flattenError(sonuc.error).fieldErrors hataları alan adına göre gruplar: { isim: ["İsim en az 2 harf olmalı."], zorluk: [...] }. (İnternetteki örneklerde sonuc.error.flatten() görürsen: zod 4'te o yazım eskidi, yerine bu geldi.)
  • Başarılıysa sonuc.data temiz ve tipli veridir: isim kırpılmış, zorluk sayıya çevrilmiş. Veritabanına giden artık bu.
  • DatabaseError, pg'nin veritabanı hataları için kullandığı sınıf. instanceof ile (Ders 16) daraltınca TypeScript hata.code'u okumana izin verir. 23505 aynı ismin ikinci kez eklenmesi (Ders 37); onu alan hatasına çeviriyoruz, gerisini yeniden fırlatıyoruz.

Formun hataları gösterebilmesi için onu bir istemci bileşenine taşı. app/sampiyonlar/yeni/SampiyonFormu.tsx:

app/sampiyonlar/yeni/SampiyonFormu.tsx
"use client" import { useActionState } from "react"import { sampiyonEkle } from "../actions" export default function SampiyonFormu() {    const [durum, formAction, bekliyor] = useActionState(sampiyonEkle, {})    const hatalar = durum.hatalar ?? {}    const degerler = durum.degerler ?? {}     return (        <form action={formAction} className="form">            <label>                İsim                <input name="isim" defaultValue={degerler.isim} />                {hatalar.isim && <span className="alan-hatasi">{hatalar.isim[0]}</span>}            </label>            <label>                Rol                <select name="rol" key={degerler.rol} defaultValue={degerler.rol}>                    <option>Büyücü</option>                    <option>Dövüşçü</option>                    <option>Nişancı</option>                </select>                {hatalar.rol && <span className="alan-hatasi">{hatalar.rol[0]}</span>}            </label>            <label>                Zorluk (1–3)                <input name="zorluk" type="number" defaultValue={degerler.zorluk} />                {hatalar.zorluk && <span className="alan-hatasi">{hatalar.zorluk[0]}</span>}            </label>            <label>                Tanıtım                <textarea name="tanitim" rows={3} defaultValue={degerler.tanitim} />                {hatalar.tanitim && <span className="alan-hatasi">{hatalar.tanitim[0]}</span>}            </label>            <button type="submit" className="dugme" disabled={bekliyor}>                {bekliyor ? "Kaydediliyor…" : "Kaydet"}            </button>        </form>    )}

ve sayfa artık yalnız başlığı ve bu bileşeni çiziyor:

app/sampiyonlar/yeni/page.tsx
import type { Metadata } from "next"import SampiyonFormu from "./SampiyonFormu" export const metadata: Metadata = {    title: "Yeni şampiyon",} export default function YeniSampiyon() {    return (        <main>            <h1>Yeni şampiyon</h1>            <SampiyonFormu />        </main>    )}
  • useActionState(sampiyonEkle, {}): React'in hazır bir hook'u. İki şey alır: Server Action ve başlangıç durumu (boş obje). Üç şey verir: durum (Server Action'ın son döndürdüğü obje), formAction (formun action'ına vereceğin, Server Action'ı saran fonksiyon) ve bekliyor (istek sürerken true).
  • hatalar.isim && <span>…</span>: Ders 40'taki koşullu çizim. İsim alanında hata varsa ilk mesajı göster.
  • disabled={bekliyor} ve "Kaydediliyor…": istek sürerken düğme kapanır; sabırsız biri iki kez basıp iki kayıt yaratamaz.
  • defaultValue={degerler.isim}: React'te bir form gönderimi bitince kutular boşaltılır. Hata durumunda kullanıcının yazdıklarını kaybetmemesi için Server Action onları geri gönderiyor, kutular da başlangıç değeri olarak onları alıyor. select'teki key küçük bir hile: bu sıfırlamada select başlangıç değerini kendiliğinden geri almıyor; key değişince React onu yeniden kurar ve seçim yerinde kalır.

Formu A, Nişancı, 5, "Kısa" ile gönder:

localhost:3000/sampiyonlar/yeni
Yeni şampiyon formu: İsim kutusunda A ve altında kırmızı İsim en az 2 harf olmalı, Rol Nişancı, Zorluk 5 ve altında Zorluk 1, 2 ya da 3 olmalı, Tanıtım Kısa ve altında Tanıtım en az 10 harf olmalı; altta mor Kaydet düğmesi.

Üç alanın hatası birden geldi, yazdıkların yerinde kaldı, veritabanına hiçbir şey gitmedi. İsmi Ahri yapıp geçerli bir tanıtımla gönderirsen bu kez veritabanı itiraz eder ve isim kutusunun altında Ahri zaten listede. yazar.

Sık hataHata mesajı çıkınca yazdıkların siliniyor

Ne zaman çıkar? useActionState'li bir formda Server Action yalnız { hatalar } döndürüp kullanıcının yazdıklarını geri vermeyince. Hatalar doğru görünür ama bütün kutular boşalmıştır: React, formun eylemi bittikten sonra formu sıfırlar. Bunu degerler'i döndürmeden denedik; hatalı gönderimden sonra isim ve zorluk kutuları boştu, select ilk seçeneğe dönmüştü.

Nasıl düzelir? Server Action'dan yazılanları da döndür (return { hatalar, degerler }) ve kutulara defaultValue={degerler.isim} ver. select için key={degerler.rol} de ekle.

Sık hataA "use server" file can only export async functions, found object.

Ne zaman çıkar? "use server" yazan bir dosyadan async fonksiyon dışında bir şey dışa aktarınca. Şemayı "nasılsa formla ilgili" diye actions.ts'e koyup export const ile dışa aktardığımızda sayfa açıldı ama form gönderilince 500 geldi:

Terminal — PowerShell
⨯ Error: A "use server" file can only export async functions, found object.Read more: https://nextjs.org/docs/messages/invalid-use-server-value

Dışa aktarılan async olmayan bir fonksiyonda ise mesaj daha erken, sayfa açılırken gelir:

Terminal — PowerShell
⨯ ./app/sampiyonlar/actions.ts:10:17Error: Server Actions must be async functions.   8 | import { SampiyonSemasi, type FormDurumu } from "@/lib/sampiyonSemasi"   9 |> 10 | export function metin(formData: FormData, ad: string) {     |                 ^^^^^  11 |     const deger = formData.get(ad)  12 |     return typeof deger === "string" ? deger : ""  13 | }

"use server" dosyanın dışa aktardığı her şeyi tarayıcıdan çağrılabilir bir Server Action yapar; bir obje ya da senkron bir fonksiyon bu kalıba uymaz.

Nasıl düzelir? Şemayı, tipleri ve yardımcı fonksiyonları başka bir dosyada tut (lib/sampiyonSemasi.ts gibi). actions.ts'teki yardımcıları dışa aktarma: metin dosyanın içinde kalabilir, yalnız export yazma. Tipler (export type) sorun değildir; derlemede silinirler.

Aynı şema, ikinci kapı: POST API

Şemayı ayrı dosyaya koymanın kazancı şimdi geliyor. Bir mobil uygulama da şampiyon ekleyebilsin: app/api/sampiyonlar/route.ts'e bir POST ekle. Dosyanın son hâli:

app/api/sampiyonlar/route.ts
import { revalidatePath } from "next/cache"import { DatabaseError } from "pg"import { z } from "zod"import { sampiyonKaydet, tumSampiyonlar } from "@/lib/sampiyonlar"import { SampiyonSemasi } from "@/lib/sampiyonSemasi" export async function GET() {    const sampiyonlar = await tumSampiyonlar()    return Response.json(sampiyonlar)} export async function POST(istek: Request) {    const govde: unknown = await istek.json().catch(() => null)    const sonuc = SampiyonSemasi.safeParse(govde)     if (!sonuc.success) {        return Response.json(            { hata: "Geçersiz veri", alanlar: z.flattenError(sonuc.error).fieldErrors },            { status: 400 }        )    }     try {        const sampiyon = await sampiyonKaydet(sonuc.data)        revalidatePath("/sampiyonlar")        return Response.json(sampiyon, { status: 201 })    } catch (hata) {        if (hata instanceof DatabaseError && hata.code === "23505") {            return Response.json({ hata: sonuc.data.isim + " zaten listede" }, { status: 409 })        }        throw hata    }}
  • istek.json() gövdeyi JSON olarak okur ve bir Promise döndürür. Gövde bozuksa Promise reddedilir; .catch(() => null) (Ders 15) o durumda null verir, null da şemadan geçemez. govde'nin tipi bilerek unknown (Ders 39): ne geldiğini zod söyleyecek.
  • Formdakiyle aynı şema, aynı kayıt fonksiyonu. Fark yalnız cevapta: form için alan hataları, API için 400, 201 ve 409 durum kodları.
  • revalidatePath burada da var: API'den eklenen şampiyon da listeye düşmeli.

Proje klasöründe istekler adında bir klasör aç (Ders 38'deki gibi) ve üç dosya koy: nami.json → { "isim": "Nami", "rol": "Büyücü", "zorluk": 2, "tanitim": "Dalgaları yöneten, takımını hızlandıran bir büyücü." }, bozuk.json → { "isim": "N", "rol": "Tank", "zorluk": "kolay" }, yarim.json → { "isim": "Nami", (kapanmamış JSON). Sonra:

İkinci terminal
PS C:\kod\next-rehber> curl.exe -i --json "@istekler\nami.json" http://localhost:3000/api/sampiyonlarHTTP/1.1 201 Createdcontent-type: application/json {"id":14,"isim":"Nami","rol":"Büyücü","zorluk":2,"tanitim":"Dalgaları yöneten, takımını hızlandıran bir büyücü."}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\nami.json" http://localhost:3000/api/sampiyonlarHTTP/1.1 409 Conflictcontent-type: application/json {"hata":"Nami zaten listede"}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\bozuk.json" http://localhost:3000/api/sampiyonlarHTTP/1.1 400 Bad Requestcontent-type: application/json {"hata":"Geçersiz veri","alanlar":{"isim":["İsim en az 2 harf olmalı."],"rol":["Rol listeden seçilmeli."],"zorluk":["Zorluk bir sayı olmalı."],"tanitim":["Invalid input: expected string, received undefined"]}}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\yarim.json" http://localhost:3000/api/sampiyonlarHTTP/1.1 400 Bad Requestcontent-type: application/json {"hata":"Geçersiz veri","alanlar":{}}

(Başlıklardan vary, Date ve bağlantı satırlarını kısalttık.) Okuması öğretici:

  • "id":14: sende farklı olabilir. Başarısız eklemeler de sayaçtan numara harcar; Zed denemesi ve aynı ismi ikinci kez eklemeler böyle numaralar yedi.
  • bozuk.json'da dört alan da ayrı ayrı raporlandı. tanitim'in mesajı İngilizce: alan hiç gelmedi ve "metin olmalı" kuralına biz mesaj yazmadık; yalnız .min ve .max'e yazdık. İstersen z.string("Tanıtım yazılmalı.") diye ona da Türkçe mesaj verebilirsin.
  • yarim.json için alanlar boş: gövde hiç JSON değildi, alan hatası değil bütün gövdenin hatası. Bir sonraki adımda bunu da ayrı bir mesajla söylemek isteyebilirsin.

Ortam değişkenleri: sunucu ile istemci sınırı

DATABASE_URL'i lib/db.ts okuyor ve o dosya yalnız sunucuda çalışıyor. Peki bir istemci bileşeni process.env.DATABASE_URL'i okursa ne olur? Deneyelim; bu bir deney, uygulamanın parçası değil. app/deneme klasörü aç, içine iki dosya:

app/deneme/Gizli.tsx
"use client" export default function Gizli() {    const adres = process.env.DATABASE_URL    const site = process.env.NEXT_PUBLIC_SITE_ADI    console.log("Gizli bileşeni:", adres, site)     return <p>Adres: {String(adres)} · Site: {site}</p>}
app/deneme/page.tsx
import Gizli from "./Gizli" export default function Deneme() {    return (        <main>            <Gizli />        </main>    )}

.env.local'a da ikinci bir satır ekle: NEXT_PUBLIC_SITE_ADI=Şampiyon Rehberi. Sunucuyu yeniden başlat ve /deneme'yi aç. Ekranda:

Metin
Adres: undefined · Site: Şampiyon Rehberi

Tarayıcı DATABASE_URL'i göremedi, NEXT_PUBLIC_ ile başlayanı gördü. Kural bu, ama hikâyenin hepsi bu değil. Sol altta kırmızı 1 Issue düğmesi belirdi, F12 → Console'da kırmızı bir hata var ve terminale bak:

Terminal — PowerShell
Gizli bileşeni: postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/rehber?sslmode=verify-full&channel_binding=require Şampiyon Rehberi GET /deneme 200 in 341ms (next.js: 179ms, application-code: 163ms)[browser] Uncaught Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:...                          <Gizli>                            <p>+                             undefined-                             {"postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.n..."}

İlk satır console.log'un sunucudaki çıktısı: orada bağlantı dizesinin tamamı vardı. Sonra tarayıcıdaki hata, [browser] önekiyle terminale de taşınmış. Canlandırma başarısız oldu: "sunucunun çizdiği metin istemcinin çizdiğiyle uyuşmadı". + tarayıcının çizdiği (undefined), - sunucunun çizdiği (bağlantı dizesi). Ders 43'ten hatırla: istemci bileşenleri de ilk HTML için önce sunucuda çizilir. Sunucuda process.env.DATABASE_URL'in değeri vardı ve HTML'e yazıldı; tarayıcıda undefined çıktı ve React ağacı tarayıcıda baştan kurdu.

Sayfa kaynağına bak (Ctrl+U): şifre dahil bağlantı dizesinin tamamı orada.

HTML
<p>Adres: <!-- -->postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/rehber?sslmode=verify-full&amp;channel_binding=require<!-- --> · Site: <!-- -->Şampiyon Rehberi</p>

Ekranda undefined görüp "güvendeyim" sanabilirsin; değilsin. Biz yayın derlemesinde de denedik: Next bu sayfayı derlemede çizip sakladı ve saklanan HTML dosyasında şifre yine vardı. Sır, sayfayı açan herkese gitti.

Sık hataİstemci bileşeninde sunucu sırrı: undefined ve Hydration failed

Ne zaman çıkar? "use client" yazan bir dosyada NEXT_PUBLIC_ ile başlamayan bir ortam değişkeni okununca. Tarayıcıda değer undefined'dır; konsolda Hydration failed because the server rendered text didn't match the client. çıkar, çünkü aynı bileşen sunucuda gerçek değerle çizilmiştir. Değeri ekrana yazdırdıysan sır sayfanın HTML'ine girmiştir.

Nasıl düzelir? Sırrı kullanan işi sunucuda yap: veriyi bir sunucu bileşeninde ya da Server Action'da oku, istemci bileşenine yalnız sonucu props olarak ver. Sır bir kez HTML'e girdiyse onu yenile: Neon'da şifreyi sıfırla (Ders 37).

NEXT_PUBLIC_: derlemede koda gömülür

Peki NEXT_PUBLIC_SITE_ADI tarayıcıya nasıl ulaştı? npm run build sonrası tarayıcıya gönderilen dosyalardan birinde bileşen şöyle görünüyor (derlenmiş koddan bir parça; adlar kısaltılmış):

Metin
let e=i.default.env.DATABASE_URL,l="Şampiyon Rehberi";return console.log("Gizli bileşeni:",e,l)

Fark açık: DATABASE_URL hâlâ bir okuma (env.DATABASE_URL); tarayıcıda okunacak bir değer yok, undefined çıkıyor. NEXT_PUBLIC_SITE_ADI ise gitmiş, yerine değeri düz metin olarak yazılmış. Next derlerken process.env.NEXT_PUBLIC_… geçen her yeri değerle değiştirir. İki sonucu var:

  • Değer derlemede donar. .env.local'daki site adını değiştirirsen yeniden derlemen gerekir.
  • Değer, sitenin JavaScript dosyalarını indiren herkese açıktır.

Şimdi en kötü "düzeltme"yi dene. .env.local'a NEXT_PUBLIC_DATABASE_URL= ile başlayan bir satır ekleyip bağlantı dizesini oraya da koy, Gizli.tsx'te process.env.NEXT_PUBLIC_DATABASE_URL oku ve derle. Ekranda artık undefined yok; "çalıştı". Sonra tarayıcıya giden dosyalarda şifreyi ara; aşağıdaki komutta AbC123dEf yerine kendi şifreni yaz (bağlantı dizesinde neondb_owner: ile @ arasındaki kısım). findstr Windows'un metin arama komutu: /s alt klasörlere de bakar, /m yalnız aranan metnin geçtiği dosyaların adını yazar.

Terminal — PowerShell
PS C:\kod\next-rehber> findstr /s /m "AbC123dEf" .next\static\*.next\static\chunks\16v888mny51z0.js

Şifre, sitenin herkese açık JavaScript dosyalarından birinde (dosya adı sende farklı olur). İçinde e="postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-…" yazıyor. Bu dosyayı sitene giren herkes indirir; bağlantı dizesini alan biri veritabanını silebilir.

.env.local DATABASE_URL=… NEXT_PUBLIC_SITE_ ADI=Şampiyon… Sunucu kodu ikisini de okur sunucu bileşeni, Server Action, Route Handler Tarayıcıya giden JS DATABASE_URL → undefined NEXT_PUBLIC_ ile başlayanın değeri metin olarak gömülür hepsi yalnız NEXT_PUBLIC_ Tarayıcıya giden JavaScript'i sayfayı açan herkes indirir.
Şekil 44.3 Sunucu kodu her ortam değişkenini okur. Tarayıcıya giden JavaScript'e yalnız NEXT_PUBLIC_ ile başlayanlar girer, hem de derlemede düz metin olarak.

Kural kısa: NEXT_PUBLIC_ yalnız herkesin görmesinde sakınca olmayan değerler içindir. Site adı, herkese açık bir analiz kimliği, sitenin kendi adresi. Şifre, API anahtarı, bağlantı dizesi asla. Ders 47'de bir yapay zekâ API'sine bağlanırken bu kuralı yeniden göreceksin.

.env.local satırıSunucu kodundaİstemci bileşenindeNerede kullan
DATABASE_URL=…Gerçek değerundefined (ama sunucudaki ilk çizimde değer var)Sırlar: yalnız sunucuda
NEXT_PUBLIC_SITE_ADI=…Gerçek değerDerlemede gömülen değerHerkese açık ayarlar

server-only: yanlış içe aktarmayı derlemede yakalamak

Sırrı istemciye taşımanın bir yolu daha var: istemci bileşeninde, sırrı kullanan bir dosyayı içe aktarmak. lib/db.ts'in başındaki import "server-only" bunun için. Deneme dosyasını şöyle değiştir:

app/deneme/Gizli.tsx
"use client" import { tumSampiyonlar } from "@/lib/sampiyonlar" export default function Gizli() {    console.log(tumSampiyonlar)    return <p>Deneme</p>}
Sık hata'server-only' cannot be imported from a Client Component module

Ne zaman çıkar? Başında import "server-only" yazan bir dosyayı (ya da onu içe aktaran bir dosyayı) bir istemci bileşenine aktarınca. Sayfa 500 verir, terminal aynı şeyi birkaç kez söyler; en açık olanı:

Terminal — PowerShell
./lib/db.ts:1:1Error: 'server-only' cannot be imported from a Client Component module> 1 | import "server-only"    | ^^^^^^^^^^^^^^^^^^^^  2 | import { Pool } from "pg"  3 |  4 | if (!process.env.DATABASE_URL) { It should only be used from a Server Component. Import traces:  Client Component Browser:    ./lib/db.ts [Client Component Browser]    ./lib/sampiyonlar.ts [Client Component Browser]    ./app/deneme/Gizli.tsx [Client Component Browser]

"server-only bir istemci bileşeni modülünden içe aktarılamaz; yalnız sunucu bileşenlerinden kullanılmalı." Import traces zinciri gösteriyor: Gizli.tsx → lib/sampiyonlar.ts → lib/db.ts. Aynı çıktıda bir de This API is only available in Server Components in the App Router, but you are using it in the Pages Router. satırı görürsün; "Pages Router" sözüne takılma, anlatmak istediği aynı şey. server-only olmasaydı da derleme dururdu, ama sebebi bulmak zor olurdu: denediğimizde Next, pg'nin tarayıcıda olmayan Node modüllerini aradı ve Module not found: Can't resolve 'dns' dedi.

Nasıl düzelir? Veriyi istemci bileşeninde okuma. Sunucu bileşeninde oku ve istemci bileşenine props olarak ver; ya da tarayıcıdan bir Route Handler'ı çağır.

Denemelerin bitti: app/deneme klasörünü sil, .env.local'daki NEXT_PUBLIC_ satırlarını kaldır. Bağlantı dizesini gerçekten bir NEXT_PUBLIC_ değişkenine koyup derlediysen ve o derlemeyi bir yere yüklediysen şifreyi Neon'da sıfırla.

Yükleniyor ve hata sayfaları

Ders 42'de veri çeken her bileşen üç durumu kendisi yönetiyordu: yükleniyor, hata, veri. Next.js'te bunlar için iki özel dosya var. Sayfa (page.tsx) veri durumunu zaten çiziyor; ötekiler:

  • loading.tsx: aynı klasördeki sayfa verisini beklerken gösterilecek bileşen.
  • error.tsx: sayfa çizilirken bir hata fırlatılırsa sayfanın yerine gösterilecek bileşen.

loading.tsx

Neon'un ücretsiz veritabanı beş dakika sorgu almazsa uyur (Ders 38); uyandıran ilk sorgu bir iki saniye sürebilir. O sürede kullanıcı tıkladığı bağlantıdan hiçbir tepki görmez. app/sampiyonlar/loading.tsx:

app/sampiyonlar/loading.tsx
export default function Yukleniyor() {    return (        <main>            <p className="soluk">Şampiyonlar yükleniyor…</p>            <div className="kartlar">                <div className="iskelet" />                <div className="iskelet" />                <div className="iskelet" />            </div>        </main>    )}

Gri kutular (.iskelet) gelecek kartların yerini tutar; bu tür ekranlara iskelet denir. Bilgisayarında sorgu çok hızlı olacağı için ekranı göremeyebilirsin. Görmek için liste sayfasına geçici bir bekleme ekle: Ders 15'teki bekle'nin tipli hâli.

app/sampiyonlar/page.tsx
function bekle(ms: number) {    return new Promise((resolve) => setTimeout(resolve, ms))} export default async function SampiyonListesi() {    await bekle(2000)

Ana sayfadan menüdeki Şampiyonlar'a tıkla:

localhost:3000/sampiyonlar
Şampiyon Rehberi menüsünün altında Şampiyonlar yükleniyor yazısı ve yan yana üç boş gri kart.
İlk iki saniye. Menü yerinde, adres çubuğu çoktan değişti.

Tıklama anında karşılık verdi; iki saniye sonra kartlar iskeletin yerine geçti. Next, loading.tsx'i sayfanın etrafına bir React Suspense sınırı olarak koyar: sayfa bekleyen bir await'e takılınca sınır yedek içeriği (loading.tsx) gösterir, sayfa hazır olunca yerine koyar. Doğrudan adresle açınca da önce iskelet, sonra liste gelir: sunucu HTML'i parça parça, bir akış olarak gönderir. Denemeden sonra bekle'yi sil.

error.tsx

Şimdi veritabanına ulaşılamadığını düşün: Neon'da bir kesinti, bozuk bir bağlantı dizesi. app/sampiyonlar/error.tsx:

app/sampiyonlar/error.tsx
"use client" type Props = {    error: Error & { digest?: string }    retry: () => void} export default function HataSayfasi({ error, retry }: Props) {    return (        <main>            <h1>Bir şeyler ters gitti</h1>            <p className="soluk">Şampiyonlar şu an getirilemedi. Biraz sonra yeniden dene.</p>            <p className="soluk">Hata kodu: {error.digest}</p>            <button className="dugme" onClick={() => retry()}>Yeniden dene</button>        </main>    )}
  • "use client" şart: hata sınırı React'te tarayıcıda çalışan bir yapı ve düğmenin onClick'i var.
  • error: fırlatılan hata. digest, Next'in her sunucu hatasına verdiği kimlik numarası.
  • retry: segmenti, yani bu klasörün sayfasını sunucudan yeniden ister ve çizmeyi dener. Next 16.3'ten beri adı bu; eski örneklerde reset görürsün. reset hâlâ var ama veriyi yeniden istemeden yalnız yeniden çizer; veritabanı hatası için işe yaramaz.

Dene: .env.local'daki sunucu adını boz (neon.tech → neon.tch), sunucuyu yeniden başlat ve Şampiyonlar'a tıkla:

localhost:3000/sampiyonlar
Menünün altında Bir şeyler ters gitti başlığı, Şampiyonlar şu an getirilemedi yazısı, Hata kodu 1620765663 satırı ve mor Yeniden dene düğmesi.
Geliştirme sunucusunda sol altta bir de kırmızı 1 Issue düğmesi çıkar; ayrıntı onda.

Menü yerinde, sitenin geri kalanı çalışıyor; yalnız /sampiyonlar segmenti hata ekranına döndü. Terminalde asıl hata ve aynı numara:

Terminal — PowerShell
⨯ Error: getaddrinfo ENOTFOUND ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tch    at async tumSampiyonlar (lib\sampiyonlar.ts:14:22)    at async SampiyonListesi (app\sampiyonlar\page.tsx:12:25)  12 |  13 | export async function tumSampiyonlar(): Promise<Sampiyon[]> {> 14 |     const { rows } = await pool.query<Sampiyon>(     |                      ^  15 |         "SELECT id, isim, rol, zorluk, tanitim FROM sampiyonlar ORDER BY isim"  16 |     )  17 |     return rows {  errno: -3008,  code: 'ENOTFOUND',  syscall: 'getaddrinfo',  hostname: 'ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tch',  digest: '1620765663'} GET /sampiyonlar 200 in 227ms (next.js: 14ms, application-code: 213ms)

Kullanıcı ekranda 1620765663 görüyor, sen terminalde (yayında: barındırıcının loglarında, Ders 32) aynı digest'i arayıp asıl hatayı buluyorsun. Adı düzelt, sunucuyu yeniden başlat ve Yeniden dene'ye bas: tarayıcı segmenti yeniden ister, liste gelir.

Ekranda neden error.message yerine numara gösterdik? Yayında Next, sunucu bileşeninden gelen hatanın mesajını tarayıcıya göndermez; mesajın içinde bağlantı dizesi, dosya yolu, SQL gibi sızmaması gereken şeyler olabilir. Yayın derlemesinde error.message'ı ekrana yazdırarak denedik, gelen şuydu: Minified React error #441; visit https://react.dev/errors/441 for the full message…. React'in hata listesinde 441, "Sunucu bileşenleri çizilirken bir hata oldu; ayrıntı, hassas bilgi sızmasın diye yayın derlemelerinde gizlendi; digest alanı ayrıntıya götürebilir" demek. Geliştirmede mesajın kendisi gelir, yayında gelmez: ekrana koymaya değmez.

Bir şey daha. Liste sayfası yayında ○'tı; yayın sunucusunda veritabanını kapatıp denedik: liste saklı kopyadan sorunsuz açıldı, yalnız ƒ olan şampiyon sayfasına girince error.tsx çıktı. Önbelleğin sessiz bir faydası.

Sık hatalar

Bölümlerin içindeki kartlara ek olarak bir tane daha. Hepsine hata rehberinden de ulaşabilirsin.

Sık hataError occurred prerendering page "/sampiyonlar".

Ne zaman çıkar? npm run build sırasında veritabanına ulaşılamayınca. Statik (○) bir sayfa derlemede çizildiği için sorgusu da derlemede çalışır; bağlantı dizesi yoksa, yanlışsa ya da internet yoksa derleme durur. Bozuk sunucu adıyla denedik:

Terminal — PowerShell
PS C:\kod\next-rehber> npm run build...Error occurred prerendering page "/sampiyonlar". Read more: https://nextjs.org/docs/messages/prerender-errorError: getaddrinfo ENOTFOUND ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tch    at async f (lib\sampiyonlar.ts:14:22)    at async i (app\sampiyonlar\page.tsx:12:25)...Export encountered an error on /sampiyonlar/page: /sampiyonlar, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null

"/sampiyonlar sayfası önceden çizilirken hata oldu." Bir barındırıcıda derlerken de aynısı olur: oradaki ortam değişkenlerinde DATABASE_URL yoksa yayın başarısız olur.

Nasıl düzelir? Derlemenin çalıştığı yerde DATABASE_URL tanımlı ve veritabanı ulaşılabilir olmalı. Kendi bilgisayarında .env.local'ı denetle. Sayfanın derlemede hiç çizilmemesi gerekiyorsa export const dynamic = "force-dynamic" de bir çözüm, ama bedelini önbellek bölümünde gördün.

Görevler

Görevleri aynı next-rehber projesinde, sırayla yap: her biri bir öncekinin üstüne kuruluyor. Hepsi dükkânla ilgili: schema.sql ile kurduğun esyalar tablosu ve Ders 43'teki eşya sayfaları. Her görevden sonra tarayıcıda ve curl.exe ile dene; en sonda npx tsc --noEmit ve npm run lint sessiz kalmalı. Önce kendin dene, takılırsan ipuçlarını sırayla aç.

Görev 1

Eşyalar veritabanından

Ders 43'teki /esyalar ve /esyalar/[id] sayfaları hâlâ lib/esyalar.ts'teki diziyi okuyor. Onları esyalar tablosuna bağla. Liste fiyata göre artan sırada olsun ve Dükkânda 3 eşya var. yazsın. Eşya sayfası, satın al düğmesiyle birlikte eskisi gibi çalışsın.

Sınırlar: /esyalar/3 (son eşya) açılmalı. /esyalar/0, /esyalar/4, /esyalar/abc ve /esyalar/9999999999 404 dönmeli, hiçbiri 500 vermemeli.

İpucu 1

lib/esyalar.ts'i lib/sampiyonlar.ts'in kalıbıyla baştan yaz: import "server-only", pool, Esya tipi, tumEsyalar() ve esyaBul(id). Diziyi sil.

İpucu 2

İki sayfa da async olmalı ve veriyi await ile almalı. [id]/page.tsx'teki dosyaya özel esyaBul'u sil, lib'den içe aktar; iki çağrının ikisine de await yaz.

İpucu 3

9999999999 on basamaklı: Postgres'in integer'ına sığmaz ve sorgu 500 ile patlar. sampiyonBul'daki /^\d{1,9}$/ denetimini buraya da koy.

Çözümü göster
lib/esyalar.ts
import "server-only"import { pool } from "@/lib/db" export type Esya = {    id: number    ad: string    fiyat: number    aciklama: string} export async function tumEsyalar(): Promise<Esya[]> {    const { rows } = await pool.query<Esya>(        "SELECT id, ad, fiyat, aciklama FROM esyalar ORDER BY fiyat"    )    return rows} export async function esyaBul(id: string): Promise<Esya | undefined> {    if (!/^\d{1,9}$/.test(id)) {        return undefined    }     const { rows } = await pool.query<Esya>(        "SELECT id, ad, fiyat, aciklama FROM esyalar WHERE id = $1",        [id]    )    return rows[0]}
app/esyalar/page.tsx
import type { Metadata } from "next"import Link from "next/link"import { tumEsyalar } from "@/lib/esyalar" export const metadata: Metadata = {    title: "Eşyalar",} export default async function EsyaListesi() {    const esyalar = await tumEsyalar()     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>    )}
app/esyalar/[id]/page.tsx
import type { Metadata } from "next"import Link from "next/link"import { notFound } from "next/navigation"import { esyaBul } from "@/lib/esyalar"import SatinAlDugmesi from "./SatinAlDugmesi" type Props = {    params: Promise<{ id: string }>} export async function generateMetadata({ params }: Props): Promise<Metadata> {    const { id } = await params    const esya = await esyaBul(id)    return { title: esya ? esya.ad : "Bulunamadı" }} export default async function EsyaSayfasi({ params }: Props) {    const { id } = await params    const esya = await 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>            <SatinAlDugmesi fiyat={esya.fiyat} />        </main>    )}

Ders 43'teki console.log("Eşya listesi hazırlanıyor") satırını sildik; istersen bırakabilirsin. Denediğimizde /esyalar/1 (Uzun Kılıç) ve /esyalar/3 (Ağır Zırh) 200; /esyalar/0, /esyalar/4, /esyalar/abc ve /esyalar/9999999999 404 döndü ve "Bu sayfa haritada yok" sayfası açıldı. SatinAlDugmesi'ne dokunmadık: fiyat veritabanından da sayı olarak geliyor.

Görev 2

Eşya API'si

Bir Discord botu dükkânı okuyabilsin. İki uç nokta yaz: GET /api/esyalar bütün eşyaları JSON olarak versin, GET /api/esyalar/2 tek eşyayı. Olmayan bir eşya için 404 ve {"hata":"Eşya bulunamadı"} dönsün.

Sınırlar: /api/esyalar/3 bulunmalı; /api/esyalar/0 ve /api/esyalar/abc 404 dönmeli; curl.exe -X DELETE http://localhost:3000/api/esyalar/2 405 vermeli.

İpucu 1

Klasörler app/api/esyalar ve app/api/esyalar/[id], dosyaların adı route.ts. İçlerinden export async function GET dışa aktar.

İpucu 2

Görev 1'de yazdığın tumEsyalar ve esyaBul'u kullan. [id] için ikinci parametrenin tipi RouteContext<"/api/esyalar/[id]">; npx tsc --noEmit bu tipi tanımazsa npx next typegen çalıştır.

Çözümü göster
app/api/esyalar/route.ts
import { tumEsyalar } from "@/lib/esyalar" export async function GET() {    const esyalar = await tumEsyalar()    return Response.json(esyalar)}
app/api/esyalar/[id]/route.ts
import { esyaBul } from "@/lib/esyalar" export async function GET(istek: Request, { params }: RouteContext<"/api/esyalar/[id]">) {    const { id } = await params    const esya = await esyaBul(id)     if (!esya) {        return Response.json({ hata: "Eşya bulunamadı" }, { status: 404 })    }     return Response.json(esya)}
İkinci terminal
PS C:\kod\next-rehber> curl.exe http://localhost:3000/api/esyalar[{"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."}]PS C:\kod\next-rehber> curl.exe http://localhost:3000/api/esyalar/2{"id":2,"ad":"Yakut Kristali","fiyat":400,"aciklama":"Can puanını artırır."}PS C:\kod\next-rehber> curl.exe -i http://localhost:3000/api/esyalar/abcHTTP/1.1 404 Not Foundvary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-type: application/jsonDate: Mon, 12 Oct 2026 02:26:18 GMTConnection: keep-aliveKeep-Alive: timeout=5Transfer-Encoding: chunked {"hata":"Eşya bulunamadı"}

/api/esyalar/0 da aynı 404'ü, DELETE ise 405'i verdi. abc'yi sorgu hiç görmedi; Görev 1'deki denetim esyaBul'un içinde olduğu için API de ondan yararlanıyor.

Görev 3

Dükkânın yükleniyor ve hata ekranları

/esyalar için kendi loading.tsx ve error.tsx dosyalarını yaz. Yüklenirken "Dükkân açılıyor…" yazsın. Hata olursa "Dükkân şu an kapalı" başlığı, hatanın digest numarası ve bir Yeniden dene düğmesi görünsün.

Dene: .env.local'daki sunucu adını boz, sunucuyu yeniden başlat ve /esyalar'ı aç; hata ekranı çıkmalı ve terminaldeki digest ekrandakiyle aynı olmalı. Adı düzeltip sunucuyu yeniden başlat ve Yeniden dene'ye bas: eşyalar gelmeli. Sonra curl.exe -i http://localhost:3000/esyalar/0 ile durum koduna bak ve gördüğünü açıkla.

İpucu 1

İki dosya da app/esyalar klasörüne. error.tsx'in ilk satırı ne olmalı?

İpucu 2

error.tsx iki prop alır: error (içinde digest) ve retry. Şampiyonlardaki dosyayı kopyalamadan, tipini kendin yazarak kur.

Çözümü göster
app/esyalar/loading.tsx
export default function DukkanYukleniyor() {    return (        <main>            <p className="soluk">Dükkân açılıyor…</p>        </main>    )}
app/esyalar/error.tsx
"use client" type Props = {    error: Error & { digest?: string }    retry: () => void} export default function DukkanHatasi({ error, retry }: Props) {    return (        <main>            <h1>Dükkân şu an kapalı</h1>            <p className="soluk">Eşyalar getirilemedi. Hata kodu: {error.digest}</p>            <button className="dugme" onClick={() => retry()}>Yeniden dene</button>        </main>    )}

/esyalar/0 artık 404 değil 200 döner ve HTML'de <meta name="robots" content="noindex"/> vardır: loading.tsx eşya sayfasını da sardığı için akış başlıyor ve durum kodu baştan 200 gidiyor. Ekranda yine "Bu sayfa haritada yok" görünür. Denediğimizde Görev 1'deki 404'ler loading.tsx'i ekleyince böyle 200 + noindex'e döndü.

error.tsx'e "use client" yazmayı unutursan Next sayfayı açmaz ve terminale Error: app/esyalar/error.tsx must be a Client Component. Add the "use client" directive the top of the file to resolve this issue. yazar: hata sınırları istemci bileşeni olmak zorunda.

Görev 4

Fiyatların yaması

Eşya fiyatları oyunun yamasına göre değişir. Eşya sayfasında, Satın al düğmesinin altında Fiyatlar 16.20 yamasına göre. yazsın. Yama numarası koda yazılmasın, .env.local'daki bir değişkenden gelsin. Satırı Ders 43'te yazdığın SatinAlDugmesi istemci bileşenine ekle.

Sınırlar (önce sonucu tahmin et, sonra dene):

  • Önek olmadan: .env.local'a YAMA=16.20 yaz, bileşende process.env.YAMA oku ve geliştirme sunucusunu yeniden başlat. Ekranda ne yazıyor, sayfa kaynağında (Ctrl+U) ne yazıyor?
  • Doğru adla yayın derlemesi al (npm run build, npm run start): ekranda 16.20 olmalı. Sonra .env.local'da değeri 16.21 yap ve derlemeden yalnız npm run start'ı yeniden başlat. Sayfa hangi numarayı gösteriyor? Yeniden derleyince ne değişiyor?
İpucu 1

İstemci bileşeni hangi önekle başlayan değişkenleri görebiliyordu? Adı .env.local'da ve bileşende harfi harfine aynı yaz.

İpucu 2

NEXT_PUBLIC_ değerleri koda ne zaman yazılıyordu: sunucu başlarken mi, derlerken mi? Eşya sayfasının ƒ olması, yani her istekte sunucuda çizilmesi cevabı değiştirir mi?

Çözümü göster

.env.local'a ikinci satır:

.env.local
NEXT_PUBLIC_YAMA=16.20

SatinAlDugmesi.tsx'te düğmenin altına tek satır:

app/esyalar/[id]/SatinAlDugmesi.tsx
            </button>            <p className="soluk">Fiyatlar {process.env.NEXT_PUBLIC_YAMA} yamasına göre.</p>        </div>

Denediğimizde:

  • Öneksiz YAMA ile ekranda Fiyatlar yamasına göre. yazdı (numaranın yeri boş) ve konsolda Hydration failed hatası çıktı. Sayfa kaynağında ise Fiyatlar <!-- -->16.20<!-- --> yamasına göre. vardı: bileşen ilk HTML için sunucuda çizilirken değeri okudu, tarayıcıda okuyamadı. Dersteki DATABASE_URL denemesinin aynısı, bu kez zararsız bir değerle.
  • NEXT_PUBLIC_YAMA=16.20 ile derleyip başlatınca ekranda Fiyatlar 16.20 yamasına göre. yazdı. Değeri 16.21 yapıp yalnız npm run start'ı yeniden başlatınca hem ekran hem sayfa kaynağı hâlâ 16.20 dedi. npm run build ve npm run start'tan sonra 16.21 geldi.

Eşya sayfası ƒ, yani her istekte sunucuda çiziliyor; yine de eski değer çıktı. Next NEXT_PUBLIC_ değerini derlerken hem tarayıcıya giden koda hem sunucu koduna gömüyor. Yayındaki sitede yama numarasını değiştirmek için yeniden derleyip yayınlaman gerekir.

Görev 5

Dükkâna yeni eşya

/esyalar/yeni adresinde bir form yap: ad, fiyat ve açıklama. Bir Server Action eşyayı kaydetsin, listeye yönlendirsin; hatalı girdide her alanın altında Türkçe bir mesaj çıksın ve yazılanlar silinmesin. Kurallar: ad 2–30 harf, fiyat 0 ile 5000 arasında bir tam sayı, açıklama 5–120 harf.

Sınırlar (hepsini dene):

  • Fiyat 0 ve 5000 kabul edilmeli; -1 ve 5001 reddedilmeli.
  • Fiyat kutusu boş bırakılırsa eşya kaydedilmemeli. Bedava eşya ile fiyatı unutulmuş eşya aynı şey değil.
  • Ad Uzun Kılıç ise Uzun Kılıç zaten dükkânda. yazmalı.
  • Yayında (npm run build, npm run start) yeni eşya listeye hemen düşmeli.
İpucu 1

Şampiyon formunun kalıbı: lib/esyaSemasi.ts (şema, YeniEsya tipi, form durumu tipi), lib/esyalar.ts'e bir esyaKaydet, app/esyalar/actions.ts ("use server", useActionState için iki parametre), app/esyalar/yeni/EsyaFormu.tsx (istemci) ve app/esyalar/yeni/page.tsx. revalidatePath("/esyalar")'ı redirect'ten önce yaz.

İpucu 2

Boş fiyat kaydedildiyse z.coerce.number()'ın tuzağına düştün: Number("") sıfırdır. Fiyat için coerce kullanma, z.number() kullan; formdan gelen metni sayıya kendin çevir.

İpucu 3

Çevirirken boş metni NaN'a çevir ki z.number() reddetsin: metni.trim() === "" ? NaN : Number(metni). Şemaya { ...degerler, fiyat: sayi(degerler.fiyat) } ver; forma geri gönderdiğin degerler yine metin kalsın.

Çözümü göster
lib/esyaSemasi.ts
import { z } from "zod" export const EsyaSemasi = z.object({    ad: z.string().trim().min(2, "Ad en az 2 harf olmalı.").max(30, "Ad en fazla 30 harf olabilir."),    fiyat: z.number("Fiyat bir sayı olmalı.").int("Fiyat tam sayı olmalı.").min(0, "Fiyat eksi olamaz.").max(5000, "Fiyat en fazla 5000 olabilir."),    aciklama: z.string().trim().min(5, "Açıklama en az 5 harf olmalı.").max(120, "Açıklama en fazla 120 harf olabilir."),}) export type YeniEsya = z.infer<typeof EsyaSemasi> export type EsyaFormDurumu = {    hatalar?: Record<string, string[] | undefined>    degerler?: Record<string, string>}

lib/esyalar.ts'in başına import type { YeniEsya } from "@/lib/esyaSemasi" ekle ve sonuna:

lib/esyalar.ts
export async function esyaKaydet(veri: YeniEsya): Promise<Esya> {    const { rows } = await pool.query<Esya>(        "INSERT INTO esyalar (ad, fiyat, aciklama) VALUES ($1, $2, $3) RETURNING id, ad, fiyat, aciklama",        [veri.ad, veri.fiyat, veri.aciklama]    )    return rows[0]}
app/esyalar/actions.ts
"use server" import { revalidatePath } from "next/cache"import { redirect } from "next/navigation"import { DatabaseError } from "pg"import { z } from "zod"import { esyaKaydet } from "@/lib/esyalar"import { EsyaSemasi, type EsyaFormDurumu } from "@/lib/esyaSemasi" function metin(formData: FormData, ad: string) {    const deger = formData.get(ad)    return typeof deger === "string" ? deger : ""} function sayi(metni: string) {    return metni.trim() === "" ? NaN : Number(metni)} export async function esyaEkle(onceki: EsyaFormDurumu, formData: FormData): Promise<EsyaFormDurumu> {    const degerler = {        ad: metin(formData, "ad"),        fiyat: metin(formData, "fiyat"),        aciklama: metin(formData, "aciklama"),    }     const sonuc = EsyaSemasi.safeParse({ ...degerler, fiyat: sayi(degerler.fiyat) })    if (!sonuc.success) {        return { hatalar: z.flattenError(sonuc.error).fieldErrors, degerler }    }     try {        await esyaKaydet(sonuc.data)    } catch (hata) {        if (hata instanceof DatabaseError && hata.code === "23505") {            return { hatalar: { ad: [sonuc.data.ad + " zaten dükkânda."] }, degerler }        }        throw hata    }     revalidatePath("/esyalar")    redirect("/esyalar")}
app/esyalar/yeni/EsyaFormu.tsx
"use client" import { useActionState } from "react"import { esyaEkle } from "../actions" export default function EsyaFormu() {    const [durum, formAction, bekliyor] = useActionState(esyaEkle, {})    const hatalar = durum.hatalar ?? {}    const degerler = durum.degerler ?? {}     return (        <form action={formAction} className="form">            <label>                Ad                <input name="ad" defaultValue={degerler.ad} />                {hatalar.ad && <span className="alan-hatasi">{hatalar.ad[0]}</span>}            </label>            <label>                Fiyat                <input name="fiyat" type="number" defaultValue={degerler.fiyat} />                {hatalar.fiyat && <span className="alan-hatasi">{hatalar.fiyat[0]}</span>}            </label>            <label>                Açıklama                <textarea name="aciklama" rows={2} defaultValue={degerler.aciklama} />                {hatalar.aciklama && <span className="alan-hatasi">{hatalar.aciklama[0]}</span>}            </label>            <button type="submit" className="dugme" disabled={bekliyor}>                {bekliyor ? "Kaydediliyor…" : "Dükkâna ekle"}            </button>        </form>    )}
app/esyalar/yeni/page.tsx
import type { Metadata } from "next"import EsyaFormu from "./EsyaFormu" export const metadata: Metadata = {    title: "Yeni eşya",} export default function YeniEsya() {    return (        <main>            <h1>Yeni eşya</h1>            <EsyaFormu />        </main>    )}

Hepsini yayın sunucusunda (npm run build, npm run start), üç eşyalı dükkânda sırayla denedik. Formun cevapları: fiyat boş → Fiyat bir sayı olmalı., -1 → Fiyat eksi olamaz., 5001 → Fiyat en fazla 5000 olabilir.; ad K ve açıklama Kısa → Ad en az 2 harf olmalı. ve Açıklama en az 5 harf olmalı.; Uzun Kılıç → Uzun Kılıç zaten dükkânda.. Doran Kalkanı (0) kaydedildi ve liste hemen Dükkânda 4 eşya var. dedi; Sonsuzluk Kılıcı'ndan (5000) sonra Dükkânda 5 eşya var.

Fiyatı coerce ile yazdığımız ilk denemede boş fiyatlı bir "Kalkan" sessizce 0 altınla kaydedildi; şemayı z.number()'a çevirmemizin sebebi bu. 12.5 yazınca tarayıcı formu hiç göndermedi: type="number" kutusu tam sayı bekliyor ve kendi uyarısını gösteriyor. Sunucunun da reddettiğini bir sonraki görevde curl.exe ile göreceksin; tarayıcının denetimi atlanabilir, sunucununki atlanamaz.

Görev 6

Mobil uygulama için POST

Bir telefon uygulaması da dükkâna eşya ekleyebilsin. POST /api/esyalar JSON gövdesini Görev 5'teki aynı şemayla doğrulasın. Başarılıysa 201 ve eklenen eşyayı (kimliğiyle) döndürsün; geçersiz veride 400 ve alan hatalarını; aynı ad için 409 ve {"hata":"… zaten dükkânda"}. API'den eklenen eşya da yayındaki listeye hemen düşsün.

istekler klasörüne şu dosyaları koyup dene: kitap.json → { "ad": "Büyü Kitabı", "fiyat": 435, "aciklama": "Yetenek gücünü artırır." }, kitap-metin.json → aynısı ama fiyat "435" (tırnaklı), kitap-bozuk.json → { "ad": "Ç", "fiyat": 12.5 }. Bozuk JSON için dersteki yarim.json'ı kullan.

Sınırlar: kitap.json ilk gönderimde 201, ikincide 409 vermeli. kitap-metin.json reddedilmeli: API sayı ister, metin değil. 12.5 Fiyat tam sayı olmalı. demeli.

İpucu 1

Şampiyon API'sindeki POST'un kalıbı. Gövdeyi await istek.json().catch(() => null) ile unknown olarak oku ve EsyaSemasi.safeParse'a ver.

İpucu 2

JSON'daki 435 zaten bir sayı; formdaki gibi sayi() ile çevirmene gerek yok. "435" ise metin; z.number() onu kendiliğinden reddeder. Kaydı Görev 5'teki esyaKaydet yapsın, 23505'i 409'a çevir.

Çözümü göster
app/api/esyalar/route.ts
import { revalidatePath } from "next/cache"import { DatabaseError } from "pg"import { z } from "zod"import { esyaKaydet, tumEsyalar } from "@/lib/esyalar"import { EsyaSemasi } from "@/lib/esyaSemasi" export async function GET() {    const esyalar = await tumEsyalar()    return Response.json(esyalar)} export async function POST(istek: Request) {    const govde: unknown = await istek.json().catch(() => null)    const sonuc = EsyaSemasi.safeParse(govde)     if (!sonuc.success) {        return Response.json(            { hata: "Geçersiz veri", alanlar: z.flattenError(sonuc.error).fieldErrors },            { status: 400 }        )    }     try {        const esya = await esyaKaydet(sonuc.data)        revalidatePath("/esyalar")        return Response.json(esya, { status: 201 })    } catch (hata) {        if (hata instanceof DatabaseError && hata.code === "23505") {            return Response.json({ hata: sonuc.data.ad + " zaten dükkânda" }, { status: 409 })        }        throw hata    }}
İkinci terminal
PS C:\kod\next-rehber> curl.exe -i --json "@istekler\kitap.json" http://localhost:3000/api/esyalarHTTP/1.1 201 Createdcontent-type: application/json {"id":7,"ad":"Büyü Kitabı","fiyat":435,"aciklama":"Yetenek gücünü artırır."}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\kitap.json" http://localhost:3000/api/esyalarHTTP/1.1 409 Conflictcontent-type: application/json {"hata":"Büyü Kitabı zaten dükkânda"}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\kitap-metin.json" http://localhost:3000/api/esyalarHTTP/1.1 400 Bad Requestcontent-type: application/json {"hata":"Geçersiz veri","alanlar":{"fiyat":["Fiyat bir sayı olmalı."]}}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\kitap-bozuk.json" http://localhost:3000/api/esyalarHTTP/1.1 400 Bad Requestcontent-type: application/json {"hata":"Geçersiz veri","alanlar":{"ad":["Ad en az 2 harf olmalı."],"fiyat":["Fiyat tam sayı olmalı."],"aciklama":["Invalid input: expected string, received undefined"]}}PS C:\kod\next-rehber> curl.exe -i --json "@istekler\yarim.json" http://localhost:3000/api/esyalarHTTP/1.1 400 Bad Requestcontent-type: application/json {"hata":"Geçersiz veri","alanlar":{}}

(Başlıkların bir kısmını kısalttık.) "id":7: Görev 5'teki iki eşyadan sonra sıradaki numara 6 olmalıydı, ama aynı adı ikinci kez denemek gibi başarısız eklemeler de sayaçtan numara harcıyor. Sende farklı olabilir.

Aynı şema iki kapıda iki farklı veriyle karşılaşıyor: form her şeyi metin gönderiyor ve biz sayi() ile çeviriyoruz; API'nin istemcisi JSON'da gerçek bir sayı gönderebilir, göndermeli de. Şemayı coerce'süz yazdığımız için API sıkı: "435" gönderen bir uygulama hatasını hemen öğreniyor. Aynı yayın sunucusunda kitap.json'ın ilk gönderiminden hemen sonra liste Dükkânda 6 eşya var. dedi; revalidatePath burada da işini yaptı.

Kendini sına

Soru 1

Bu klasör ağacında ne ters gider?

Metin
app/├─ esyalar/│  ├─ page.tsx│  ├─ route.ts│  └─ actions.ts└─ api/   └─ esyalar/      └─ [id]/         └─ route.ts
Cevabı göster

app/esyalar klasöründe hem page.tsx hem route.ts var. Bir adreste ya sayfa ya Route Handler olabilir; Next Conflicting route and page at /esyalar der. route.ts'i app/api/esyalar/route.ts'e taşı. actions.ts sorun değil: adres olmaz, yalnız Server Action'ları tutar. app/api/esyalar/[id]/route.ts de doğru: /api/esyalar/1 gibi adresleri karşılar.

Soru 2

Sunucu bileşeni mi, Server Action mı, Route Handler mı? (a) Eşya sayfası açılırken eşyanın bilgisini okumak. (b) Bir Discord botunun her saat şampiyon listesini çekmesi. (c) Profil sayfasındaki "Takma adımı kaydet" formu. (d) Telefon uygulamasının yeni bir maç notu göndermesi.

Cevabı göster

(a) Sunucu bileşeni: sayfa çizilirken await esyaBul(id); ayrı bir istek gerekmez. (b) Route Handler (GET): bot HTML değil JSON ister ve sitenin formlarını kullanmaz. (c) Server Action: sitenin kendi formu; action={...} ile, JavaScript'siz bile çalışır. (d) Route Handler (POST): Server Action'ların kimliğini Next derlemede üretir ve kendi formlarına koyar, dışarıdaki bir uygulama için kararlı bir adres değildir; dış programlara açık, belgelenmiş bir uç nokta gerekir.

Soru 3

Bir arkadaşın şunu yazmış. Geliştirmede her şey çalışıyor, yayında yeni eşyalar listeye düşmüyor. Neden?

TypeScript
export async function esyaEkle(onceki: EsyaFormDurumu, formData: FormData) {    // ... doğrulama ve kayıt ...    redirect("/esyalar")    revalidatePath("/esyalar")}
Cevabı göster

redirect bir hata fırlatarak çalışır; kendisinden sonraki satırlar hiç çalışmaz. revalidatePath hiç çağrılmıyor, /esyalar'ın derlemede saklanan kopyası (○) değişmiyor. Geliştirme sunucusu hiçbir sayfayı saklamadığı için orada sorun görünmüyor. Düzeltme: revalidatePath'i redirect'ten önce yaz.

Soru 4

.env.local dosyasında iki satır var: ODEME_GIZLI_ANAHTAR=sk_… ve NEXT_PUBLIC_HARITA_ANAHTARI=pk_…. Bir istemci bileşeni ikisini de process.env ile okuyup ekrana yazıyor. Tarayıcıda ne görünür, asıl tehlike nerede?

Cevabı göster

Tarayıcıda ODEME_GIZLI_ANAHTAR undefined, NEXT_PUBLIC_HARITA_ANAHTARI gerçek değeriyle görünür; konsolda Hydration failed hatası çıkar. Tehlike iki yerde. Birincisi gizli anahtar: istemci bileşeni ilk HTML için sunucuda da çizildiği için gerçek değeri sayfanın HTML'ine yazılır, kaynağı açan herkes görür. Bu anahtar hemen yenilenmeli. İkincisi NEXT_PUBLIC_ anahtar: derlemede JavaScript dosyalarına gömülür; bu yüzden yalnız herkese açık olması tasarlanmış bir anahtar (ör. yalnız senin alan adından çalışacak biçimde sınırlanmış bir harita anahtarı) olabilir. Sırları yalnız sunucu kodunda oku.

Soru 5

Bu iki satır ne yazar?

TypeScript
const A = z.object({ adet: z.coerce.number().min(1) })const B = z.object({ adet: z.coerce.number().min(0) })console.log(A.safeParse({ adet: "" }).success)console.log(B.safeParse({ adet: "" }).success)
Cevabı göster

false ve true. coerce boş metni Number("") ile 0'a çevirir. A sıfırı reddeder; B kabul eder, yani boş kutu sessizce 0 olarak geçer. Sıfırın geçerli olduğu alanlarda boş metni çevirmeden önce yakala (Görev 5'teki sayi() gibi) ya da coerce'süz z.number() kullan.

Soru 6

app/api/esyalar/route.ts içinde export async function post(istek: Request) yazılmış. curl.exe --json "@istekler\kitap.json" http://localhost:3000/api/esyalar ne döner? Terminalde ne yazar?

Cevabı göster

405 Method Not Allowed. Next yalnız büyük harfli metot adlarını tanır; terminale Detected lowercase method 'post' in …route.ts. Export the uppercase 'POST' method name to fix this error. yazar. Dosyada GET varsa o çalışmaya devam eder; yalnız POST yokmuş gibi davranılır. Düzeltme: export async function POST.

Soru 7

error.tsx'te kullanıcıya error.message'ı göstermek yerine digest numarasını gösterdik. Yayında error.message ne olur? Bu neden iyi bir şey? digest ne işe yarar?

Cevabı göster

Yayında sunucu bileşeninden gelen hatanın mesajı tarayıcıya gönderilmez; error.message React'in genel bir metni olur (Minified React error #441…: ayrıntı gizlendi). İyi, çünkü asıl mesajda bağlantı dizesinin parçaları, dosya yolları ya da SQL olabilir; bunlar saldırgana yol gösterir. digest sunucu loglarındaki asıl hatayla eşleşen numaradır: kullanıcı numarayı sana söyler, sen logda arayıp gerçek mesajı bulursun.

Kayıt noktası

Commit'ten önce deneme artıklarını temizle: app/deneme klasörünü ve zod-dene.ts'i sil; .env.local'da yalnız DATABASE_URL ve Görev 4'teki NEXT_PUBLIC_YAMA kalsın. Sonra her şeyi bir kez denetle; üçü de sessiz ya da başarılı bitmeli:

Terminal — PowerShell
PS C:\kod\next-rehber> npx tsc --noEmitPS C:\kod\next-rehber> npm run lintPS C:\kod\next-rehber> npm run build

Derleme çıktısının sonunda görevlerle birlikte sitenin bütün adresleri:

Terminal — PowerShell
Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/esyalar├ ƒ /api/esyalar/[id]├ ƒ /api/sampiyonlar├ ƒ /api/sampiyonlar/[id]├ ○ /esyalar├ ƒ /esyalar/[id]├ ○ /esyalar/yeni├ ○ /hakkinda├ ○ /sampiyonlar├ ƒ /sampiyonlar/[id]├ ○ /sampiyonlar/yeni└ ○ /yama-notlari

○ /esyalar ve ○ /sampiyonlar saklanıyor; ikisini de değiştiren her yol revalidatePath çağırıyor. Bütün API'ler ƒ. Şimdi kayıt:

Bir sonraki derste bu uygulamanın görünüşüne dönüyorsun: Tailwind CSS ile hızlı stil, telefonda düzgün görünen sayfalar ve klavyeyle, ekran okuyucuyla da rahat kullanılan formlar.

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