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
pghavuzuyla PostgreSQL'den okuyup sayfayı veriyle çizebilirsin app/api/.../route.tsdosyasındaGETvePOSTfonksiyonları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
revalidatePathile sayfayı güncel tutabilirsin - Gizli bilgiyi sunucuda tutup
NEXT_PUBLIC_önekinin tarayıcıya neyi gönderdiğini açıklayabilirsin loading.tsx,error.tsxve zod ile yükleniyor, hata ve geçersiz girdi durumlarını yönetebilirsin
Bu sayfada
- Tam yığın: tek projede üç kapı
- Veritabanını hazırla
- Sunucu bileşeninde veritabanından okumak
- Route Handler: başka programlar için API
- Server Actions: formdan veritabanına
- Önbellek: yayında neden eski veri?
- Doğrulama: zod
- Ortam değişkenleri: sunucu ile istemci sınırı
- Yükleniyor ve hata sayfaları
- Sık hatalar
- Görevler
- Kendini sına
- 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:
- Sunucu bileşeni sayfayı çizerken veritabanından okur. Veri sayfanın ilk HTML'iyle gelir.
- Server Action formdan gelen veriyi alıp veritabanına yazar.
- Route Handler başka programlara (bir mobil uygulamaya, bir Discord botuna,
curl.exe'ye) JSON verir.
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ç:
- Neon'da projeni aç, SQL Editor'de
CREATE DATABASE rehber;yaz ve Run'a bas. - SQL Editor'ün üstündeki veritabanı seçicisinden
rehber'i seç (arayüz değişmiş olabilir). - Aşağıdaki betiği yapıştırıp çalıştır. Aynı metni projenin köküne
schema.sqladıyla da kaydet; tabloların nasıl kurulduğu kodunla birlikte dursun (Ders 37'deki şema dosyası fikri).
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:
SELECT id, isim, rol, zorluk FROM sampiyonlar ORDER BY id;| id | isim | rol | zorluk |
|---|---|---|---|
| 1 | Ahri | Büyücü | 2 |
| 2 | Garen | Dövüşçü | 1 |
| 3 | Jinx | Nişancı | 2 |
| 4 | Lux | Büyücü | 1 |
| 5 | Yasuo | Dövüşçü | 3 |
| 6 | Teemo | Niş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:
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:
DATABASE_URL=postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/rehber?sslmode=verify-full&channel_binding=requireDeğ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:
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:
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 dosyalarimport { pool } from "@/lib/db"ile alır.
DATABASE_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:
⨯ 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:
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ı parantezdekiSampiyonbir tip parametresi.pg'ye "satırlar bu şekilde gelecek" diyorsun;rowsartıkSampiyon[]. Bu bir söz, denetim değil: sütun adlarını yanlış yazarsan TypeScript fark etmez.sampiyonBulasyncoldu ve$1ile parametreli sorgu kullanıyor. Adresten gelen metni SQL'e yapıştırmıyoruz.rows[0]: satır yoksaundefined. Dönüş tipineSampiyon | undefinedyazdı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:
Property '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:
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:
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:
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:
○ 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çinde | Sunucuda, 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?
invalid 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):
⨯ 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:
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:
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. Sayfalardakiexport defaultburada yok. - Fonksiyon bir
Responsedöndürür.Response, tarayıcıdakifetch'in döndürdüğü nesnenin aynısı; burada sen kuruyorsun.Response.json(veri)veriyi JSON'a çevirir vecontent-type: application/jsonbaşlığını koyar.
Sunucu açıkken ikinci bir terminal aç ve dene:
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:
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çindekiparams, sayfalardaki gibi bir Promise. RouteContext<"/api/sampiyonlar/[id]">: Next'in klasörlerine bakıp ürettiği tip, Ders 43'tekiLayoutPropsgibi. İçindeparams: Promise<{ id: string }>var.Response.json(veri, { status: 404 }): ikinci argümanla durum kodunu verirsin. Express'tekires.status(404).json(…)'un karşılığı.
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: chunkedSon 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.id | const { 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ın | Next 405 döndürür |
Detected 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:
⨯ 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ç:
"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, birFormDatanesnesi.formData.get("isim"),name="isim"olan kutunun içindekini verir. Gelen her şey metindir;zorlukbile"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):
.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;}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> )} 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.tsForm 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:

Sayfa listeye döner ve Annie oradadır: 7 şampiyon var. Ne oldu, sırayla:
- Next derlerken
sampiyonEkle'ye gizli bir kimlik verdi ve formun içine bu kimliği koydu. Fonksiyonun kodu tarayıcıya gitmedi. - Kaydet'e basınca tarayıcı formdaki dört kutuyu bir
POSTisteğiyle sayfanın kendi adresine (/sampiyonlar/yeni) gönderdi. TerminaldekiPOSTsatırı bu. - Next kimliğe bakıp
sampiyonEkle'yi sunucuda çalıştırdı ve form verisiniformDataolarak verdi.INSERTçalıştı. redirectcevaba "ş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. TerminaldeGET /sampiyonlarsatırınınPOSTsatı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.
It 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:
⨯ ./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:
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:
npm run startile yayın sunucusunu başlat./sampiyonlar/yeni'den Vex'i ekle: Büyücü, zorluk2, tanıtım "Her şeyden sıkılan, gölgesiyle saldıran küçük bir büyücü."- Liste açılınca say:
7 şampiyon var. Vex yok. Sayfayı F5 ile yenile: yine 7. - İkinci terminalde
curl.exe http://localhost:3000/api/sampiyonlariste: Vex orada. Veritabanında 8 şampiyon var.
Kayıt yazıldı ama sayfa eski. Sayfanın yanıt başlıklarına bak:
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=5x-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 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:
"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.
revalidatePath unutulduNe 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:
export const revalidate = 60revalidate = 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:
Route (app) Revalidate Expire┌ ○ /├ ○ /_not-found├ ƒ /api/sampiyonlar├ ƒ /api/sampiyonlar/[id]├ ○ /esyalar├ ƒ /esyalar/[id]├ ○ /hakkinda├ ○ /sampiyonlar 10s 1y├ ƒ /sampiyonlar/[id]├ ○ /sampiyonlar/yeni└ ○ /yama-notlariSonra SQL Editor'den Zyra'yı ekleyip sayfayı arka arkaya istedik:
| Ne zaman | x-nextjs-cache | Sayfadaki sayı |
|---|---|---|
| Zyra eklenmeden önce | HIT | 10 |
| Ekledikten hemen sonra | HIT | 10 |
| 11 saniye sonra, ilk istek | STALE | 10 |
| Bir saniye sonra | HIT | 11 |
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:
⨯ 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:
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:
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}'))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 birisimve sayı olan birhpalanı olan obje".z.string()vez.number()alanların kuralları.KartSemasi.parse(veri)veriyi denetler. Veri şemaya uyuyorsa temiz bir kopyasını döndürür, uymuyorsaZodErrorfırlatır. Son satırdakihppyazı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.issuesher 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 zatenKart'tır. Buradakitypeof, Ders 23'te çalışırken soru sorantypeofdeğ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:
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,zorlukda"2".coerce(zorla çevir) değeri önceNumber(...)ile sayıya çevirir, sonra sayı kurallarını uygular."abc"çevrilinceNaNolur 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:
import "server-only"import { pool } from "@/lib/db"import type { YeniSampiyon } from "@/lib/sampiyonSemasi"ve ikonAdresi'nin üstüne yeni bir fonksiyon:
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:
"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.getmetin, dosya ya danulldö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ı) veformData. Birazdan kullanacağınuseActionStatehook'u Server Action'ı bu biçimde çağırır.onceki'yi kullanmıyoruz ama sıra yüzünden yazmak zorundayız. safeParsebaşarısızsa fonksiyon bir obje döndürür: hatalar ve kullanıcının yazdıkları.z.flattenError(sonuc.error).fieldErrorshataları alan adına göre gruplar:{ isim: ["İsim en az 2 harf olmalı."], zorluk: [...] }. (İnternetteki örneklerdesonuc.error.flatten()görürsen: zod 4'te o yazım eskidi, yerine bu geldi.)- Başarılıysa
sonuc.datatemiz ve tipli veridir:isimkırpılmış,zorluksayıya çevrilmiş. Veritabanına giden artık bu. DatabaseError,pg'nin veritabanı hataları için kullandığı sınıf.instanceofile (Ders 16) daraltınca TypeScripthata.code'u okumana izin verir.23505aynı 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:
"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:
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(formunaction'ına vereceğin, Server Action'ı saran fonksiyon) vebekliyor(istek sürerkentrue).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'tekikeyküçük bir hile: bu sıfırlamadaselectbaşlangıç değerini kendiliğinden geri almıyor;keydeğişince React onu yeniden kurar ve seçim yerinde kalır.
Formu A, Nişancı, 5, "Kısa" ile gönder:

Üç 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.
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.
A "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:
⨯ Error: A "use server" file can only export async functions, found object.Read more: https://nextjs.org/docs/messages/invalid-use-server-valueDışa aktarılan async olmayan bir fonksiyonda ise mesaj daha erken, sayfa açılırken gelir:
⨯ ./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:
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 durumdanullverir,nullda şemadan geçemez.govde'nin tipi bilerekunknown(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,201ve409durum kodları. revalidatePathburada 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:
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.minve.max'e yazdık. İstersenz.string("Tanıtım yazılmalı.")diye ona da Türkçe mesaj verebilirsin.yarim.jsoniçinalanlarboş: 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:
"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>}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:
Adres: undefined · Site: Şampiyon RehberiTarayı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:
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.
<p>Adres: <!-- -->postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/rehber?sslmode=verify-full&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.
undefined ve Hydration failedNe 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ış):
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.
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.
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şeninde | Nerede kullan |
|---|---|---|---|
DATABASE_URL=… | Gerçek değer | undefined (ama sunucudaki ilk çizimde değer var) | Sırlar: yalnız sunucuda |
NEXT_PUBLIC_SITE_ADI=… | Gerçek değer | Derlemede gömülen değer | Herkese 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:
"use client" import { tumSampiyonlar } from "@/lib/sampiyonlar" export default function Gizli() { console.log(tumSampiyonlar) return <p>Deneme</p>}'server-only' cannot be imported from a Client Component moduleNe 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ı:
./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:
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.
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:

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:
"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üğmeninonClick'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 örneklerderesetgörürsün.resethâ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:

Menü yerinde, sitenin geri kalanı çalışıyor; yalnız /sampiyonlar segmenti hata ekranına döndü. Terminalde asıl hata ve aynı numara:
⨯ 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.
Error 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:
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ç.
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
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]}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> )}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.
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
import { tumEsyalar } from "@/lib/esyalar" export async function GET() { const esyalar = await tumEsyalar() return Response.json(esyalar)}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)}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.
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
export default function DukkanYukleniyor() { return ( <main> <p className="soluk">Dükkân açılıyor…</p> </main> )}"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.
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'aYAMA=16.20yaz, bileşendeprocess.env.YAMAoku 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): ekranda16.20olmalı. Sonra.env.local'da değeri16.21yap ve derlemeden yalnıznpm 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:
NEXT_PUBLIC_YAMA=16.20SatinAlDugmesi.tsx'te düğmenin altına tek satır:
</button> <p className="soluk">Fiyatlar {process.env.NEXT_PUBLIC_YAMA} yamasına göre.</p> </div>Denediğimizde:
- Öneksiz
YAMAile ekrandaFiyatlar yamasına göre.yazdı (numaranın yeri boş) ve konsoldaHydration failedhatası çıktı. Sayfa kaynağında iseFiyatlar <!-- -->16.20<!-- --> yamasına göre.vardı: bileşen ilk HTML için sunucuda çizilirken değeri okudu, tarayıcıda okuyamadı. DerstekiDATABASE_URLdenemesinin aynısı, bu kez zararsız bir değerle. NEXT_PUBLIC_YAMA=16.20ile derleyip başlatınca ekrandaFiyatlar 16.20 yamasına göre.yazdı. Değeri16.21yapıp yalnıznpm run start'ı yeniden başlatınca hem ekran hem sayfa kaynağı hâlâ16.20dedi.npm run buildvenpm run start'tan sonra16.21geldi.
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.
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
0ve5000kabul edilmeli;-1ve5001reddedilmeli. - 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ıçiseUzun 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
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:
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]}"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")}"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> )}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.
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
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 }}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?
app/├─ esyalar/│ ├─ page.tsx│ ├─ route.ts│ └─ actions.ts└─ api/ └─ esyalar/ └─ [id]/ └─ route.tsCevabı 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?
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?
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:
PS C:\kod\next-rehber> npx tsc --noEmitPS C:\kod\next-rehber> npm run lintPS C:\kod\next-rehber> npm run buildDerleme çıktısının sonunda görevlerle birlikte sitenin bütün adresleri:
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.