Blok 6 · Ders 46
Blok 6 projesi: tam yığın uygulama
Next.js ve PostgreSQL ile girişli, veritabanlı bir uygulama (maç notları) yazıp Vercel'e yayınlıyorsun.
Bu dersin sonunda
- Bir uygulamayı yazmadan önce sayfalarını, verisini ve tablolarını bir plana dökebilirsin
- Better Auth ile GitHub ve Discord girişini kurup oturumdaki kullanıcıyı sunucuda okuyabilirsin
- Server Actions ve zod ile not ekleyen, gösteren, düzenleyen ve silen sayfalar yazabilirsin
- Her sorguya kullanıcı şartı koyup başkasının verisine ulaşma denemelerini kapatabilirsin
- Uygulamayı ortam değişkenleriyle Vercel'e yayınlayıp README, ekran görüntüsü ve canlı bağlantıyla portföyüne koyabilirsin
Bu sayfada
Altıncı bloğun sonuna geldin: TypeScript, React ve Next.js'i öğrendin. Bu derste hepsini tek bir gerçek uygulamada birleştiriyorsun: Maç Notları. Maçtan sonra aklında kalanı yazıyorsun ("Zed'e karşı E'yi erken harcadım"), uygulama notlarını saklıyor ve hangi şampiyonda ne kadar kazandığını gösteriyor. Şifre yok: GitHub ya da Discord hesabınla giriyorsun. Herkes yalnız kendi notlarını görüyor. Dersin sonunda uygulama internette, kendi adresinde çalışıyor olacak; GitHub'da README'si ve ekran görüntüleriyle duran, iş başvurusunda gösterebileceğin bir proje.
Proje dersi uzun. Bir oturumda bitirmek zorunda değilsin; her ana bölümün sonunda uygulama çalışır durumda kalıyor.
Plan: kod yazmadan önce
Ders 38'de API'yi yazmadan önce bir sözleşme yazmıştın: hangi adres, hangi cevap. Bir web uygulamasında da aynısını yaparsın, yalnız bu kez üç şey planlanır: kullanıcının yapabilecekleri, sayfalar ve veri. Plansız başlarsan yarı yolda bir tabloyu baştan kurman ya da yazdığın sayfayı çöpe atman gerekir.
Kullanıcı ne yapabilecek?
- GitHub ya da Discord hesabıyla giriş yapar, çıkış yapar.
- Bir maç notu yazar: şampiyon, sonuç (galibiyet ya da kayıp), tarih ve not metni.
- Notlarını kart kart görür, birine tıklayınca tamamını okur.
- Notunu düzenler ya da siler.
- İstatistik sayfasında şampiyon başına maç sayısını ve galibiyet oranını görür.
- Başkasının notunu göremez, değiştiremez, silemez. Bu son madde bir özellik değil, bir kural; bütün dersin omurgası.
Sayfalar
| Adres | Ne gösterir | Kim açabilir |
|---|---|---|
/ | Kısa tanıtım ve "Giriş yap" | Herkes; giriş yapmışsa /notlar'a yönlenir |
/giris | GitHub ve Discord düğmeleri | Herkes |
/notlar | Notların kartları | Yalnız giriş yapan, yalnız kendi notları |
/notlar/yeni | Not formu | Giriş yapan |
/notlar/[id] | Bir notun tamamı, düzenle ve sil düğmeleri | Yalnız notun sahibi |
/notlar/[id]/duzenle | Doldurulmuş form | Yalnız notun sahibi |
/istatistik | Şampiyon başına sayılar | Giriş yapan, yalnız kendi sayıları |
/api/auth/... | Girişin arka kapıları; sayfa değil | Better Auth yönetir |
Son satırdaki adresleri sen yazmayacaksın; birazdan kuracağın giriş kütüphanesi kendisi karşılayacak.
Veri modeli
Hangi tablolar gerekiyor? Bir tanesi belli: notlar. Her not bir kullanıcıya ait; yani bir de kullanıcılar tablosu var ve notlar ona yabancı anahtarla bağlı, bire-çok: bir kullanıcının çok notu olur, bir notun tek sahibi.
Giriş sistemi üç tablo daha ister: açık oturumlar, kullanıcının hangi sağlayıcıyla (GitHub mı, Discord mu) bağlandığı ve giriş sırasında kısa süre tutulan doğrulama kayıtları. Bu dört tabloyu birazdan kuracağın kütüphane tarif edecek; sen yalnız notlar'ı yazacaksın. Tabloların ve aralarındaki bağların resmine bakmak, hepsini kafanda tutmaktan kolaydır:
Birkaç karar ve sebepleri:
kullanici_id integer,REFERENCES kullanicilar(id) ON DELETE CASCADE. Kullanıcı silinirse notları da silinsin (Ders 36'dakiON DELETE). Sahibi olmayan not anlamsız.sonuc text CHECK (sonuc IN ('galibiyet', 'kayıp')). İki değer dışında bir şey veritabanına hiç girmesin (Ders 35). Formda zod da denetleyecek; kısıt, kodundaki bir unutkanlığa karşı ikinci kilit.tarih date. Maçın oynandığı gün; saat gerekmiyor.olusturuldu timestamptz DEFAULT now()ise notun yazıldığı an, kendiliğinden dolar.kullanici_idüzerinde bir indeks. Hemen her sorgu "bu kullanıcının notları" diye başlayacak; indeks, tablo büyüdükçe bu aramayı hızlı tutar.- Sütun adları: bizim tablomuz Ders 33'ten beri olduğu gibi Türkçe ve
snake_case. Better Auth'un sütunları İngilizce vecamelCase(userId,expiresAt); onları kütüphane belirliyor. İkisi aynı veritabanında yan yana yaşayabilir.
Proje ve veritabanı
Yeni proje
Komut Ders 45'tekinin aynısı, yalnız adı farklı. C:\kod klasöründe:
PS C:\kod> npx create-next-app@latest mac-notlari --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*" --no-react-compiler --no-cache-components --use-npmSonda Success! Created mac-notlari at C:\kod\mac-notlari yazar; create-next-app Git deposunu da kurup ilk kaydı alır. Klasörü VS Code'da aç (code mac-notlari) ve ilk paketleri kur: veritabanı sürücüsü pg, doğrulama için zod ve giriş kütüphanesi better-auth (birazdan tanıyacaksın). pg'nin tipleri yine ayrı pakette:
PS C:\kod\mac-notlari> npm install better-auth pg zod added 36 packages, and audited 394 packages in 18s 149 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\mac-notlari> npm install -D @types/pg added 1 package, and audited 395 packages in 1s...Bu ders Next.js 16.4.0, better-auth 1.7.7, pg 8.23.1 ve zod 4.6.6 ile denendi; npm view better-auth version dersi yazarken 1.7.7 diyordu. 5 high severity vulnerabilities ve install-scripts satırları Ders 43'ten tanıdık: şablondaki ESLint'ten geliyor, npm audit fix --force çalıştırma.
Şablonu temizle. app/globals.css'te yalnız ilk satır kalsın:
@import "tailwindcss";Son olarak Ders 45'teki düğmeyi getir. VS Code'un dosya listesinde proje kökünde components adında bir klasör aç. Sonra Windows'un Dosya Gezgini'nde (İngilizce Windows'ta File Explorer) C:\kod\stil-atolyesi\components\Dugme.tsx dosyasını kopyala ve C:\kod\mac-notlari\components klasörüne yapıştır. Dosyaya dokunma; bu derste tur, type, disabled ve onClick özellikleriyle aynen kullanacaksın.
Neon'da proje ve iki dal
Ders 38'deki düzeni tekrarlıyorsun: canlı veri production dalında, geliştirme onun bir kopyasında.
- Neon'da New Project'e bas. Ad
mac-notlari, Postgres sürümü 18, bölge AWS Europe (Frankfurt). Create project. Ana dalın adıproduction. - Branches sayfasında New branch: ad
gelistirme, ebeveynproduction. Automatically delete branch after kutusunun işaretini kaldır (Ders 38'de neden olduğunu gördün). Create. - Connect'e bas, dal olarak
gelistirme'yi seç, Connection pooling açık kalsın ve dizeyi kopyala.
Düğme adları Ekim 2026'daki Neon arayüzüne göre; değişmiş olabilir. Proje kökünde (package.json'ın yanında) .env.local dosyasını aç ve dizeyi yaz; sonundaki sslmode=require'ı Ders 37'deki gibi verify-full yap:
DATABASE_URL=postgresql://neondb_owner:SIFREN@ep-ornek-123456-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full&channel_binding=requireDers 44'teki lib/db.ts'i de aynı yolla getir: proje kökünde lib klasörünü aç, C:\kod\next-rehber\lib\db.ts'i oraya kopyala. İlk satırında import "server-only" var; birazdan bu satır sana bir ders verecek.
Giriş: Better Auth
Neden hazır bir kütüphane?
Ders 31'de bir giriş sisteminin parçalarını kendin yazdın: şifreyi hash'leyip tuzladın, oturumu bir Map'te tuttun, çereze HttpOnly ve SameSite verdin, OAuth akışını adım adım izledin. Aynı dersin sonunda kural da vardı: gerçek bir üründe bunu sıfırdan yazma. Bir alanı unutmak (çerezin süresi, state denetimi, oturumun sunucuda silinmesi) bütün kapıyı açar.
Next.js için yıllarca en çok kullanılan kütüphane Auth.js'ti (eski adıyla NextAuth). 2025'te Auth.js projesi Better Auth'a katıldı; Auth.js'in kendi sitesi bugün "The Auth.js project is now part of Better Auth" diyor; Better Auth'un belgelerinde de "Migrating from Auth.js to Better Auth" adlı bir geçiş rehberi var. Bu derste doğrudan Better Auth'u kullanıyorsun: TypeScript ile yazılmış, Next.js'e resmî entegrasyonu olan, açık kaynak bir kimlik kütüphanesi. Kullanıcıları, oturumları ve sağlayıcı hesaplarını senin veritabanında tutar; yani Neon'daki pg havuzunu ona verirsin, başka bir servise üye olmazsın.
Gizli anahtar ve adres
Better Auth iki ayara bakar. BETTER_AUTH_SECRET, oturum çerezini imzalamakta kullanılan sırdır: çerezi değiştiren biri imzayı tutturamaz (Ders 31'deki imza fikri). BETTER_AUTH_URL uygulamanın adresidir; GitHub'a "işin bitince kullanıcıyı şuraya geri gönder" derken bu adresi kullanır. Sırrı Better Auth'un komut satırı aracı üretir:
PS C:\kod\mac-notlari> npx auth@latest secretAdd the following to your .env file:# Auth SecretBETTER_AUTH_SECRET=ae69cf7c9eca19728734aeafe8d99f6a61dade0824d2dbb1036a3423c10d51f3auth paketi Better Auth'un komut satırı aracı (npm view auth description: "The CLI for Better Auth"). npx onu kurmadan indirip çalıştırır. Değer her seferinde rastgele; seninki farklı olacak ve sende kalacak. Mesaj ".env dosyasına ekle" diyor; Next'te bizim dosyamız .env.local:
DATABASE_URL=postgresql://neondb_owner:SIFREN@ep-ornek-123456-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full&channel_binding=requireBETTER_AUTH_SECRET=ae69cf7c9eca19728734aeafe8d99f6a61dade0824d2dbb1036a3423c10d51f3BETTER_AUTH_URL=http://localhost:3000lib/auth.ts: kütüphanenin ayarları
lib klasöründe auth.ts dosyasını aç:
import { betterAuth } from "better-auth"import { nextCookies } from "better-auth/next-js"import { pool } from "@/lib/db" export const gelistirmede = process.env.NODE_ENV === "development" export const auth = betterAuth({ database: pool, user: { modelName: "kullanicilar" }, session: { modelName: "oturumlar" }, account: { modelName: "hesaplar" }, verification: { modelName: "dogrulamalar" }, advanced: { database: { generateId: "serial" }, }, socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID as string, clientSecret: process.env.GITHUB_CLIENT_SECRET as string, }, discord: { clientId: process.env.DISCORD_CLIENT_ID as string, clientSecret: process.env.DISCORD_CLIENT_SECRET as string, }, }, emailAndPassword: { enabled: gelistirmede, }, plugins: [nextCookies()],})Ayarların hepsi tek bir objede. Better Auth BETTER_AUTH_SECRET ve BETTER_AUTH_URL'i kendisi okur; burada yazmana gerek yok.
Satır satır:
database: pool: Ders 44'teki havuz. Better Auth sorgularını bu havuzla çalıştırır; ayrı bir bağlantı açmaz.modelName: Better Auth'un tabloları varsayılan olarakuser,session,account,verificationadlarını taşır.userPostgres'te ayrılmış bir sözcük; her sorguda çift tırnakla yazmak gerekir. Türkçe adlar hem bizim düzenimize uyuyor hem bu tuzağı ortadan kaldırıyor. Sütun adları İngilizce kalıyor.generateId: "serial": kullanıcı kimlikleri1, 2, 3…diye artan sayılar olsun (Ders 33'teki kimlik sütunu). Varsayılan, rastgele metin kimliklerdir. Sayı seçtik, çünkünotlar.kullanici_id integerolacak ve sonraki derslerde bu kimliği sayı olarak kullanacaksın.socialProviders: GitHub ve Discord. Değerleri birazdan sağlayıcıların sitelerinden alacaksın.as string, TypeScript'e "bu değer tanımlı olacak" sözü; tanımlı değilse ne olduğunu hata kartlarında göreceksin.emailAndPassword.enabled: gelistirmede: e-posta ve şifreyle giriş yalnız geliştirmede açık. Neden gerektiğini "Deneme girişi" bölümünde göreceksin.NODE_ENV,npm run dev'de"development",npm run buildve yayında"production"olur; değeri Next kendisi koyar.nextCookies(): Better Auth'un çerez yazan işlerini (giriş, çıkış) bir Server Action içinden çağıracaksın. Server Action'ın cevabına çerez eklemek Next'in kendi yöntemini ister; bu eklenti onu yapar. Belgesi listede en sonda durmasını istiyor.
Şimdi ortam değişkenleri. .env.local'a dört satır daha ekle; değerleri sağlayıcı uygulamalarını açınca dolduracaksın, şimdilik boş kalsınlar:
GITHUB_CLIENT_ID=GITHUB_CLIENT_SECRET=DISCORD_CLIENT_ID=DISCORD_CLIENT_SECRET=Tabloları kurmak
Better Auth tablolarını tarif edebilir: komut satırı aracı lib/auth.ts'i okur, veritabanına bakar ve eksik tabloların SQL'ini bir dosyaya yazar. Dene:
PS C:\kod\mac-notlari> npx auth@latest generate --output schema.sql --yes[#better-auth]: Couldn't read your auth config. Error: Cannot find module 'server-only'Require stack:- C:\kod\mac-notlari\lib\db.ts at Module._resolveFilename (node:internal/modules/cjs/loader:1564:15) ...Couldn't read your auth config. Error: Cannot find module 'server-only'Ne zaman çıkar? Better Auth'un komut satırı aracı (npx auth@latest generate ya da migrate) ayar dosyasını okurken, o dosyanın içe aktardığı bir dosyada import "server-only" varsa.
Neden? server-only Next'in tanıdığı bir işaret (Ders 44). Komut satırı aracı Next'in içinde değil, düz Node'da çalışır; Node bu adda bir paket bulamaz. Zincir hata mesajında görünüyor: araç lib/auth.ts'i okudu, o lib/db.ts'i içe aktardı, orada takıldı.
Nasıl düzelir? lib/db.ts'in ilk satırını sil. Korumadan vazgeçmiyorsun: sayfaların içe aktaracağı lib/notlar.ts ve lib/oturum.ts dosyalarının başına import "server-only" yazacaksın. Bir istemci bileşeni yanlışlıkla onları içe aktarırsa derleme yine durur.
lib/db.ts'in son hâli:
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)})Komutu yeniden çalıştır. Sondaki --yes, aracın soracağı "şu dosyaya yazayım mı?" sorusuna önceden evet der:
PS C:\kod\mac-notlari> npx auth@latest generate --output schema.sql --yes(node:26800) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///C:/kod/mac-notlari/lib/auth.ts?_1 is not specified and it doesn't parse as CommonJS.Reparsing as ES module because module syntax was detected. This incurs a performance overhead....2026-10-12T02:56:23.691Z ERROR [Better Auth]: Database schema mismatch Missing tables kullanicilar, oturumlar, hesaplar, dogrulamalar help: Run `npx auth migrate` to add the missing tables and columns.🚀 Schema was generated successfully!Kırmızı ERROR seni korkutmasın. Better Auth veritabanına baktı ve "dört tablo eksik" dedi; zaten bu yüzden SQL istiyoruz. MODULE_TYPELESS_PACKAGE_JSON uyarısı Ders 44'teki zod-dene.ts'te gördüğünle aynı, zararsız. Proje kökünde schema.sql dosyası oluştu:
create table "kullanicilar" ("id" integer GENERATED BY DEFAULT AS IDENTITY not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null); create table "oturumlar" ("id" integer GENERATED BY DEFAULT AS IDENTITY not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" integer not null references "kullanicilar" ("id") on delete cascade); create table "hesaplar" ("id" integer GENERATED BY DEFAULT AS IDENTITY not null primary key, "accountId" text not null, "providerId" text not null, "userId" integer not null references "kullanicilar" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null); create table "dogrulamalar" ("id" integer GENERATED BY DEFAULT AS IDENTITY not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null); create index "oturumlar_userId_idx" on "oturumlar" ("userId"); create index "hesaplar_userId_idx" on "hesaplar" ("userId"); create index "dogrulamalar_identifier_idx" on "dogrulamalar" ("identifier");Satırlar uzun ama her şeyi tanıyorsun: kimlik sütunları, not null, unique, references … on delete cascade, indeksler. Tablo ve sütun adları çift tırnaklı, çünkü sütunlar büyük harf içeriyor; Postgres tırnaksız adları küçük harfe çevirir (Ders 37'deki AS "takmaAd" gibi). oturumlar, Ders 31'de Map'te tuttuğun oturumların veritabanındaki hâli: rastgele bir token, sahibi (userId) ve son kullanma anı (expiresAt). hesaplar, bir kullanıcının hangi sağlayıcıyla bağlandığını tutar: providerId github ya da discord, accountId kullanıcının o sitedeki numarası.
Dosyanın sonuna bir boş satır bırakıp kendi tablonu ekle:
CREATE TABLE notlar ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, kullanici_id integer NOT NULL REFERENCES kullanicilar(id) ON DELETE CASCADE, sampiyon text NOT NULL, sonuc text NOT NULL CHECK (sonuc IN ('galibiyet', 'kayıp')), tarih date NOT NULL, metin text NOT NULL, olusturuldu timestamptz NOT NULL DEFAULT now()); CREATE INDEX notlar_kullanici_id_idx ON notlar (kullanici_id);Neon'da SQL Editor'ü aç, üstteki dal seçicisinden gelistirme'yi seç, schema.sql'in tamamını yapıştır ve Run'a bas. Sol menüdeki Tables sayfasında beş tablo görünmeli.
relation "kullanicilar" does not existNe zaman çıkar? notlar'ı, bağlı olduğu kullanicilar tablosundan önce kurmaya çalışınca: yalnız dosyanın sonunu seçip çalıştırdıysan ya da notlar'ı dosyanın başına yazdıysan. Boş bir veritabanında denedik:
CREATE TABLE notlar ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, kullanici_id integer NOT NULL REFERENCES kullanicilar(id) ON DELETE CASCADE, sampiyon text NOT NULL);ERROR: relation "kullanicilar" does not existNasıl düzelir? Yabancı anahtar var olan bir tabloya işaret etmeli (Ders 36). Dosyayı yukarıdan aşağı, tamamıyla çalıştır: önce Better Auth'un tabloları, sonra notlar.
Kapı: /api/auth
Giriş akışı tarayıcıdan sunucuya birkaç istekle ilerler: "GitHub ile girişi başlat", "GitHub'dan döndüm", "oturumum hâlâ geçerli mi?" Bunların hepsi Better Auth'un adresleri. Onları tek bir Route Handler'a bağlarsın. app/api/auth/[...all]/route.ts:
import { toNextJsHandler } from "better-auth/next-js"import { auth } from "@/lib/auth" export const { GET, POST } = toNextJsHandler(auth)[...all] Ders 43'teki [id]'nin genişi: [id] adresin bir parçasını yakalar, [...all] geri kalanının hepsini. /api/auth/sign-in/social, /api/auth/callback/github, /api/auth/get-session: hepsi bu dosyaya düşer. toNextJsHandler(auth) bir GET ve bir POST fonksiyonu üretir; sen onları adlarıyla dışa aktarırsın (Ders 44'teki kural: Route Handler'da metot adları büyük harf).
GitHub'da OAuth uygulaması açmak
GitHub, kimin adına konuştuğunu bilmek ister: "Maç Notları adlı uygulama, şu adrese geri dönmek üzere senden izin istiyor." Bunun için GitHub'a uygulamanı tanıtırsın; o da sana bir kimlik ve bir sır verir.
- GitHub'da sağ üstteki profil resmine tıkla, Settings'e gir.
- Sol menünün en altındaki Developer settings'e, sonra OAuth Apps'e tıkla.
- New OAuth App'e bas (daha önce hiç açmadıysan düğmenin adı Register a new application).
- Application name:
Maç Notları. Homepage URL:http://localhost:3000. Application description boş kalabilir. - Authorization callback URL:
http://localhost:3000/api/auth/callback/github. Harfi harfine; tek bir eğik çizgi ya da port farkı girişi bozar. - Enable Device Flow işaretsiz kalsın. Register application'a bas.
- Açılan sayfada Client ID yazıyor; kopyala. Altındaki Generate a new client secret'e bas ve çıkan sırrı hemen kopyala: GitHub onu bir daha göstermez.
Adımlar GitHub'ın "Creating an OAuth app" belgesine göre yazıldı (Ekim 2026); arayüz değişmiş olabilir. İki değeri .env.local'a yaz:
GITHUB_CLIENT_ID=Ov23liDenemeKimlik01GITHUB_CLIENT_SECRET=buraya-github-in-verdigi-sirDiscord'da uygulama açmak
İsteğe bağlı: Discord hesabın yoksa bu bölümü geç; uygulama GitHub'la çalışır.
https://discord.com/developers/applicationsadresine git, Discord hesabınla gir, New Application'a bas. AdMaç Notları, koşulları onayla, Create.- Sol menüden OAuth2 sayfasına geç. Client ID'yi kopyala. Client Secret'ın yanındaki Reset Secret'a bas, onayla ve çıkan sırrı hemen kopyala.
- Aynı sayfada Redirects bölümünde Add Redirect'e bas,
http://localhost:3000/api/auth/callback/discordyaz ve sayfanın altındaki Save Changes'e bas.
Discord geliştirici portalının arayüzü sık değişir; bir düğmeyi bulamazsan sayfada "OAuth2" ve "Redirects" sözcüklerini ara. Değerleri .env.local'daki DISCORD_CLIENT_ID ve DISCORD_CLIENT_SECRET satırlarına yaz.
Giriş sayfası
Tarayıcı tarafında Better Auth'un küçük bir istemcisi var. lib/auth-client.ts:
import { createAuthClient } from "better-auth/react" export const authClient = createAuthClient()Adres vermiyoruz: uygulama ile giriş kapısı aynı sitede, istemci istekleri kendi sitesine gönderir. Düğmeler tıklanınca çalıştığı için bir istemci bileşeni olacak. app/giris/GirisDugmeleri.tsx:
"use client" import { useState } from "react"import Dugme from "@/components/Dugme"import { authClient } from "@/lib/auth-client" export default function GirisDugmeleri() { const [bekliyor, setBekliyor] = useState(false) const [hata, setHata] = useState("") async function girisYap(saglayici: "github" | "discord") { setBekliyor(true) setHata("") const { error } = await authClient.signIn.social({ provider: saglayici, callbackURL: "/notlar" }) if (error) { console.error(error) setHata("Giriş başlatılamadı. Biraz sonra yeniden dene.") setBekliyor(false) } } return ( <div className="mt-6 flex flex-col gap-3"> <Dugme disabled={bekliyor} onClick={() => girisYap("github")}>GitHub ile giriş yap</Dugme> <Dugme tur="ikincil" disabled={bekliyor} onClick={() => girisYap("discord")}>Discord ile giriş yap</Dugme> {hata && <p role="alert" className="text-sm text-red-700 dark:text-red-400">{hata}</p>} </div> )}authClient.signIn.social({ provider, callbackURL }): sunucudaki/api/auth/sign-in/social'a birPOSTatar. Sunucu GitHub'ın izin sayfasının adresini hazırlar; istemci tarayıcıyı oraya götürür.callbackURLher şey bitince kullanıcının hangi sayfamıza döneceği.- Başarılıysa sayfa zaten değişiyor; o yüzden yalnız hata durumunu ele alıyoruz.
{ error }Ders 12'deki parçalama: dönen objeden yalnızerroralanını al. Better Auth istemcisi hata fırlatmaz, sonucuerroralanında verir. bekliyor: istek sürerken iki düğme de kapalı; sabırsız biri iki giriş başlatamaz.
Sayfanın kendisi bir sunucu bileşeni. Giriş yapmış biri bu sayfaya gelirse doğrudan notlarına gitsin; bunun için oturumu okuyan bir yardımcıya ihtiyacın var, birazdan yazacaksın. app/giris/page.tsx:
import type { Metadata } from "next"import { redirect } from "next/navigation"import Dugme from "@/components/Dugme"import { gelistirmede } from "@/lib/auth"import { oturumdakiKullaniciId } from "@/lib/oturum"import { denemeGirisi } from "./actions"import GirisDugmeleri from "./GirisDugmeleri" export const metadata: Metadata = { title: "Giriş",} export default async function GirisSayfasi() { if ((await oturumdakiKullaniciId()) !== null) { redirect("/notlar") } return ( <section className="mx-auto max-w-sm"> <h1 className="text-2xl font-bold">Giriş yap</h1> <p className="mt-2 text-zinc-600 dark:text-zinc-400">Şifre yok: GitHub ya da Discord hesabınla gir.</p> <GirisDugmeleri /> {gelistirmede && ( <div className="mt-10 rounded-xl border-2 border-dashed border-amber-500 p-4"> <h2 className="font-bold">Deneme girişi</h2> <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Yalnız geliştirme sunucusunda görünür.</p> <div className="mt-3 flex gap-3"> {["Deniz", "Mert"].map((ad) => ( <form key={ad} action={denemeGirisi}> <input type="hidden" name="ad" value={ad} /> <Dugme type="submit" tur="ikincil">{ad} olarak gir</Dugme> </form> ))} </div> </div> )} </section> )}Alttaki kesik çerçeveli kutu ve denemeGirisi yeni; ikisini birkaç bölüm sonra açıklıyoruz. Önce giriş akışının kendisi.
Düğmeye basınca ne olur?
Ders 31'de OAuth akışını dört adımda okumuştun. Şimdi aynı akış senin uygulamanda, gerçek adreslerle:
Uygulamanın ikinci adımda verdiği ve tarayıcının üçüncü adımda gittiği adres aşağıda. Uygulamayı denerken yakaladık (port 3000'e çevrildi; kimlik, state ve code_challenge sende farklı olur):
https://github.com/login/oauth/authorize?response_type=code&client_id=Ov23liDenemeKimlik01&state=0p0rdl-sDA5gVl6yKhWFIQdHa_Mbq0b-&scope=read%3Auser+user%3Aemail&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fcallback%2Fgithub&code_challenge_method=S256&code_challenge=KjN1h4Naw1uHfLNRD7D3iveNI8mXZWjmI3nQAD3zvsc| Parça | Ne demek |
|---|---|
client_id | Senin uygulamanın kimliği. Açık bir değer; adres çubuğunda herkes görür. |
state | Better Auth'un ürettiği rastgele değer. Aynısını better-auth.state adlı HttpOnly bir çereze de yazar. GitHub geri döndüğünde ikisi karşılaştırılır; uymazsa giriş reddedilir (Ders 31'deki CSRF koruması). |
scope=read:user user:email | "Profilimi ve e-postamı okuyabilir." Kullanıcı izin sayfasında bunu görür. Better Auth'un GitHub belgesi user:email iznini şart koşuyor; varsayılan olarak kendisi istiyor. |
redirect_uri | Geri dönüş adresi, BETTER_AUTH_URL'den kuruldu. GitHub'a kaydettiğinle birebir aynı olmalı. |
code_challenge | Tek kullanımlık kodun yolda çalınmasına karşı ek bir kilit (PKCE). Better Auth kendisi yapar. |
Discord'un adresi aynı kalıpta: https://discord.com/api/oauth2/authorize?…&scope=identify+email&redirect_uri=…%2Fapi%2Fauth%2Fcallback%2Fdiscord.
Altıncı adımdaki istemci sırrı tarayıcıya hiç inmez: istek sunucundan GitHub'a gider. Kullanıcının GitHub şifresi ise uygulamana hiç uğramaz. Sekizinci adımda Better Auth oturumlar tablosuna bir satır ekler ve tarayıcıya better-auth.session_token adlı bir çerez yazar. Denemede gördüğümüz işaretler: HttpOnly, SameSite=Lax. Yayında, yani adres https ile başlayınca, bir de Secure eklenir ve çerezin adı __Secure-better-auth.session_token olur (Ders 31). Oturum varsayılan olarak 7 gün geçerli.
OAuth provider requires clientIdNe zaman çıkar? .env.local'da GITHUB_CLIENT_ID ya da GITHUB_CLIENT_SECRET boşken "GitHub ile giriş yap"a basınca. Tarayıcıda düğmenin altında "Giriş başlatılamadı" yazar; tarayıcının konsolunda (F12 → Console) 500 (Internal Server Error) görünür. Terminalde asıl sebep:
2026-10-12T03:07:32.150Z WARN [Better Auth]: Social provider github is missing clientId or clientSecret2026-10-12T03:07:32.151Z WARN [Better Auth]: Social provider discord is missing clientId or clientSecret2026-10-12T03:07:34.840Z ERROR [Better Auth]: Error Error: OAuth provider requires clientId at ignore-listed frames# SERVER_ERROR: Error: OAuth provider requires clientId at ignore-listed frames POST /api/auth/sign-in/social 500 in 358ms (next.js: 98ms, application-code: 259ms)İlk iki satır, Better Auth ilk kez yüklenince (sunucuya gelen ilk istekte) bir uyarı olarak gelir; hata ancak düğmeye basınca çıkar.
Nasıl düzelir? GitHub'dan aldığın iki değeri .env.local'a yaz ve kaydet. Next .env.local'ı izler ve Reload env: .env.local diyerek yeniden okur; olmazsa sunucuyu durdurup yeniden başlat. Discord'u kullanmıyorsan onun uyarısı zararsız.
Deneme girişi: yalnız geliştirmede
İki sorun var. Birincisi, GitHub uygulamasını açmadan da projede ilerleyebilmek istersin. İkincisi daha önemli: yetkiyi denemek için iki ayrı kullanıcıya ihtiyacın olacak. Biri notunu yazacak, öbürü ona ulaşmaya çalışacak. İki GitHub hesabın yoksa bunu yapamazsın.
Çözüm, yalnız geliştirme sunucusunda açık bir arka kapı: Better Auth'un e-posta ve şifreyle girişini lib/auth.ts'te enabled: gelistirmede ile açtın. Şimdi iki düğmelik bir Server Action yaz. app/giris/actions.ts:
"use server" import { headers } from "next/headers"import { redirect } from "next/navigation"import { auth, gelistirmede } from "@/lib/auth" export async function cikisYap() { await auth.api.signOut({ headers: await headers() }) redirect("/giris")} const DENEME_SIFRESI = "deneme-sifresi-46" export async function denemeGirisi(formData: FormData) { if (!gelistirmede) { throw new Error("Deneme girişi yalnız geliştirmede açık.") } const ad = formData.get("ad") === "Mert" ? "Mert" : "Deniz" const email = ad.toLowerCase() + "@ornek.com" try { await auth.api.signInEmail({ body: { email, password: DENEME_SIFRESI } }) } catch { await auth.api.signUpEmail({ body: { name: ad, email, password: DENEME_SIFRESI } }) } redirect("/notlar")}auth.api.…: Better Auth'un her adresi sunucuda bir fonksiyon olarak da çağrılabilir.signInEmailgirişi dener; kullanıcı yoksa hata fırlatır, biz designUpEmailile hesabı açarız. Kayıt, kullanıcıyı aynı anda içeri de alır. Çerezleri cevabanextCookies()eklentisi yazar.cikisYap:signOutoturumuoturumlartablosundan siler ve çerezi temizler.headers()ile tarayıcının gönderdiği başlıkları (çerez dahil) veriyoruz: kütüphane kimin çıktığını çerezden anlar.formData.get("ad") === "Mert" ? "Mert" : "Deniz": formdan ne gelirse gelsin yalnız iki addan biri kullanılır. Biri gizli kutuya başka bir ad yazsa da üçüncü bir kullanıcı açılmaz.- Şifre kodun içinde: yalnız geliştirmede kullanılan, kimseyi korumayan bir değer olduğu için. Gerçek bir şifre asla koda yazılmaz.
Düğmeleri birazdan, sunucuyu açınca deneyeceksin. Önce giriş sayfasının içe aktardığı lib/oturum.ts dosyasını yazman gerekiyor; o olmadan sayfa açılmaz.
Oturumdaki kullanıcı
Her sayfa ve her Server Action aynı soruyu soracak: "şu an kim giriş yapmış?" Cevabı tek bir dosyada topla. lib/oturum.ts:
import "server-only"import { headers } from "next/headers"import { redirect } from "next/navigation"import { auth } from "@/lib/auth" export async function oturumuGetir() { return auth.api.getSession({ headers: await headers() })} export async function oturumdakiKullaniciId(): Promise<number | null> { const oturum = await oturumuGetir() return oturum ? Number(oturum.user.id) : null} export async function girisGerekli(): Promise<number> { const kullaniciId = await oturumdakiKullaniciId() if (kullaniciId === null) { redirect("/giris") } return kullaniciId}auth.api.getSession({ headers }): isteğin çerezini okur, imzasınıBETTER_AUTH_SECRETile denetler,oturumlartablosunda süresi dolmamış bir satır arar. Bulursa{ session, user }döndürür, bulamazsanull.Number(oturum.user.id): kimlikler veritabanında sayı, ama Better Auth onları metin olarak verir ("1"). Belgesi bunu açıkça söylüyor: sayı kimliklerde "sayının metin hâlini alırsın, bu normal". Bizim sorgularımız ve sonraki dersler sayı kullanacak; çeviriyi tek bir yerde yapıyoruz.girisGerekli(): giriş yoksa kullanıcıyı/giris'e gönderir, varsa kimliği döndürür.redirectbir hata fırlatarak çalıştığı için (Ders 44)return kullaniciIdsatırına yalnız giriş varsa gelinir; TypeScript da dönüş tipininnumberolduğunu bilir.
Şimdi her sayfanın çerçevesi. app/layout.tsx'in içini tamamen değiştir:
import type { Metadata } from "next"import Link from "next/link"import { cikisYap } from "@/app/giris/actions"import { oturumuGetir } from "@/lib/oturum"import { odak } from "@/lib/stil"import "./globals.css" export const metadata: Metadata = { title: { default: "Maç Notları", template: "%s · Maç Notları" }, description: "Maçlarından öğrendiklerini not al, hangi şampiyonda ne kadar kazandığını gör.",} export default async function RootLayout({ children }: LayoutProps<"/">) { const oturum = await oturumuGetir() return ( <html lang="tr" className="scheme-light-dark"> <body className="bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> <header className="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"> <nav className="mx-auto flex max-w-3xl flex-wrap items-center gap-x-5 gap-y-2 px-4 py-3"> <Link href="/" className={`text-lg font-bold ${odak}`}>Maç Notları</Link> {oturum && ( <> <Link href="/notlar" className={`hover:underline ${odak}`}>Notlarım</Link> <Link href="/istatistik" className={`hover:underline ${odak}`}>İstatistik</Link> <span className="ml-auto text-sm text-zinc-600 dark:text-zinc-400">{oturum.user.name}</span> <form action={cikisYap}> <button type="submit" className={`cursor-pointer text-sm font-semibold hover:underline ${odak}`}>Çıkış</button> </form> </> )} </nav> </header> <main className="mx-auto max-w-3xl px-4 py-8">{children}</main> </body> </html> )}title.template: alt sayfaların başlığı"Giriş"ise sekmedeGiriş · Maç Notlarıyazar (Ders 43).- Çıkış bir form, bağlantı değil: oturumu silen, yani iş yapan bir şey (Ders 45'teki kural: götüren bağlantı, iş yapan düğme). Bir Server Action'a bağlı olduğu için JavaScript yüklenmeden de çalışır.
- Ders 45'teki
odaksabitini de bir dosyaya koy; birazdan iki sabit daha ekleyeceğiz.lib/stil.ts:
export const odak = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500" export const baglantiDugme = `inline-block rounded-lg bg-violet-700 px-4 py-2 font-semibold text-white hover:bg-violet-600 ${odak}` export const girdi = `mt-1 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 dark:bg-zinc-950 aria-invalid:border-red-700 dark:aria-invalid:border-red-400 ${odak}`baglantiDugme düğme gibi görünen bağlantılar için (Ders 45'teki "Maç ekle"). girdi form kutuları için; aria-invalid: öneki, kutu aria-invalid="true" olduğunda kenarı kırmızı yapar. Hatalı kutuyu hem ekran okuyucuya hem göze aynı nitelik söylüyor.
Ana sayfa kısa. app/page.tsx:
import Link from "next/link"import { redirect } from "next/navigation"import { oturumdakiKullaniciId } from "@/lib/oturum"import { baglantiDugme } from "@/lib/stil" export default async function AnaSayfa() { if ((await oturumdakiKullaniciId()) !== null) { redirect("/notlar") } return ( <section className="py-10 text-center"> <h1 className="text-3xl font-bold">Her maç bir ders</h1> <p className="mx-auto mt-3 max-w-md text-zinc-600 dark:text-zinc-400"> Maçtan sonra aklında kalanı yaz: neyi iyi yaptın, neye dikkat edeceksin. Notların yalnız sana görünür. </p> <Link href="/giris" className={`mt-8 ${baglantiDugme}`}>Giriş yap</Link> </section> )}npm run dev ile sunucuyu başlat:
PS C:\kod\mac-notlari> npm run dev > mac-notlari@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 1619ms✓ Running next.config.ts took 73mshttp://localhost:3000/giris'i aç:

Önce deneme girişi: Deniz olarak gir'e bas. Next'in İngilizce 404 sayfası açılır (This page could not be found.), çünkü /notlar sayfası henüz yok. Giriş yine de oldu: menüde Notlarım, İstatistik, Deniz ve Çıkış var. Terminalde:
2026-10-12T04:27:32.999Z WARN [Better Auth]: User not found GET /notlar 404 in 328ms (next.js: 285ms, application-code: 43ms) POST /giris 200 in 516ms (next.js: 1.8ms, application-code: 515ms) └─ ƒ denemeGirisi({}) in 166ms app/giris/actions.tsUser not found bir uyarı: signInEmail Deniz'i bulamadı, catch devreye girdi ve hesap açıldı. İkinci girişte bu satır çıkmaz. GET /notlar 404 satırı, eylemin sonundaki redirect("/notlar")'ın sonucu; o sayfayı birazdan yazacaksın. Şimdi Çıkış'a bas ve Mert olarak gir ile ikinci kullanıcıyı da aç; yetki bölümünde ona ihtiyacın olacak. Sonra Neon'da SQL Editor'den bak:
SELECT id, name, email FROM kullanicilar ORDER BY id;| id | name | |
|---|---|---|
| 1 | Deniz | deniz@ornek.com |
| 2 | Mert | mert@ornek.com |
hesaplar tablosunda ikisinin de providerId değeri credential ("şifreyle"); password sütununda şifrenin kendisi değil, 4ed80fec6d92e5fba6514411ebfb09d0:4fe9c16… gibi bir değer var. İki nokta üst üstenin solu tuz, sağı hash: Better Auth da Ders 31'deki gibi node:crypto'nun scrypt'ini kullanıyor.
GitHub uygulamasını açtıysan Çıkış'a basıp GitHub ile giriş yap'ı da dene: GitHub'ın izin sayfası açılır, Authorize'a basınca aynı 404 sayfasına dönersin ve menüde GitHub'daki adın görünür. İşin bitince yine Çıkış'a bas; notları birazdan Deniz'le yazacaksın. GitHub'la Deniz'den önce girdiysen kullanıcı numaraları sende bir kayar (GitHub hesabın 1, Deniz 2, Mert 3). Sorun değil; dersteki sorgularda kullanıcı numarası olarak 1 ve 2 yazan yerlere kendi numaralarını koy.
Notlar: ekle, göster, düzenle, sil
Şema ve tipler
Formdan gelecek verinin kuralları Ders 44'teki gibi bir zod şemasında. lib/notSemasi.ts:
import { z } from "zod" export const NotSemasi = z.object({ sampiyon: z.string().trim().min(2, "Şampiyon adı en az 2 harf olmalı.").max(20, "Şampiyon adı en fazla 20 harf olabilir."), sonuc: z.enum(["galibiyet", "kayıp"], "Sonuç listeden seçilmeli."), tarih: z.iso.date("Tarih geçerli bir gün olmalı."), metin: z.string().trim().min(10, "Not en az 10 harf olmalı.").max(2000, "Not en fazla 2000 harf olabilir."),}) export type NotVerisi = z.infer<typeof NotSemasi> export type Not = NotVerisi & { id: number } export type FormDurumu = { hatalar?: Record<string, string[] | undefined> degerler?: Record<string, string> mesaj?: string}z.iso.date(…): metnin2026-09-20biçiminde, gerçekten var olan bir gün olmasını ister. Tarayıcının tarih kutusu (<input type="date">) değeri tam bu biçimde gönderir, ekranda20.09.2026gösterse bile.Not = NotVerisi & { id: number }:&iki tipi birleştirir: şemanın dört alanı artıid. Veritabanından gelen bir not böyle görünecek. Dosyadaserver-onlyyok, çünkü form bileşeni (istemci) de bu tipleri kullanacak.mesaj: Ders 44'tekiFormDurumu'na bir alan ekledik. Alan hatası olmayan bir sorunu ("bu not bulunamadı") formun tepesinde göstermek için.
Veri katmanı: her sorguda kullanıcı
lib/notlar.ts. Bu dosya dersin en önemli dosyası; her fonksiyonun ilk parametresine bak:
import "server-only"import { pool } from "@/lib/db"import type { Not, NotVerisi } from "@/lib/notSemasi" function gecerliId(id: string) { return /^\d{1,9}$/.test(id)} export async function notlariGetir(kullaniciId: number): Promise<Not[]> { const { rows } = await pool.query<Not>( `SELECT id, sampiyon, sonuc, tarih::text AS tarih, metin FROM notlar WHERE kullanici_id = $1 ORDER BY tarih DESC, id DESC`, [kullaniciId] ) return rows} export async function notuGetir(kullaniciId: number, id: string): Promise<Not | undefined> { if (!gecerliId(id)) { return undefined } const { rows } = await pool.query<Not>( `SELECT id, sampiyon, sonuc, tarih::text AS tarih, metin FROM notlar WHERE id = $1 AND kullanici_id = $2`, [id, kullaniciId] ) return rows[0]} export async function notEkle(kullaniciId: number, veri: NotVerisi): Promise<number> { const { rows } = await pool.query<{ id: number }>( `INSERT INTO notlar (kullanici_id, sampiyon, sonuc, tarih, metin) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [kullaniciId, veri.sampiyon, veri.sonuc, veri.tarih, veri.metin] ) return rows[0].id} export async function notuGuncelle(kullaniciId: number, id: string, veri: NotVerisi): Promise<boolean> { if (!gecerliId(id)) { return false } const { rowCount } = await pool.query( `UPDATE notlar SET sampiyon = $1, sonuc = $2, tarih = $3, metin = $4 WHERE id = $5 AND kullanici_id = $6`, [veri.sampiyon, veri.sonuc, veri.tarih, veri.metin, id, kullaniciId] ) return rowCount === 1} export async function notuSil(kullaniciId: number, id: string): Promise<boolean> { if (!gecerliId(id)) { return false } const { rowCount } = await pool.query( "DELETE FROM notlar WHERE id = $1 AND kullanici_id = $2", [id, kullaniciId] ) return rowCount === 1}Vurgulu satırlar dört sorgunun kullanıcı şartı. Dosyanın tamamında kullanici_id'siz tek bir okuma, güncelleme ya da silme yok.
- Her fonksiyon
kullaniciIdalır. Notlara dokunan hiçbir fonksiyon kullanıcısız çağrılamaz; TypeScript eksik parametreyi yakalar. Kimliğin nereden geldiğine dikkat: bu dosya bilmiyor, çağıran kod oturumdan getirecek. WHERE id = $1 AND kullanici_id = $2: "1 numaralı not" değil, "1 numaralı not, eğer bu kullanıcınınsa". Sahibi başkaysa sorgu boş döner; bu bir hata değil, "öyle bir not yok" cevabı. Sebebini yetki bölümünde göreceksin.rowCount === 1:UPDATEveDELETEkaç satıra dokunduklarını söyler (Ders 37).0ise ya böyle bir not yok ya da bu kullanıcının değil; çağıran ikisini ayırt etmeyecek.- Ters tırnaklı sorgular: SQL'i bir şablon metne yazdık, çünkü ters tırnaklı metin satırlara bölünebiliyor; şart satırı kendi satırında, göze çarpıyor. İçinde
${…}yok: değerler yine$1,$2ile ayrı gidiyor (Ders 37). gecerliId: Ders 44'tekiabctuzağının çaresi. Adresten ya da formdan gelen kimlik bir sayı değilse sorgu hiç gitmez.tarih::text AS tarih:datesütununu metne çeviriyoruz. Neden gerekli olduğunu bir hata kartında göreceksin.
Liste sayfası
Her not bir kart. Ders 45'teki MacKarti'nın kardeşi, bu kez tıklanabilir. components/NotKarti.tsx:
import Link from "next/link"import type { Not } from "@/lib/notSemasi"import { odak } from "@/lib/stil"import { tarihYaz } from "@/lib/tarih" const sonucSiniflari = { galibiyet: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300", kayıp: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",} export default function NotKarti({ not }: { not: Not }) { const galibiyet = not.sonuc === "galibiyet" return ( <Link href={`/notlar/${not.id}`} className={`block rounded-xl border border-zinc-200 bg-white p-4 shadow-sm hover:border-violet-400 dark:border-zinc-800 dark:bg-zinc-900 ${odak}`} > <div className="flex items-center justify-between gap-3"> <h2 className="text-lg font-bold">{not.sampiyon}</h2> <span className={`rounded-full px-2.5 py-0.5 text-sm font-semibold ${sonucSiniflari[not.sonuc]}`}> <span aria-hidden="true">{galibiyet ? "✓ " : "✗ "}</span> {galibiyet ? "Galibiyet" : "Kayıp"} </span> </div> <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{tarihYaz(not.tarih)}</p> <p className="mt-2 line-clamp-2">{not.metin}</p> </Link> )}kayıp: bir obje anahtarı; anahtarlarda Türkçe harf serbest, değerle aynı yazılması yeter. line-clamp-2 uzun metni iki satırda keser ve sonuna … koyar. Tarihi 20 Eylül 2026 diye yazan küçük yardımcı, lib/tarih.ts:
export function tarihYaz(tarih: string) { return new Date(tarih).toLocaleDateString("tr-TR", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC", })}toLocaleDateString, Ders 17'deki toLocaleString'in yalnız tarihi yazan kardeşi; ikinci argüman neyin nasıl yazılacağını söyler. new Date("2026-09-20") o günün UTC gece yarısını verir; timeZone: "UTC" yazmasaydık tarih, kodun çalıştığı bilgisayarın saat dilimine göre yazılırdı: Türkiye'deki bir bilgisayarda sorun olmazdı, ama saat dilimi UTC'nin gerisinde olan bir yerde (ör. ABD'deki bir sunucuda) bir önceki gün çıkardı.
Sayfa. app/notlar/page.tsx:
import type { Metadata } from "next"import Link from "next/link"import NotKarti from "@/components/NotKarti"import { notlariGetir } from "@/lib/notlar"import { girisGerekli } from "@/lib/oturum"import { baglantiDugme } from "@/lib/stil" export const metadata: Metadata = { title: "Notlarım",} export default async function NotlarSayfasi() { const kullaniciId = await girisGerekli() const notlar = await notlariGetir(kullaniciId) return ( <> <div className="flex flex-wrap items-center justify-between gap-4"> <h1 className="text-3xl font-bold">Notlarım</h1> <Link href="/notlar/yeni" className={baglantiDugme}>Yeni not</Link> </div> {notlar.length === 0 ? ( <div className="mt-6 rounded-xl border-2 border-dashed border-zinc-300 px-6 py-12 text-center dark:border-zinc-700"> <h2 className="text-lg font-bold">Henüz not yok</h2> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Bir maç bitince aklında kalanı yaz; burada kart kart birikir.</p> <Link href="/notlar/yeni" className={`mt-6 ${baglantiDugme}`}>İlk notunu yaz</Link> </div> ) : ( <ul className="mt-6 grid gap-4 sm:grid-cols-2"> {notlar.map((not) => ( <li key={not.id}> <NotKarti not={not} /> </li> ))} </ul> )} </> )}İki satır sayfanın bütün güvenliği: girisGerekli() kimliği oturumdan alır, notlariGetir(kullaniciId) yalnız o kullanıcının notlarını getirir. Adreste, formda, hiçbir yerde kullanıcı numarası yok. Liste boşken Ders 45'teki boş durum: neden boş olduğunu söylüyor ve ilk adımı gösteriyor.
Yeni not: form ve Server Action
Form Ders 44'teki SampiyonFormu'nun aynı düzeninde: useActionState, alan hataları, yazılanları geri doldurmak. Bir farkla: aynı form hem yeni not hem düzenleme için kullanılacak, bu yüzden hangi Server Action'ı çağıracağını dışarıdan alıyor. app/notlar/NotFormu.tsx:
"use client" import { useActionState } from "react"import Dugme from "@/components/Dugme"import type { FormDurumu, Not } from "@/lib/notSemasi"import { girdi } from "@/lib/stil" type Props = { eylem: (onceki: FormDurumu, formData: FormData) => Promise<FormDurumu> not?: Not} function AlanHatasi({ id, mesajlar }: { id: string; mesajlar?: string[] }) { if (!mesajlar) { return null } return <p id={id} className="mt-1 text-sm text-red-700 dark:text-red-400">{mesajlar[0]}</p>} export default function NotFormu({ eylem, not }: Props) { const [durum, formAction, bekliyor] = useActionState(eylem, {}) const hatalar = durum.hatalar ?? {} const d = durum.degerler return ( <form action={formAction} className="mt-6 grid max-w-lg gap-4"> {not && <input type="hidden" name="id" value={not.id} />} {durum.mesaj && <p role="alert" className="text-red-700 dark:text-red-400">{durum.mesaj}</p>} <div> <label htmlFor="sampiyon" className="font-medium">Şampiyon</label> <input id="sampiyon" name="sampiyon" required maxLength={20} defaultValue={d?.sampiyon ?? not?.sampiyon} aria-invalid={!!hatalar.sampiyon} aria-describedby="sampiyon-hata" className={girdi} /> <AlanHatasi id="sampiyon-hata" mesajlar={hatalar.sampiyon} /> </div> <div> <label htmlFor="sonuc" className="font-medium">Sonuç</label> <select id="sonuc" name="sonuc" key={d?.sonuc} defaultValue={d?.sonuc ?? not?.sonuc} className={girdi}> <option value="galibiyet">Galibiyet</option> <option value="kayıp">Kayıp</option> </select> <AlanHatasi id="sonuc-hata" mesajlar={hatalar.sonuc} /> </div> <div> <label htmlFor="tarih" className="font-medium">Tarih</label> <input id="tarih" name="tarih" type="date" required defaultValue={d?.tarih ?? not?.tarih} aria-invalid={!!hatalar.tarih} aria-describedby="tarih-hata" className={girdi} /> <AlanHatasi id="tarih-hata" mesajlar={hatalar.tarih} /> </div> <div> <label htmlFor="metin" className="font-medium">Not</label> <textarea id="metin" name="metin" required rows={6} maxLength={2000} defaultValue={d?.metin ?? not?.metin} aria-invalid={!!hatalar.metin} aria-describedby="metin-hata" className={girdi} /> <AlanHatasi id="metin-hata" mesajlar={hatalar.metin} /> </div> <div> <Dugme type="submit" disabled={bekliyor}>{bekliyor ? "Kaydediliyor…" : "Kaydet"}</Dugme> </div> </form> )}eylembir prop: fonksiyonlar da props olarak verilebilir (Ders 40). Bir sunucu bileşeni, bir Server Action'ı istemci bileşenine böyle geçirebilir; Ders 44'teki hata kartında Next'in kendisi de bu yolu öneriyordu.d?.sampiyon ?? not?.sampiyon: önce kullanıcının az önce yazdığı (hata sonrası geri gelen) değer, yoksa düzenlenen notun değeri, o da yoksa boş.?.Ders 12'deki isteğe bağlı zincirleme:dyoksa hata vermedenundefineddöner.- Gizli
idkutusu: yalnız düzenlerken var. Sunucu hangi notun güncelleneceğini bu kutudan öğrenecek. Bu kutu tarayıcıda, yani kullanıcının elinde; yetki bölümünde onu bilerek değiştireceksin. required,maxLength: tarayıcı boş ya da çok uzun bir formu hiç göndermez; kullanıcı beklemeden uyarı görür. Ama bunlar kolaylık, güvenlik değil: sunucu yine zod'la denetleyecek.
Server Action'lar app/notlar/actions.ts'te:
"use server" import { revalidatePath } from "next/cache"import { redirect } from "next/navigation"import { z } from "zod"import { notEkle, notuGuncelle, notuSil } from "@/lib/notlar"import { NotSemasi, type FormDurumu } from "@/lib/notSemasi"import { girisGerekli } from "@/lib/oturum" function metin(formData: FormData, ad: string) { const deger = formData.get(ad) return typeof deger === "string" ? deger : ""} function formuOku(formData: FormData) { return { sampiyon: metin(formData, "sampiyon"), sonuc: metin(formData, "sonuc"), tarih: metin(formData, "tarih"), metin: metin(formData, "metin"), }} export async function notEkleEylemi(onceki: FormDurumu, formData: FormData): Promise<FormDurumu> { const kullaniciId = await girisGerekli() const degerler = formuOku(formData) const sonuc = NotSemasi.safeParse(degerler) if (!sonuc.success) { return { hatalar: z.flattenError(sonuc.error).fieldErrors, degerler } } const id = await notEkle(kullaniciId, sonuc.data) revalidatePath("/notlar") redirect(`/notlar/${id}`)} export async function notGuncelleEylemi(onceki: FormDurumu, formData: FormData): Promise<FormDurumu> { const kullaniciId = await girisGerekli() const id = metin(formData, "id") const degerler = formuOku(formData) const sonuc = NotSemasi.safeParse(degerler) if (!sonuc.success) { return { hatalar: z.flattenError(sonuc.error).fieldErrors, degerler } } const guncellendi = await notuGuncelle(kullaniciId, id, sonuc.data) if (!guncellendi) { return { mesaj: "Bu not bulunamadı.", degerler } } revalidatePath("/notlar") redirect(`/notlar/${id}`)} export async function notSilEylemi(formData: FormData) { const kullaniciId = await girisGerekli() await notuSil(kullaniciId, metin(formData, "id")) revalidatePath("/notlar") redirect("/notlar")}Üç eylem de aynı sırayla başlar: önce kim (girisGerekli), sonra veri (safeParse), en son iş. Server Action herkese açık bir kapı olduğu için sayfanın giriş denetimi yetmez; her eylem kendi kapısını kendisi tutar. Formdan gelen alanların hiçbiri kullanıcı kimliği değil.
Sayfa yalnız başlık ve form. app/notlar/yeni/page.tsx:
import type { Metadata } from "next"import { girisGerekli } from "@/lib/oturum"import { notEkleEylemi } from "../actions"import NotFormu from "../NotFormu" export const metadata: Metadata = { title: "Yeni not",} export default async function YeniNotSayfasi() { await girisGerekli() return ( <> <h1 className="text-3xl font-bold">Yeni not</h1> <NotFormu eylem={notEkleEylemi} /> </> )}Deniz olarak gir (Mert'le girmişsen önce Çıkış), Yeni not'a bas ve önce kötü bir not dene: şampiyon kutusuna boşluk, A, boşluk; not kutusuna Kötüydü. Tarayıcının required denetimi geçer (kutular boş değil), zod geçirmez:

Sonra gerçek bir not yaz: Ahri, Kayıp, 20.09.2026, "Orta koridorda Zed'e karşı oynadım. 6. seviyeden sonra her ultisinde öldüm, çünkü E'mi önceden harcıyordum. Bir dahaki sefere E'yi Zed ultisini attıktan sonra kullan." Kaydet: sayfa notun kendi adresine, /notlar/1'e gider. O sayfayı şimdi yaz.
Not sayfası, düzenleme ve silme
app/notlar/[id]/page.tsx:
import Link from "next/link"import { notFound } from "next/navigation"import Dugme from "@/components/Dugme"import { notuGetir } from "@/lib/notlar"import { girisGerekli } from "@/lib/oturum"import { baglantiDugme } from "@/lib/stil"import { tarihYaz } from "@/lib/tarih"import { notSilEylemi } from "../actions" export default async function NotSayfasi({ params }: PageProps<"/notlar/[id]">) { const { id } = await params const kullaniciId = await girisGerekli() const not = await notuGetir(kullaniciId, id) if (!not) { notFound() } return ( <article> <p className="text-sm text-zinc-600 dark:text-zinc-400"> {tarihYaz(not.tarih)} · {not.sonuc === "galibiyet" ? "Galibiyet" : "Kayıp"} </p> <h1 className="mt-1 text-3xl font-bold">{not.sampiyon}</h1> <p className="mt-4 whitespace-pre-line">{not.metin}</p> <div className="mt-8 flex flex-wrap gap-3"> <Link href={`/notlar/${not.id}/duzenle`} className={baglantiDugme}>Düzenle</Link> <form action={notSilEylemi}> <input type="hidden" name="id" value={not.id} /> <Dugme type="submit" tur="tehlike">Sil</Dugme> </form> </div> </article> )}PageProps<"/notlar/[id]">Next'in klasörlerden ürettiği tip (Ders 45). Yeni bir klasör açtıktan sonra VS Code tipi bulamazsanpm run devaçıkken dosyayı bir kez kaydet.notFound()hem "böyle bir not yok" hem "bu not senin değil" durumunda çalışır. Kullanıcıya ikisini ayırt ettirmiyoruz; ayırt ettirseydik "2 numaralı not var ama başkasının" bilgisini sızdırırdık.whitespace-pre-line: notun içindeki satır sonları ekranda da satır sonu olsun.- Silme bir form: veri değiştiren her şey gibi
POSTile, bir Server Action'a gider.
Düzenleme sayfası aynı formu, notun değerleriyle açar. app/notlar/[id]/duzenle/page.tsx:
import type { Metadata } from "next"import { notFound } from "next/navigation"import { notuGetir } from "@/lib/notlar"import { girisGerekli } from "@/lib/oturum"import { notGuncelleEylemi } from "../../actions"import NotFormu from "../../NotFormu" export const metadata: Metadata = { title: "Notu düzenle",} export default async function DuzenleSayfasi({ params }: PageProps<"/notlar/[id]/duzenle">) { const { id } = await params const kullaniciId = await girisGerekli() const not = await notuGetir(kullaniciId, id) if (!not) { notFound() } return ( <> <h1 className="text-3xl font-bold">Notu düzenle</h1> <NotFormu eylem={notGuncelleEylemi} not={not} /> </> )}Son olarak Next'in İngilizce 404 sayfası yerine kendi sayfan. app/not-found.tsx:
import Link from "next/link"import { baglantiDugme } from "@/lib/stil" export default function Bulunamadi() { return ( <section className="py-10 text-center"> <h1 className="text-3xl font-bold">Burada bir şey yok</h1> <p className="mt-3 text-zinc-600 dark:text-zinc-400">Aradığın not silinmiş ya da hiç olmamış olabilir.</p> <Link href="/notlar" className={`mt-8 ${baglantiDugme}`}>Notlarıma dön</Link> </section> )}Birkaç not daha yaz, birini düzenle, birini sil. Sınırları da dene: /notlar/abc ve /notlar/9999 "Burada bir şey yok" demeli; çıkış yapıp /notlar/yeni'yi açmaya çalışırsan /giris'e gitmelisin.
date sütunu Date objesi geliyorNe zaman çıkar? Sorguda tarih::text AS tarih yerine düz tarih yazınca. Hata mesajı yok: 20 Eylül'de oynanan maçın kartında 19 Eylül 2026 yazar. Sebebi pg'nin date sütununu ne yaptığı. Aynı satırı iki biçimde okuduk:
{ tarih: 2026-09-19T21:00:00.000Z, metin: '2026-09-20' }pg, date değerini bir JavaScript Date objesine çevirir: sunucunun saat diliminde o günün gece yarısı. Türkiye UTC'nin üç saat ilerisinde olduğu için 20 Eylül 00.00, UTC'de 19 Eylül 21.00 eder. tarihYaz da UTC'ye göre yazdığı için bir gün geri gider. TypeScript fark etmez: Not tipinde tarih: string yazıyor, ama pool.query<Not> bir söz, denetim değil (Ders 44). Daha sinsi yanı: Vercel'in sunucuları UTC'de çalıştığı için yayında doğru görünür; hata yalnız senin bilgisayarında çıkar.
Nasıl düzelir? date sütununu SQL'de metne çevir: tarih::text AS tarih her yerde '2026-09-20' verir. Saat dilimi olmayan bir gün için metin en güvenli taşıyıcı. Saati olan timestamptz için durum farklı; o gerçekten bir an olduğu için Date olarak gelmesi doğru.
Yetki: herkes yalnız kendi notunu görür
Deniz bir not yazdı ve adresi /notlar/1 oldu. Mert de bir not yazacak; adresi /notlar/2 olacak. Numaralar sırayla artıyor, yani tahmin edilebilir. Deniz adres çubuğuna 2 yazarsa ne olur? Bu bölümde yetkiyi önce bilerek bozacak, saldırganın gözünden deneyecek, sonra düzelteceksin.
İlk adım, ikinci bir kullanıcı: çıkış yap, Mert olarak gir, bir not yaz: Yasuo, Kayıp, 06.10.2026, "Ejderha savaşında rüzgâr duvarını geç açtım, Jinx'in roketleri hepimizi bitirdi." Notun adresi /notlar/2. Çıkış yap, Deniz olarak gir.
Deneme 1: adresi değiştirmek
Önce açığı bilerek aç. notuGetir'deki sorgudan kullanıcı şartını sil (bu bir deney; birazdan geri alacaksın):
const { rows } = await pool.query<Not>( `SELECT id, sampiyon, sonuc, tarih::text AS tarih, metin FROM notlar WHERE id = $1`, [id])const { rows } = await pool.query<Not>( `SELECT id, sampiyon, sonuc, tarih::text AS tarih, metin FROM notlar WHERE id = $1 AND kullanici_id = $2`, [id, kullaniciId])Deniz olarak /notlar/2'yi aç:

Sağ üstte Deniz yazıyor, sayfada Mert'in notu var; üstelik Düzenle ve Sil düğmeleriyle. Sayfa giriş istiyordu (girisGerekli), Deniz de giriş yapmıştı: kimlik doğrulama kusursuz çalıştı. Eksik olan yetkilendirme. Ne derleme ne npm run lint bir şey dedi; denedik, ikisi de sessizdi. kullaniciId parametresi fonksiyonda kullanılmadan duruyordu ve hiçbir araç bunu dert etmedi.
Şartı geri koy ve yeniden aç: "Burada bir şey yok", durum kodu 404. Sorgu "2 numaralı not, eğer Deniz'inse" diye sordu; öyle bir satır yok.
Deneme 2: gizli kutuyu değiştirmek
Daha sinsi bir deneme. Sayfaların hepsi korunuyor, ama Server Action ayrı bir kapı. Bu kez notuGetir doğru, yalnız notuGuncelle'nin sorgusu şartsız olsun:
`UPDATE notlar SET sampiyon = $1, sonuc = $2, tarih = $3, metin = $4 WHERE id = $5`,[veri.sampiyon, veri.sonuc, veri.tarih, veri.metin, id]Deniz olarak kendi notunun düzenleme sayfasını aç: /notlar/1/duzenle. Sayfa açılır, çünkü not Deniz'in. Şimdi saldırganın yaptığını yap:
- F12 ile geliştirici araçlarını aç, Elements sekmesine geç.
- Ctrl+F ile
name="id"ara:<input type="hidden" name="id" value="1">. value="1"'in üstüne çift tıkla,2yaz, Enter'a bas.- Not kutusuna "Bu notu Deniz değiştirdi. Mert'in haberi yok." yaz ve Kaydet'e bas.
Ekranda "Burada bir şey yok" çıkar: eylem bitince Deniz'i /notlar/2'ye gönderdi ve o sayfa doğru çalıştı. Her şey yolunda görünüyor. Veritabanına bak:
SELECT id, kullanici_id, sampiyon, metin FROM notlar ORDER BY id;| id | kullanici_id | sampiyon | metin |
|---|---|---|---|
| 1 | 1 | Ahri | Orta koridorda Zed'e karşı oynadım. 6. seviyeden sonra … |
| 2 | 2 | Ahri | Bu notu Deniz değiştirdi. Mert'in haberi yok. |
Mert'in notu değişmiş; şampiyonu bile Ahri olmuş. Tablo göstermiyor ama sonucu ve tarihi de Deniz'in notununki oldu, çünkü form Deniz'in notunun değerleriyle doluydu. Ekran "bulunamadı" derken veritabanı yazılmıştı. Bu deneme bir şeyi gösteriyor: sayfayı korumak eylemi korumaz. Düzenleme sayfası "bu not senin mi?" diye sordu ve doğru cevabı aldı; ama gizli kutu tarayıcıdaydı ve tarayıcı kullanıcının elinde.
Şartı geri koy (WHERE id = $5 AND kullanici_id = $6, dizide id, kullaniciId). Sonra Mert'in notunu eski hâline getir: çıkış yap, Mert olarak gir, notunu aç ve Düzenle ile Yasuo, Kayıp, 06.10.2026 ve kendi metnine geri çevir. Deniz olarak yeniden gir ve denemeyi tekrarla:

UPDATE … WHERE id = 2 AND kullanici_id = 1 hiçbir satır bulamadı, rowCount 0 oldu, eylem { mesaj: "Bu not bulunamadı." } döndürdü. Mert'in notu yerinde.
Deneme 3: kimliği formdan almak
Bir yanlış daha, bu kez iyi niyetle yapılanı. "Kullanıcının kimliği zaten sayfada var, forma bir gizli kutu daha koyayım" diye düşünmek kolay:
<input type="hidden" name="kullaniciId" value={kullaniciId} />const kullaniciId = await girisGerekli()Soldaki kutuyu Deniz, Deneme 2'deki gibi 2 yapar ve Mert adına not yazar; ya da kullanici_id şartını kendi istediği numarayla doldurur. Formdan gelen kimliğe güvenen bir sorgu, şartı hiç olmayan bir sorgu kadar açıktır. Kural: kullanıcının kim olduğu yalnız oturumdan okunur. Form ve adres "hangi not?" sorusunu cevaplayabilir; "kim?" sorusunu asla.
Yetkinin kuralları, tek yerde:
- Kullanıcının kimliği oturumdan gelir:
girisGerekli(). Formdan, adresten, istek gövdesinden asla. - Kullanıcıya ait veriye dokunan her sorguda
kullanici_idşartı vardır: okuma, güncelleme, silme, sayma. - Sayfayı korumak eylemi korumaz; her Server Action ve her Route Handler kendi denetimini yapar.
- "Yok" ile "senin değil" aynı cevabı alır:
404ya da "bulunamadı". - Ne TypeScript ne ESLint bu açığı yakalar. Yakalamanın yolu, bu bölümde yaptığın gibi, iki kullanıcıyla bilerek denemek.
İstatistik
İstatistik sayfası Ders 34'teki GROUP BY'ı kullanacak: şampiyon başına kaç maç, kaçı galibiyet. Önce sayfaya veri lazım. Deniz'in birkaç maçını tek seferde eklemek için SQL Editor'de (dal: gelistirme) şunu çalıştır; veri yükleyen bir betik olduğu için kopyalayabilirsin. kullanici_id değerinin 1 olduğunu az önceki kullanicilar sorgusundan biliyorsun; sende farklıysa değiştir:
INSERT INTO notlar (kullanici_id, sampiyon, sonuc, tarih, metin) VALUES (1, 'Ahri', 'galibiyet', '2026-09-24', 'Yine Zed vardı. Bu kez erken Zhonya aldım; Zed ultisini atınca Zhonya''yı açtım ve ölmedim.'), (1, 'Lux', 'kayıp', '2026-09-27', 'Destek oynadım. Ejderha savaşlarının üçüne de geç kaldım; görüş koymadan nehre girdim.'), (1, 'Jinx', 'galibiyet', '2026-09-29', 'Erken ganklar yedim ama farmı bırakmadım. Geç oyunda takım savaşlarını temizledim.'), (1, 'Garen', 'kayıp', '2026-10-02', 'Teemo''ya karşı oynadım. Mantarların yerini tahmin etmeden çalıya girme.'), (1, 'Lux', 'galibiyet', '2026-10-05', 'Kalkanı nişancının önüne attım, iki ölümü engelledim. Ejderhadan önce nehre totem koydum.'), (1, 'Ahri', 'galibiyet', '2026-10-08', 'Rakip Talon''du. E''yi hep elimde tuttum ve Talon her geldiğinde büyüledim.');Liste sayfası artık dolu:

Şampiyon başına iki sayı istiyoruz: toplam maç ve galibiyet. Toplam, Ders 34'teki count(*). Galibiyet için yeni ama küçük bir parça var: FILTER.
SELECT sampiyon, count(*)::int AS mac, count(*) FILTER (WHERE sonuc = 'galibiyet')::int AS galibiyetFROM notlarWHERE kullanici_id = 1GROUP BY sampiyonORDER BY mac DESC, sampiyon;| sampiyon | mac | galibiyet |
|---|---|---|
| Ahri | 3 | 2 |
| Lux | 2 | 1 |
| Garen | 1 | 0 |
| Jinx | 1 | 1 |
count(*) FILTER (WHERE …) "bu gruptaki satırlardan yalnız şu şarta uyanları say" demek. Sorgunun genel WHERE'inden farkı: o, satırları gruplara girmeden eler; FILTER ise yalnız bu sayının hangi satırlara bakacağını söyler. Böylece aynı grupta hem bütün maçları hem galibiyetleri sayabildin. ::int, Ders 37'deki tuzak için: count bigint döndürür ve pg onu metin olarak verir. ORDER BY mac DESC, sampiyon: önce en çok oynanan, eşitse alfabetik; Garen ile Jinx bu yüzden bu sırada.
Fonksiyonu lib/notlar.ts'in sonuna ekle:
export type SampiyonSatiri = { sampiyon: string mac: number galibiyet: number} export async function sampiyonIstatistigi(kullaniciId: number): Promise<SampiyonSatiri[]> { const { rows } = await pool.query<SampiyonSatiri>( `SELECT sampiyon, count(*)::int AS mac, count(*) FILTER (WHERE sonuc = 'galibiyet')::int AS galibiyet FROM notlar WHERE kullanici_id = $1 GROUP BY sampiyon ORDER BY mac DESC, sampiyon`, [kullaniciId] ) return rows}Ve elbette WHERE kullanici_id = $1: sayılar da kişisel veri. Sayfa, app/istatistik/page.tsx:
import type { Metadata } from "next"import { sampiyonIstatistigi } from "@/lib/notlar"import { girisGerekli } from "@/lib/oturum" export const metadata: Metadata = { title: "İstatistik",} function oran(galibiyet: number, mac: number) { return Math.round((galibiyet / mac) * 100)} export default async function IstatistikSayfasi() { const kullaniciId = await girisGerekli() const satirlar = await sampiyonIstatistigi(kullaniciId) const toplamMac = satirlar.reduce((toplam, s) => toplam + s.mac, 0) const toplamGalibiyet = satirlar.reduce((toplam, s) => toplam + s.galibiyet, 0) if (toplamMac === 0) { return ( <> <h1 className="text-3xl font-bold">İstatistik</h1> <p className="mt-4 text-zinc-600 dark:text-zinc-400">İstatistik için önce birkaç not yaz.</p> </> ) } return ( <> <h1 className="text-3xl font-bold">İstatistik</h1> <p className="mt-2 text-lg"> {toplamMac} maç, {toplamGalibiyet} galibiyet: <strong>%{oran(toplamGalibiyet, toplamMac)}</strong> </p> <table className="mt-6 w-full text-left"> <thead className="border-b border-zinc-300 text-sm text-zinc-600 dark:border-zinc-700 dark:text-zinc-400"> <tr> <th className="py-2">Şampiyon</th> <th className="py-2">Maç</th> <th className="py-2">Galibiyet</th> <th className="py-2">Oran</th> </tr> </thead> <tbody> {satirlar.map((s) => ( <tr key={s.sampiyon} className="border-b border-zinc-200 dark:border-zinc-800"> <td className="py-2 font-semibold">{s.sampiyon}</td> <td className="py-2">{s.mac}</td> <td className="py-2">{s.galibiyet}</td> <td className="py-2"> <div className="flex items-center gap-2"> <div className="h-2 w-24 rounded-full bg-zinc-200 dark:bg-zinc-800"> <div className="h-2 rounded-full bg-violet-600" style={{ width: `${oran(s.galibiyet, s.mac)}%` }} /> </div> %{oran(s.galibiyet, s.mac)} </div> </td> </tr> ))} </tbody> </table> </> )}- Toplamları ikinci bir sorguyla değil, gelen satırlardan
reduceile hesaplıyoruz: veri zaten elimizde. toplamMac === 0sınırı: hiç notu olmayan biri için0 / 0NaNolurdu ve ekranda%NaNyazardı. Boş durumu ayrı çiziyoruz.- Çubuğun genişliği bir
style: oran her satırda farklı bir sayı. Tailwind sınıfını sayıdan kurmanın neden çalışmadığını Ders 45'te görmüştün. - Yüzde işareti Türkçede sayının önüne yazılır:
%57.

Mert olarak gir ve sayfayı aç: tek satır, Yasuo, 1 maç, 0 galibiyet. Deniz'in yedi maçı Mert'in sayılarına karışmadı.
Yayına almak: Vercel
Ders 32'de API'yi Render'a koymuştun: sürekli çalışan bir sunucu. Next.js uygulamasını sunucusuz bir barındırıcıya, Vercel'e koyacaksın. Next.js'i geliştiren şirket Vercel; ücretsiz Hobby planı kişisel projeler için yeterli. Her git push'tan sonra uygulamayı kendisi derler ve yayınlar.
Hazırlık: havuz ve derleme
Vercel'in sunucuları istek gelince açılır, iş bitince bir süre bekleyip uyur (Ders 32). Uyuyan bir kopyanın havuzunda açık kalan bağlantılar Neon'un bağlantı sınırını boşuna doldurabilir. Ders 44'teki bilgi kutusunda gördüğün çare: Vercel'in attachDatabasePool fonksiyonu. Kur:
PS C:\kod\mac-notlari> npm install @vercel/functions added 19 packages, and audited 414 packages in 5s...ve lib/db.ts'e iki satır ekle:
import { attachDatabasePool } from "@vercel/functions"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,}) attachDatabasePool(pool) pool.on("error", (hata) => { console.error("Havuzdaki boşta bekleyen bir bağlantı koptu:", hata.message)})Vercel'in belgesi: "Havuzu kurduktan hemen sonra çağır; boştaki bağlantılar fonksiyon uykuya geçmeden bırakılır." Kendi bilgisayarında hiçbir şey değiştirmez; denedik, geliştirme sunucusu ve derleme aynen çalıştı. Yayından önce Ders 44'teki üç denetimi yap:
PS C:\kod\mac-notlari> npx tsc --noEmitPS C:\kod\mac-notlari> npm run lint > mac-notlari@0.1.0 lint> eslint PS C:\kod\mac-notlari> npm run build > mac-notlari@0.1.0 build> next build ▲ Next.js 16.4.0 (Turbopack)- Environments: .env.local✓ Running next.config.ts took 56ms Creating an optimized production build ...✓ Compiled successfully in 3.8s Running TypeScript ... Finished TypeScript in 2.8s ... Collecting page data using 11 workers ... Generating static pages using 11 workers (0/8) ... Generating static pages using 11 workers (2/8) Generating static pages using 11 workers (4/8) Generating static pages using 11 workers (6/8) ✓ Generating static pages using 11 workers (8/8) in 200ms Finalizing page optimization ... Route (app)┌ ƒ /├ ƒ /_not-found├ ƒ /api/auth/[...all]├ ƒ /giris├ ƒ /istatistik├ ƒ /notlar├ ƒ /notlar/[id]├ ƒ /notlar/[id]/duzenle└ ƒ /notlar/yeni ƒ (Dynamic) server-rendered on demandDers 44'ten farklı bir tablo: bütün sayfalar ƒ. Sebebi layout.tsx: menüyü çizmek için oturumuGetir() her istekte tarayıcının çerezine bakıyor (Ders 44'te "sayfa çerez okumuyorsa saklanır" demiştik). Burada istediğimiz tam bu: her kullanıcı başka notlar görüyor, kimse başkasının saklı sayfasını almamalı. Ders 44'teki "yayında eski veri" sürprizi bu uygulamada çıkmaz. revalidatePath'leri yine de silme: tarayıcıdaki Next yönlendiricisinin önbelleğini de tazeler ve bir gün bir sayfayı saklanır yaparsan unutmamış olursun.
Canlı veritabanı
Neon'da SQL Editor'ün dal seçicisinden bu kez production'ı seç, schema.sql'in tamamını yapıştır ve çalıştır. Deneme verisini canlıya yükleme. Sonra Connect'e bas, dal production, Connection pooling açık; -pooler'lı dizeyi kopyala ve sonunu sslmode=verify-full yap. Bu dize birazdan Vercel'e gidecek, bilgisayarındaki hiçbir dosyaya değil.
GitHub'a gönder
Önce kaydet. git status'ta .env.local görünmemeli:
PS C:\kod\mac-notlari> git add .warning: in the working copy of 'app/globals.css', LF will be replaced by CRLF the next time Git touches itwarning: in the working copy of 'app/layout.tsx', LF will be replaced by CRLF the next time Git touches itwarning: in the working copy of 'app/page.tsx', LF will be replaced by CRLF the next time Git touches it...PS C:\kod\mac-notlari> git commit -m "Maç notları: giriş, notlar, yetki ve istatistik"[master 37986cd] Maç notları: giriş, notlar, yetki ve istatistik 28 files changed, 1482 insertions(+), 126 deletions(-) create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/giris/GirisDugmeleri.tsx create mode 100644 app/giris/actions.ts ... create mode 100644 lib/tarih.ts create mode 100644 schema.sqlGitHub'da mac-notlari adında boş bir depo aç (README ekleme seçeneğini işaretleme; README'yi sen yazacaksın), sonra Ders 7'deki komutlar. KULLANICIADIN yerine kendi GitHub adını yaz:
git branch -M maingit remote add origin https://github.com/KULLANICIADIN/mac-notlari.gitgit push -u origin mainİlk iki komut bir şey yazmaz. git push Ders 7'deki gibi gönderilen nesneleri sayar; son iki satırı * [new branch] main -> main ve branch 'main' set up to track 'origin/main'. olur.
create-next-app'in açtığı dalın adı master; Vercel'in "üretim dalı" GitHub deposunun varsayılan dalıdır ve yeni depolarda bu main. Dalı baştan main yapınca ikisi tutar.
Vercel'de proje
https://vercel.com/signupadresinde Continue with GitHub ile hesap aç; plan olarak Hobby'yi seç.https://vercel.com/newsayfasında GitHub hesabını bağla; Vercel bir GitHub uygulaması kurmak için izin ister. Depolar listesindemac-notlari'nın yanındaki Import'a bas.- Configure Project ekranında Framework Preset kendiliğinden Next.js olur; derleme ayarlarına dokunma.
- Environment Variables bölümünü aç ve aşağıdaki tablodaki değişkenleri tek tek gir. Sırlar için türü Secret seç: kaydettikten sonra değeri kimse, sen bile göremez.
- Deploy'a bas. Bir iki dakika sonra ekranda bir önizleme resmi ve adresin çıkar:
mac-notlari-XXXX.vercel.appgibi.
| Ad | Değer | Tür |
|---|---|---|
DATABASE_URL | Neon production dalının havuzlu dizesi | Secret |
BETTER_AUTH_SECRET | npx auth@latest secret ile yeni bir değer; geliştirmedekini kullanma | Secret |
GITHUB_CLIENT_ID | GitHub uygulamasının kimliği | Config |
GITHUB_CLIENT_SECRET | GitHub uygulamasının sırrı | Secret |
DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET | Discord kullanıyorsan | Config, Secret |
Ekran ve düğme adları Vercel'in Ekim 2026'daki belgelerine göre; değişmiş olabilir. BETTER_AUTH_URL listede yok, çünkü adresini henüz bilmiyorsun. Vercel ayrıca fonksiyonları varsayılan olarak ABD'nin doğu kıyısında (Washington, iad1) çalıştırır; veritabanın ise Frankfurt'ta. Her sorgu Atlantik'i iki kez geçmesin diye projenin Settings → Functions sayfasında Function Regions'dan Frankfurt (fra1) seç. Hobby planında tek bölge seçilebilir; o da yeter.
Canlı adresi her yere yaz
İlk yayın açıldı ama giriş henüz çalışmaz: GitHub yalnız localhost'a geri dönmeyi biliyor. Better Auth da kendi adresini bilmiyor; gelen istekten tahmin etmeye çalışıyor ve günlüğe Base URL is not set diye başlayan bir uyarı yazıyor. Üç yer:
- Vercel: projenin Settings → Environment Variables sayfasında
BETTER_AUTH_URLekle, değeri canlı adresin:https://mac-notlari-XXXX.vercel.app(sonunda eğik çizgi yok). - GitHub: OAuth uygulamanın sayfasında Add callback URL'e bas ve
https://mac-notlari-XXXX.vercel.app/api/auth/callback/githubekle. GitHub bir uygulamaya on taneye kadar geri dönüş adresi verebiliyor;localhostolanı silme, geliştirmede lazım. Homepage URL'i de canlı adres yap. Update application. - Discord: OAuth2 → Redirects'e ikinci adres olarak
https://mac-notlari-XXXX.vercel.app/api/auth/callback/discordekle, Save Changes.
Vercel'in belgesi açık: ortam değişkenlerindeki değişiklik yalnız yeni yayınlara uygulanır. Projenin Deployments sayfasında en üstteki yayının menüsünden Redeploy'a bas. Yeni yayın açılınca canlı adreste GitHub ile gir. Notların boş: production dalında henüz kimse yok, ilk kullanıcı sensin. Giriş sayfasında deneme kutusu da yok; NODE_ENV artık production.
BetterAuthError: You are using the default secret. Please set `BETTER_AUTH_SECRET` in your environment variables or pass `secret` in your auth config.Ne zaman çıkar? Yayında (npm run build ve npm run start, ya da Vercel) BETTER_AUTH_SECRET tanımlı değilken. Geliştirmede Better Auth eksik sırrın yerine herkesin bildiği bir varsayılan kullanır; yayında bunu reddeder. Kendi bilgisayarımızda sırrı silip yayın sunucusunu başlattık; derleme bu satırları yazıp yine bitti, ama sonra her sayfa 500 verdi:
PS C:\kod\mac-notlari> npm run start...⨯ [Error [BetterAuthError]: You are using the default secret. Please set `BETTER_AUTH_SECRET` in your environment variables or pass `secret` in your auth config.] { digest: '1923974184'}Vercel'de aynı satırı projenin Logs sayfasında görürsün (Ders 32).
Nasıl düzelir? npx auth@latest secret ile yeni bir değer üret, Vercel'de BETTER_AUTH_SECRET olarak ekle ve Redeploy et. Değişkenin adını harfi harfine kontrol et; BETTER_AUTH_SECRETS gibi bir yazım hatası da aynı hatayı verir.
Failed to collect page data ve DATABASE_URL tanımlı değilNe zaman çıkar? Vercel'de DATABASE_URL girilmeden yayın başlatılınca. Derleme, sayfaların ayarlarını toplarken lib/db.ts'i yükler; dosyanın ilk denetimi hata fırlatır ve derleme durur. Bilgisayarımızda .env.local'dan satırı silerek denedik:
PS C:\kod\mac-notlari> npm run build... Collecting page data using 11 workers ...Error: Failed to collect configuration for /api/auth/[...all] at ignore-listed frames { [cause]: 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({} > Build error occurredError: Failed to collect page data for /api/auth/[...all] at ignore-listed frames { type: 'Error'}Satırda adı geçen sayfa denemeden denemeye değişti (/api/auth/[...all], /notlar, /_not-found): derleme sayfaları aynı anda birkaç işçiyle topluyor ve lib/db.ts'i ilk yükleyen hangisiyse onun adı yazılıyor. Önemli olan [cause] satırı: 5. satırdaki, bizim yazdığımız denetim. Vercel aynı npm run build'i çalıştırır; bu satırlar yayının Build Logs bölümüne düşer ve yayın başarısız olur. Eski yayın, varsa, açık kalır.
Nasıl düzelir? Mesajı biz yazmıştık ve yer söylüyor. Vercel'de .env.local yok; değişkeni Settings → Environment Variables'a gir, Production ortamı işaretli olsun ve yeniden yayınla.
redirect_uri_mismatchNe zaman çıkar? Giriş isteğindeki geri dönüş adresi GitHub'a kaydettiğinle birebir aynı değilse. En sık sebepler: canlı adresi GitHub'a eklemeyi unutmak, BETTER_AUTH_URL'i yanlış yazmak (https yerine http, sonda fazladan /), GitHub'daki adreste yolu farklı yazmak (/api/auth/callback/github yerine /api/auth/github/callback gibi).
GitHub'ın belgesine göre GitHub bu durumda kullanıcıyı kayıtlı adrese error=redirect_uri_mismatch ve "The redirect_uri MUST match the registered callback URL for this application." açıklamasıyla geri gönderir. Bu dönüşü uygulamamızda canlandırdık; Better Auth kullanıcıyı kendi hata sayfasına götürdü:

Nasıl düzelir? Üç adresi yan yana koy ve harf harf karşılaştır: BETTER_AUTH_URL + /api/auth/callback/github, GitHub'daki kayıtlı adres ve tarayıcının adres çubuğundaki adres. Port, http/https ve sondaki eğik çizgi dahil aynı olmalı.
README, ekran görüntüleri ve canlı bağlantı
Uygulama yayında. Şimdi onu başkalarına anlat. Bir işe alımcı ya da başka bir yazılımcı deponu açtığında ilk README'yi okur; çoğu zaman yalnız onu. Ders 32'deki README bir API'nin belgesiydi; bu README bir ürünü tanıtıyor. Okuyanın üç sorusu var: bu ne, çalışıyor mu, nasıl yapılmış?
Önce ekran görüntüleri. Canlı adreste gir, birkaç gerçek not yaz, notlar sayfasını aç ve Win+Shift+S ile Ekran Alıntısı Aracı'nı aç (İngilizce Windows'ta Snipping Tool); pencereyi ya da bir alanı seç. Görüntü panoya kopyalanır; sağ altta çıkan bildirime tıklayıp Kaydet (Save) ile proje kökünde açacağın docs klasörüne notlar.png adıyla kaydet. İstatistik sayfası için de istatistik.png. Görüntüde başkasının adı ya da e-postası olmasın; deneme verisi en güvenlisi.
Sonra create-next-app'in yazdığı İngilizce README.md'nin içini tamamen değiştir (kendi canlı adresinle):
# Maç Notları Maçtan sonra aklında kalanı yazdığın, hangi şampiyonda ne kadar kazandığını gösteren bir web uygulaması. Herkes yalnız kendi notlarını görür. **Canlı adres:** https://mac-notlari-XXXX.vercel.app   ## Neler yapabilirsin - GitHub ya da Discord hesabınla giriş yaparsın; şifre yok.- Not eklersin, düzenlersin, silersin.- Notları galibiyete ya da kayba göre süzersin.- Şampiyon başına maç sayını ve galibiyet oranını görürsün. ## Kullanılanlar Next.js 16 (App Router, Server Actions), TypeScript, Tailwind CSS 4, PostgreSQL (Neon), Better Auth, zod. Yayın: Vercel. ## Yerelde çalıştırmak 1. `npm install`2. Proje kökünde `.env.local` aç, aşağıdaki değişkenleri doldur.3. `schema.sql`'i Neon'un SQL Editor'ünde çalıştır.4. `npm run dev`, sonra http://localhost:3000 Geliştirme sunucusunda giriş sayfasında "Deneme girişi" kutusu çıkar: GitHub ya da Discord uygulaması açmadan iki deneme kullanıcısıyla (Deniz, Mert) girebilirsin. Yayında bu kutu ve e-posta girişi kapalıdır. ## Ortam değişkenleri | Ad | Ne ||---|---|| `DATABASE_URL` | Neon bağlantı dizesi || `BETTER_AUTH_SECRET` | Oturum çerezlerini imzalayan sır; `npx auth@latest secret` üretir || `BETTER_AUTH_URL` | Uygulamanın adresi, ör. `http://localhost:3000` || `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | GitHub OAuth uygulaması || `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` | Discord uygulaması | ## Güvenlik - Kullanıcının kimliği her zaman oturumdan okunur, formdan alınmaz.- Notlara dokunan her sorguda `kullanici_id` şartı var; başkasının notunun numarasını bilmek işe yaramaz.- Formdan gelen her veri sunucuda zod ile doğrulanır. ## Sınırlar - Giriş yalnız ana adreste çalışır; Vercel'in önizleme adreslerinde çalışmaz.- Şampiyon adı serbest metin; "Ahri" ile "ahri" istatistikte iki ayrı satır olur.- Görseller göreli yolla:
docs/notlar.png. GitHub README'yi gösterirken bu yolu depodaki dosyaya çevirir. - "Neler yapabilirsin" birinci sırada; teknik ayrıntı sonra. Okuyan önce ne işe yaradığını anlamalı.
- "Güvenlik" ve "Sınırlar": çoğu README'de olmayan iki bölüm. Bir junior başvurusunda "yetkiyi nasıl düşündüğünü" ve "neyi bilerek yapmadığını" göstermek, bir özellik daha eklemekten çok konuşturur. Ders 54'te portföyü ve README şablonunu ayrıntılı ele alacaksın.
- Ortam değişkenlerinin değerleri yok, yalnız adları. README'ye sır yazılmaz.
- "Süzersin" maddesi Görev 2'nin özelliği. README'yi görevlerden önce yazıyorsan o satırı Görev 2'yi bitirince ekle; README'de olmayan bir özelliği anlatma.
Son olarak GitHub'da deponun sayfasında sağdaki About bölümünün yanındaki dişli simgesine bas, Website kutusuna canlı adresi yaz ve kaydet. Deponu açan herkes adresi About bölümünde tıklanabilir bir bağlantı olarak görür.
Görevler
Görevler aynı projede. Her birini bitirince yerelde dene, sonra hepsini dersin sonundaki kayıtla birlikte gönder; Vercel her push'ta yeniden yayınlar. Önce kendin dene, takılırsan ipuçlarını sırayla aç.
Sekmede notun adı
Bir notun sayfası açıkken tarayıcı sekmesinde yalnız Maç Notları yazıyor. Sekmede Ahri · 20 Eylül 2026 · Maç Notları yazsın. Sınır: Deniz Mert'in notunun adresini açarsa sekmede Bulunamadı · Maç Notları yazmalı; şampiyonun adı sekmeden bile sızmamalı. /notlar/abc de Bulunamadı demeli.
İpucu 1
Ders 43'teki generateMetadata. Sayfanın yanına, aynı params'ı alan ikinci bir fonksiyon.
İpucu 2
generateMetadata içinde girisGerekli() yerine oturumdakiKullaniciId() kullan: başlık için yönlendirme yapmaya gerek yok, sayfa zaten yapacak. Notu getirirken de kullanıcıyı vermeyi unutma; şartsız bir sorgu başlığa başkasının şampiyonunu yazar.
Çözümü göster
app/notlar/[id]/page.tsx'in başı:
import type { Metadata } from "next"import Link from "next/link"import { notFound } from "next/navigation"import Dugme from "@/components/Dugme"import { notuGetir } from "@/lib/notlar"import { girisGerekli, oturumdakiKullaniciId } from "@/lib/oturum"import { baglantiDugme } from "@/lib/stil"import { tarihYaz } from "@/lib/tarih"import { notSilEylemi } from "../actions" export async function generateMetadata({ params }: PageProps<"/notlar/[id]">): Promise<Metadata> { const { id } = await params const kullaniciId = await oturumdakiKullaniciId() const not = kullaniciId === null ? undefined : await notuGetir(kullaniciId, id) return { title: not ? `${not.sampiyon} · ${tarihYaz(not.tarih)}` : "Bulunamadı" }}Denedik: Deniz için /notlar/1 → Ahri · 20 Eylül 2026 · Maç Notları, /notlar/2 (Mert'in) → Bulunamadı · Maç Notları, /notlar/abc → Bulunamadı · Maç Notları. · Maç Notları kısmını layout.tsx'teki template ekliyor.
Galibiyetler, kayıplar
Notlar sayfasının başlığının altına üç bağlantı koy: Hepsi, Galibiyetler, Kayıplar. Adresler /notlar, /notlar?sonuc=galibiyet, /notlar?sonuc=kayıp; seçili olan kalın ve altı çizili. Deniz için sayılar 7, 4, 3 olmalı. Sınırlar: ?sonuc=berabere gibi tanımsız bir değer hepsini göstermeli. Mert'in hiç galibiyeti yok: süzgeçli boş liste "Henüz not yok" dememeli; "Bu süzgece uyan not yok" deyip "Hepsini göster" bağlantısı vermeli (ısınmanın 4. sorusu).
İpucu 1
Adresin ?'ten sonrası Ders 45'teki gibi searchParams ile gelir. notlariGetir'e isteğe bağlı ikinci bir parametre ekle: sonuc?: string.
İpucu 2
notlariGetir'in içinde değer "galibiyet" ya da "kayıp" ise AND sonuc = $2 eklenmiş ikinci bir sorgu çalıştır; değilse eski sorgu. Kullanıcı şartı iki sorguda da kalmalı. Sayfada üç durum var: süzgeçli boş, süzgeçsiz boş, dolu.
Çözümü göster
lib/notlar.ts'teki notlariGetir:
export async function notlariGetir(kullaniciId: number, sonuc?: string): Promise<Not[]> { if (sonuc === "galibiyet" || sonuc === "kayıp") { const { rows } = await pool.query<Not>( `SELECT id, sampiyon, sonuc, tarih::text AS tarih, metin FROM notlar WHERE kullanici_id = $1 AND sonuc = $2 ORDER BY tarih DESC, id DESC`, [kullaniciId, sonuc] ) return rows } const { rows } = await pool.query<Not>( `SELECT id, sampiyon, sonuc, tarih::text AS tarih, metin FROM notlar WHERE kullanici_id = $1 ORDER BY tarih DESC, id DESC`, [kullaniciId] ) return rows}app/notlar/page.tsx:
import type { Metadata } from "next"import Link from "next/link"import NotKarti from "@/components/NotKarti"import { notlariGetir } from "@/lib/notlar"import { girisGerekli } from "@/lib/oturum"import { baglantiDugme, odak } from "@/lib/stil" export const metadata: Metadata = { title: "Notlarım",} const suzgecler = [ { ad: "Hepsi", adres: "/notlar", deger: undefined }, { ad: "Galibiyetler", adres: "/notlar?sonuc=galibiyet", deger: "galibiyet" }, { ad: "Kayıplar", adres: "/notlar?sonuc=kayıp", deger: "kayıp" },] export default async function NotlarSayfasi({ searchParams }: PageProps<"/notlar">) { const kullaniciId = await girisGerekli() const { sonuc } = await searchParams const secili = sonuc === "galibiyet" || sonuc === "kayıp" ? sonuc : undefined const notlar = await notlariGetir(kullaniciId, secili) return ( <> <div className="flex flex-wrap items-center justify-between gap-4"> <h1 className="text-3xl font-bold">Notlarım</h1> <Link href="/notlar/yeni" className={baglantiDugme}>Yeni not</Link> </div> <nav aria-label="Sonuca göre süz" className="mt-4 flex gap-4 text-sm"> {suzgecler.map((s) => ( <Link key={s.ad} href={s.adres} aria-current={s.deger === secili ? "page" : undefined} className={`${s.deger === secili ? "font-bold underline" : "hover:underline"} ${odak}`}> {s.ad} </Link> ))} </nav> {notlar.length === 0 && secili && ( <p className="mt-6 text-zinc-600 dark:text-zinc-400"> Bu süzgece uyan not yok. <Link href="/notlar" className="underline">Hepsini göster</Link> </p> )} {notlar.length === 0 && !secili && ( <div className="mt-6 rounded-xl border-2 border-dashed border-zinc-300 px-6 py-12 text-center dark:border-zinc-700"> <h2 className="text-lg font-bold">Henüz not yok</h2> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Bir maç bitince aklında kalanı yaz; burada kart kart birikir.</p> <Link href="/notlar/yeni" className={`mt-6 ${baglantiDugme}`}>İlk notunu yaz</Link> </div> )} {notlar.length > 0 && ( <ul className="mt-6 grid gap-4 sm:grid-cols-2"> {notlar.map((not) => ( <li key={not.id}> <NotKarti not={not} /> </li> ))} </ul> )} </> )}secili yalnız iki geçerli değerden biri ya da undefined; ?sonuc=galibiyet&sonuc=kayıp gibi dizi gelen bir değer de undefined olur. aria-current="page" ekran okuyucuya "seçili olan bu" der (Ders 45). Denedik: Deniz için (yok) 7 kart, galibiyet 4, kayıp 3, berabere 7, iki değerli adres 7; Mert için galibiyet "Bu süzgece uyan not yok".
Gelecekteki maç olmaz
Tarih kutusuna yarının tarihini yazan biri "Maç tarihi bugünden sonra olamaz." uyarısını almalı; bugünün tarihi ise kabul edilmeli. Denetim sunucuda, zod şemasında olsun. Sınır: Türkiye'de gece 00.30'da o günün tarihi kabul edilmeli.
İpucu 1
zod'da bir kurala kendi denetimini eklemek için .refine(fonksiyon, mesaj) var: fonksiyon true döndürürse değer geçer, false döndürürse mesaj alan hatası olur. YYYY-AA-GG biçimindeki iki tarih metni <= ile doğru karşılaştırılır: yıl önce, ay sonra, gün en son yazıldığı için metin sırası ile takvim sırası aynı.
İpucu 2
"Bugün"ü new Date().toISOString().slice(0, 10) ile bulmak akla ilk gelen yol, ama UTC'nin bugününü verir. Türkiye'de gece yarısı ile 03.00 arasında UTC hâlâ dündür. Aşağıdaki programı C:\kod\ogren\ders46.js olarak çalıştır ve farkı gör. "en-CA" (Kanada İngilizcesi) tarihleri tam YYYY-AA-GG biçiminde yazan bir dil ayarı:
const gece = new Date("2026-10-11T21:30:00Z")console.log(gece.toISOString().slice(0, 10))console.log(new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Istanbul" }).format(gece))console.log("2026-10-12" <= "2026-10-12", "2026-10-13" <= "2026-10-12", "2026-09-30" <= "2026-10-12")2026-10-112026-10-12true false trueUTC'de 21.30 olan an, İstanbul'da 12 Ekim 00.30. İlk yol 11 Ekim diyor; o saatte bugünün maçını yazan biri "gelecek" uyarısı alırdı.
Çözümü göster
lib/notSemasi.ts'in başı:
import { z } from "zod" function bugun() { return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Istanbul" }).format(new Date())} export const NotSemasi = z.object({ sampiyon: z.string().trim().min(2, "Şampiyon adı en az 2 harf olmalı.").max(20, "Şampiyon adı en fazla 20 harf olabilir."), sonuc: z.enum(["galibiyet", "kayıp"], "Sonuç listeden seçilmeli."), tarih: z.iso.date("Tarih geçerli bir gün olmalı.").refine((tarih) => tarih <= bugun(), "Maç tarihi bugünden sonra olamaz."), metin: z.string().trim().min(10, "Not en az 10 harf olmalı.").max(2000, "Not en fazla 2000 harf olabilir."),})bugun() her denetimde yeniden çağrılır; bir sabite koysaydın sunucunun açıldığı günde takılı kalırdı. Saat dilimini açıkça yazdık, çünkü Vercel'in sunucuları UTC'de çalışır. Denedik (12 Ekim 2026'da): 2026-10-12 kaydedildi, 2026-10-13 tarih kutusunun altında "Maç tarihi bugünden sonra olamaz." gösterdi. Tarayıcıda da önden uyarmak istersen kutuya max niteliği verebilirsin, ama sunucudaki denetim kalmalı.
Giriş hatası kendi sayfanda
Bir giriş hatası olunca kullanıcı Better Auth'un İngilizce hata sayfasına düşüyor. Onun yerine /giris'e dönsün ve kırmızı bir kutuda Türkçe bir açıklama görsün. Kullanıcı GitHub'da Cancel'a basarsa (GitHub error=access_denied gönderir) "Giriş iznini vermedin. İstersen yeniden dene." yazsın; state_mismatch için "Giriş yarıda kaldı ya da süresi doldu. Baştan dene."; bilinmeyen bir kod için kodu da içeren genel bir cümle.
İpucu 1
Better Auth'un ayarlarında onAPIError: { errorURL: "/giris" } var. Denedik: bir hata olunca kullanıcıyı /giris?error=access_denied gibi bir adrese gönderiyor.
İpucu 2
Giriş sayfası searchParams'tan error'u okusun. Kodları mesajlara çeviren bir obje (Record<string, string>) ve ?? ile bir varsayılan cümle yeter. Kutuya role="alert" ver. Denemek için GitHub'a gitmen gerekmiyor: çıkış yapmışken http://localhost:3000/api/auth/callback/github?code=abc&state=uydurma adresini aç (giriş yapmışsan /giris seni /notlar'a gönderir ve mesajı göremezsin); state tutmadığı için state_mismatch dönüşü oluşur.
Çözümü göster
lib/auth.ts'te plugins'in hemen üstüne:
onAPIError: { errorURL: "/giris", },app/giris/page.tsx'in değişen kısmı:
const hataMesajlari: Record<string, string> = { access_denied: "Giriş iznini vermedin. İstersen yeniden dene.", state_mismatch: "Giriş yarıda kaldı ya da süresi doldu. Baştan dene.",} export default async function GirisSayfasi({ searchParams }: PageProps<"/giris">) { if ((await oturumdakiKullaniciId()) !== null) { redirect("/notlar") } const { error } = await searchParams const hata = typeof error === "string" ? (hataMesajlari[error] ?? `Giriş yapılamadı (kod: ${error}). Yeniden dene.`) : null return ( <section className="mx-auto max-w-sm"> <h1 className="text-2xl font-bold">Giriş yap</h1> <p className="mt-2 text-zinc-600 dark:text-zinc-400">Şifre yok: GitHub ya da Discord hesabınla gir.</p> {hata && ( <p role="alert" className="mt-4 rounded-lg border border-red-300 bg-red-50 p-3 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200"> {hata} </p> )} <GirisDugmeleri />Üç dönüşü de denedik: access_denied ve redirect_uri_mismatch için geçerli bir state ile GitHub'ın dönüşünü taklit ettik, state_mismatch için uydurma bir state kullandık.

redirect_uri_mismatch için Giriş yapılamadı (kod: redirect_uri_mismatch). Yeniden dene. çıktı. Mesaj adresten geliyor, yani biri adrese istediğini yazabilir; React metni HTML olarak değil düz metin olarak çizdiği için bu bir XSS açığı değil, en fazla tuhaf bir cümle.
Notlarını JSON olarak ver
GET /api/notlar adresi giriş yapmış kullanıcının notlarını JSON olarak versin; giriş yoksa 401 ve {"hata":"Önce giriş yap"}. Sınırlar: çerezsiz istek 401; uydurma bir çerezle istek de 401; Deniz'in çereziyle yalnız Deniz'in notları. Bir mobil uygulama ya da bir Discord botu notlarını okumak isteseydi kapısı bu olurdu.
İpucu 1
Ders 44'teki Route Handler: app/api/notlar/route.ts ve içinde export async function GET(). oturumdakiKullaniciId() Route Handler'da da çalışır; headers() orada da isteğin başlıklarını verir.
İpucu 2
Denemek için çerezin değerine ihtiyacın var: tarayıcıda F12 → Application → Cookies → http://localhost:3000 → better-auth.session_token satırının Value'su. curl.exe -i -H "Cookie: better-auth.session_token=DEGER" http://localhost:3000/api/notlar. Bu değer bir oturum anahtarı; kimseyle paylaşma, işin bitince çıkış yap.
Çözümü göster
import { notlariGetir } from "@/lib/notlar"import { oturumdakiKullaniciId } from "@/lib/oturum" export async function GET() { const kullaniciId = await oturumdakiKullaniciId() if (kullaniciId === null) { return Response.json({ hata: "Önce giriş yap" }, { status: 401 }) } const notlar = await notlariGetir(kullaniciId) return Response.json(notlar)}PS C:\kod\mac-notlari> curl.exe -i http://localhost:3000/api/notlarHTTP/1.1 401 Unauthorizedcontent-type: application/json {"hata":"Önce giriş yap"}PS C:\kod\mac-notlari> curl.exe -i -H "Cookie: better-auth.session_token=uydurma.deger" http://localhost:3000/api/notlarHTTP/1.1 401 Unauthorized...PS C:\kod\mac-notlari> curl.exe -i -H "Cookie: better-auth.session_token=VuTo4MyC2Zie..." http://localhost:3000/api/notlarHTTP/1.1 200 OKcontent-type: application/json [{"id":8,"sampiyon":"Ahri","sonuc":"galibiyet","tarih":"2026-10-08","metin":"Rakip Talon'du. E'yi hep elimde tuttum ve Talon her geldiğinde büyüledim."},{"id":7,"sampiyon":"Lux","sonuc":"galibiyet","tarih":"2026-10-05","metin":"Kalkanı nişancının önüne attım, iki ölümü engelledim. Ejderhadan önce nehre totem koydum."},...](Başlıklardan vary, Date ve bağlantı satırlarını, çerezin ve cevabın devamını kısalttık.) 401, 403 değil: kullanıcı kim olduğunu hiç kanıtlamadı (Ders 31). Uydurma çerezin imzası BETTER_AUTH_SECRET ile tutmadığı için Better Auth onu hiç tanımadı. tarih JSON'da da "2026-10-08": ::text sayesinde.
Hesabımı sil
Menüdeki kullanıcı adı /hesap sayfasına götürsün. Orada bir Hesabı sil formu olsun: "Eminim, hesabımı sil." onay kutusu ve kırmızı Hesabımı sil düğmesi. Basınca kullanıcının oturumu kapansın, kendisi, bütün notları, oturumları ve sağlayıcı hesapları silinsin ve ana sayfaya dönsün. Sınırlar: onay kutusu işaretsizse (biri required'ı geliştirici araçlarıyla silse bile) hiçbir şey silinmemeli; Mert hesabını silince Deniz'in notlarına dokunulmamalı.
İpucu 1
Tek bir DELETE FROM kullanicilar WHERE id = $1 yeter. Neden yettiğini şemadaki ON DELETE CASCADE'lerde ara: notlar, oturumlar ve hesaplar üçü de kullanıcıya böyle bağlı.
İpucu 2
Kullanıcıyı silmek oturumlar'daki satırı da siler (CASCADE), ama tarayıcıdaki çerezi silmez; onu auth.api.signOut({ headers: await headers() }) temizler. Bu yüzden eylemde ikisi de var: önce çıkış, sonra silme. Silinecek kimlik yine girisGerekli()'den gelir; formda kimlik yok. Onay kutusunu sunucuda da denetle: formData.get("onay") !== "evet" ise redirect("/hesap").
Çözümü göster
lib/hesap.ts:
import "server-only"import { pool } from "@/lib/db" export async function kullaniciyiSil(kullaniciId: number) { await pool.query("DELETE FROM kullanicilar WHERE id = $1", [kullaniciId])}app/hesap/actions.ts:
"use server" import { headers } from "next/headers"import { redirect } from "next/navigation"import { auth } from "@/lib/auth"import { kullaniciyiSil } from "@/lib/hesap"import { girisGerekli } from "@/lib/oturum" export async function hesabiSil(formData: FormData) { const kullaniciId = await girisGerekli() if (formData.get("onay") !== "evet") { redirect("/hesap") } await auth.api.signOut({ headers: await headers() }) await kullaniciyiSil(kullaniciId) redirect("/")}app/hesap/page.tsx:
import type { Metadata } from "next"import Dugme from "@/components/Dugme"import { girisGerekli } from "@/lib/oturum"import { hesabiSil } from "./actions" export const metadata: Metadata = { title: "Hesabım",} export default async function HesapSayfasi() { await girisGerekli() return ( <section className="max-w-lg"> <h1 className="text-3xl font-bold">Hesabım</h1> <form action={hesabiSil} className="mt-6 rounded-xl border border-red-300 p-4 dark:border-red-900"> <h2 className="text-lg font-bold">Hesabı sil</h2> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Bütün notların ve oturumların da silinir. Bu geri alınamaz.</p> <label className="mt-4 flex items-center gap-2"> <input type="checkbox" name="onay" value="evet" required /> Eminim, hesabımı sil. </label> <div className="mt-4"> <Dugme type="submit" tur="tehlike">Hesabımı sil</Dugme> </div> </form> </section> )}app/layout.tsx'teki ad span'ı bağlantı olur:
<Link href="/hesap" className={`ml-auto text-sm text-zinc-600 hover:underline dark:text-zinc-400 ${odak}`}>{oturum.user.name}</Link>Mert'le denedik. Önce required'ı geliştirici araçlarıyla kaldırıp kutuyu işaretlemeden gönderdik: sayfa /hesap'ta kaldı, hiçbir şey silinmedi. Sonra kutuyu işaretleyip gönderdik; öncesi ve sonrası:
| kullanıcı | Mert'in notu | bütün notlar | oturum | hesap | |
|---|---|---|---|---|---|
| Önce | 2 | 1 | 8 | 2 | 2 |
| Sonra | 1 | 0 | 7 | 1 | 1 |
Ana sayfaya döndü, menüde Çıkış yok, tarayıcıda Better Auth çerezi kalmadı. Deniz'in yedi notu yerinde. Mert deneme girişiyle yeniden girerse yeni bir hesap açılır ve kimliği 3 olur: kimlik sayacı silinen numaraları yeniden kullanmaz (Ders 38).
Kendini sına
Soru 1
Bu fonksiyonda açık nerede? Deniz ne yapabilir?
export async function notuSil(kullaniciId: number, id: string) { await pool.query("DELETE FROM notlar WHERE id = $1", [id])}Cevabı göster
Sorguda kullanici_id şartı yok; kullaniciId parametresi alınmış ama kullanılmamış. Deniz kendi notunun sayfasındaki silme formunda gizli id kutusunu başka bir sayıya çevirirse o numaradaki notu, kimin olursa olsun, siler. Numaralar sırayla arttığı için birkaç denemeyle herkesin notunu silebilir. Doğrusu WHERE id = $1 AND kullanici_id = $2 ve dizide [id, kullaniciId]. TypeScript de ESLint de bu dosyada bir sorun görmez.
Soru 2
Sayfaların hepsi girisGerekli() ile başlıyor. Server Action'ların da başında olması neden şart?
Cevabı göster
Server Action sayfadan ayrı bir kapı: tarayıcı onu bir POST isteğiyle çağırır ve bu istek sayfa açılmadan, elle de gönderilebilir (Ders 44). Sayfanın denetimi yalnız sayfa çizilirken çalışır; eylem çağrılırken çalışmaz. Eylem kimin çağırdığını kendisi sormazsa giriş yapmamış biri de onu çağırabilir; eylem de kullanıcının kimliğini ancak dışarıdan gelen bir değerden (formdan, adresten) alabilir. Bu, Deneme 3'teki açığın ta kendisi. Her kapı kendi denetimini yapar.
Soru 3
Bir arkadaşın "kullanıcının kimliği zaten sayfada var, notu kaydederken forma kullaniciId adlı gizli bir kutu koyalım" diyor. Ne cevap verirsin?
Cevabı göster
Gizli kutu tarayıcıda, yani kullanıcının elinde; geliştirici araçlarıyla bir saniyede değişir. Kutuyu 2 yapan Deniz, Mert adına not yazar. Kullanıcının kim olduğu yalnız sunucunun doğruladığı yerden, oturumdan okunur: girisGerekli(). Form ve adres "hangi not?" sorusunu taşıyabilir, "kim?" sorusunu asla.
Soru 4
BETTER_AUTH_SECRET yanlışlıkla herkese açık bir depoya gitti. Ne olabilir, ne yaparsın?
Cevabı göster
Sırrı bilen biri Better Auth'un imzalarını kendisi atabilir: imzası tutan oturum çerezleri, giriş akışının state çerezi. Bizim ayarımızda oturum ayrıca oturumlar tablosunda arandığı için uydurma bir çerezle hemen birinin hesabına giremez; ama korumanın bir katmanı gitmiştir ve oturumu çerezde tutan bir ayara geçtiğin gün kapı tamamen açılır. Yapacağın şey anahtarı yenilemek: npx auth@latest secret ile yeni bir değer üret, Vercel'de değiştir ve yeniden yayınla. Eski sırla imzalanmış bütün çerezler geçersiz olur, herkes yeniden giriş yapar; bu bir bedel, ama doğru olan. Sonra sırrın Git geçmişinde kaldığını unutma: dosyayı silmek yetmez, değerin kendisi artık işe yaramaz olmalı.
Soru 5
Yerelde GitHub ile giriş çalışıyor; yayında GitHub'da izin verdikten sonra kullanıcı localhost:3000'e gönderiliyor ya da redirect_uri_mismatch görüyor. Neyi unuttun?
Cevabı göster
İki şeyden biri, çoğu zaman ikisi. Vercel'de BETTER_AUTH_URL tanımlı değil ya da yanlış: Better Auth geri dönüş adresini bu değerden kurar. GitHub'daki OAuth uygulamasına canlı geri dönüş adresi (https://…vercel.app/api/auth/callback/github) eklenmemiş: GitHub istekteki adresi kayıtlılarla karşılaştırır, tutmazsa kayıtlı adrese hatayla döner. İkisini de düzelttikten sonra Vercel'de Redeploy gerekir, çünkü ortam değişkenleri yalnız yeni yayınlara uygulanır.
Soru 6
Mert'in üç notu var: Yasuo galibiyet, Yasuo kayıp, Yone kayıp. İstatistik sorgusu Mert için hangi satırları döndürür?
SELECT sampiyon, count(*)::int AS mac, count(*) FILTER (WHERE sonuc = 'galibiyet')::int AS galibiyetFROM notlarWHERE kullanici_id = 2GROUP BY sampiyonORDER BY mac DESC, sampiyon;Cevabı göster
İki satır: önce Yasuo | 2 | 1, sonra Yone | 1 | 0. GROUP BY notları şampiyona göre gruplar; count(*) gruptaki bütün satırları, FILTER'lı count(*) yalnız galibiyetleri sayar. Yone'nin galibiyeti yok ama satırı yine gelir, sayısı 0 olur. Sıralama önce maç sayısına göre azalan: Yasuo 2 maçla önde.
Soru 7
notuGetir'in sorgusunda tarih::text AS tarih yerine düz tarih yazsan bilgisayarında ne görürsün? Vercel'de?
Cevabı göster
Bilgisayarında kartlarda bir gün önceki tarih: 20 Eylül'ün notunda 19 Eylül 2026. pg date sütununu sunucunun saat diliminde gece yarısına denk gelen bir Date objesine çevirir; Türkiye UTC+3 olduğu için bu an UTC'de bir önceki günün 21.00'i, tarihYaz da UTC'ye göre yazıyor. Vercel'in sunucuları UTC'de çalıştığı için yayında doğru görünür. "Bende yanlış, yayında doğru" türünden hatalar en zor bulunanlardır; ::text ikisinde de aynı metni verir.
Kayıt noktası
Görevleri ve README'yi kaydet ve gönder. Vercel push'u görünce yeniden yayınlar; birkaç dakika sonra canlı adreste süzgeçleri ve hesap sayfasını görürsün.
Blok 6 bitti: elinde girişli, veritabanlı, yetkisi düşünülmüş ve yayında bir uygulama var. Bir sonraki derste yedinci bloğa, yapay zekâya geçiyorsun: büyük dil modellerinin nasıl çalıştığını sezgiyle anlayıp TypeScript'ten ilk API isteğini göndereceksin.