Bookstore SPA'yı Uçtan Uca Bitirmek: Feature Klasörleri, Authentication, Environment'lar ve Deploy
Bir React SPA'yı production'a hazırlamak, feature sınırlarını lint kuralıyla korumak, VITE_ değişkenlerinin build sırasında bundle'a yazıldığını bilip secret'ı tarayıcıdan uzak tutmak ve oturumu token yerine HttpOnly cookie taşıyan bir BFF'e vermek demek. Bookstore'a ASP.NET Core ve YARP ile küçük bir BFF ekliyor, sign in akışını, SPA fallback'i, cache header'larını, nginx yapılandırmasını ve bundle analizini gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler React 19.3, React Router 8.5, TanStack Query 5.107, Zustand 5.1, Vite 8.3, TypeScript 7.0, Oxlint 1.93, Vitest 5.1, rollup-plugin-visualizer 7.1, Node.js 24, .NET 10 (ASP.NET Core 10), Yarp.ReverseProxy 2.3 ve nginx 1.30 ile denenmiştir; tarayıcı adımları Playwright 1.65 ile headless Chromium'da çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React SPA'sını (single-page application; tek HTML sayfasında çalışan uygulama) bu yazıda production'a hazırlıyoruz. Üç soru. .env.production.local dosyasına VITE_PAYMENT_KEY ve PAYMENT_SECRET diye iki değer yazdın; build sonrası hangisi tarayıcıya giden JavaScript'te görünür? Kullanıcı sign in olduktan sonra sayfadaki JavaScript oturum cookie'sini okuyabilir mi? Kullanıcı /books/9780441172719 adresini yer imlerinden açtığında sunucu ne döndürür? Cevaplar yazının içinde, gerçek çıktılarla.
Feature Klasörleri ve Public API
İkinci yazıdan beri kod features/catalog, features/cart gibi klasörlerde duruyor, ama her dosya her dosyayı import edebiliyordu: checkout sayfası sepetin cartApi.ts'ine, router auth'un requireUser.ts'ine doğrudan uzanıyordu. Bir feature'ın iç yapısını değiştirmek, kimin neyi kullandığını aramak demekti. Her feature'a bir index.ts ekledik; dışarıya açtığı şey yalnız orada:
// Sepet feature'ının public API'si
export { cartApi, cartKey, cartQuery } from './cartApi';
export { CartPanel } from './CartPanel';
export { CartSummary } from './CartSummary';
export type { Cart, CartLine, Order, OrderLine } from './types';
export { useCart } from './useCart';
Feature içindeki dosyalar birbirini ./useCart gibi göreli yolla, diğer feature'lar ve app/ ise @/features/cart ile import ediyor. Kuralı insanların hatırlamasına bırakmamak için Oxlint'e ekledik:
"no-restricted-imports": ["error", {
"patterns": [{
"regex": "^@/features/[^/]+/",
"message": "Import another feature through its public API ('@/features/<name>'); inside a feature use relative imports."
}]
}]
$ npx oxlint
src/features/catalog/BookPage.tsx:4:1: error eslint(no-restricted-imports): '@/features/cart/useCart' import is restricted from being used by a pattern. help: Import another feature through its public API ('@/features/<name>'); inside a feature use relative imports.
Angular'da aynı ayrımı UI Mimari Pattern'leri serisindeki "Büyük Angular Uygulamasında Sınırlar: Feature Folder, Facade ve Katmanlar" yazısında anlattık; React tarafında framework bir şey dayatmadığı için sınırı lint kuralı çiziyor. Lazy route'lar da artık feature'ın kendisini yüklüyor:
lazy: async () => {
const { CheckoutPage, checkoutLoader } = await import('@/features/checkout');
return { Component: CheckoutPage, loader: checkoutLoader };
},
Barrel dosyalarının bir bedeli var: lazy yüklenen bir feature'ın index.ts'inden tek bir şeyi bile statik import edersen, bundler o feature'ı ana chunk'a almak zorunda kalır. Checkout ve admin'i yalnız router'ın import()'u ile açıyoruz.
Environment Değişkenleri: import.meta.env Build Sırasında Yazılır
Vite .env, .env.local, .env.[mode] ve .env.[mode].local dosyalarını okuyor; mode'a özel dosya genel olanı eziyor, .local dosyaları Git'e girmiyor. Kodun gördüğü yalnız VITE_ ile başlayan değişkenler:
# Her mode için public, build zamanı ayarları. Tarayıcıya yalnız VITE_ değişkenleri gider, hepsi de gider.
VITE_SUPPORT_EMAIL=support@example.com
/// <reference types="vite/client" />
interface ViteTypeOptions {
// import.meta.env.VITE_TYPO undefined yerine derleme hatası olur
strictImportMetaEnv: unknown;
}
interface ImportMetaEnv {
readonly VITE_SUPPORT_EMAIL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
strictImportMetaEnv olmadan import.meta.env her anahtarı kabul ediyor ve yazım hatası çalışma sırasında undefined olarak çıkıyor. Açınca hata derleme sırasında geliyor:
$ npx tsc -b
src/typo.ts(1,40): error TS2551: Property 'VITE_SUPORT_EMAIL' does not exist on type 'ImportMetaEnv'. Did you mean 'VITE_SUPPORT_EMAIL'?
Önce Tahmin Et'teki ilk sorunun cevabı için iki değeri gerçekten yazdık ve geçici bir dosyayla ikisini de kullandık:
// Deney: iki değerden hangisi bundle'a girer?
console.log('payment key', import.meta.env.VITE_PAYMENT_KEY);
console.log('payment secret', import.meta.env.PAYMENT_SECRET);
$ cat .env.production.local
VITE_PAYMENT_KEY=example-key-not-real
PAYMENT_SECRET=example-secret-not-real
$ npx vite build
$ grep -o 'console.log(`payment [^)]*)' dist/assets/index-*.js
console.log(`payment key`,`example-key-not-real`)
console.log(`payment secret`,void 0)
VITE_ önekli değer bundle'a düz metin olarak yazıldı; öneksiz değer void 0 oldu. Vite bu değerleri runtime'da okumuyor, build sırasında kodun içine yerleştiriyor. Bu iki sonuç doğuruyor: tarayıcıya giden bir değişkende secret olamaz (DevTools'u açan herkes görür) ve aynı build'i test ile production arasında taşıyamazsın, her ortam için yeniden build gerekir. Angular'ın environment.ts + fileReplacements yaklaşımı da aynı şekilde build zamanında çalışıyor (Angular serisindeki "Angular Build ve Deploy: ng build, Environment Dosyaları, Budgets ve SPA Routing"). Ortama göre değişen ayarlar için ya her ortama ayrı build ya da sunucunun runtime'da verdiği bir config endpoint'i gerekiyor; secret'lar ise hiç tarayıcıya inmemeli. Bookstore'da bunun yeri aşağıdaki BFF (Backend for Frontend; frontend'e özel sunucu katmanı).
Authentication: Token Yerine Cookie ve BFF
Yedinci yazıdan beri sign in demo bir store'du: kullanıcı bellekte duruyordu, sunucu kimseyi tanımıyordu. Gerçek bir SPA'da en yaygın hata access token'ı localStorage'a koymak: sayfada çalışan herhangi bir script, örneğin bir XSS (Cross-Site Scripting; sayfaya dışarıdan script enjekte edilmesi) açığı ya da ele geçirilmiş bir npm paketi, token'ı okuyup başka yere gönderebilir. Frontend Güvenliği serisindeki "Token Nerede Saklanmalı? localStorage, Memory, Cookie, Service Worker ve BFF'in Tehdit Modeli" yazısı seçenekleri karşılaştırıyor; sonuç BFF: token tarayıcıya hiç gelmiyor, tarayıcı yalnız HttpOnly bir oturum cookie'si taşıyor.

Snapshot'a bff/Bookstore.Bff adında küçük bir ASP.NET Core 10 projesi ekledik. Önce cookie:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// __Host- öneki: tarayıcı cookie'yi yalnız Secure, Path=/ ve Domain'siz kabul eder
options.Cookie.Name = "__Host-bookstore";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
// Oturumu olmayan bir fetch, login sayfasına yönlendirme yerine 401 alır
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
HttpOnly cookie'yi JavaScript'ten gizliyor. __Host- öneki tarayıcıya cookie'yi yalnız Secure, Path=/ ve Domain'siz kabul etmesini söylüyor; bir alt alan adı onu ezemiyor. Cookie her istekte otomatik gittiği için CSRF'e (Cross-Site Request Forgery; başka bir sitenin kullanıcının oturumuyla istek göndermesi) karşı ayrıca korunmak gerekiyor:
// Anti-CSRF: /api ve /bff çağrıları özel bir header göndermeli; başka bir site bunu CORS preflight olmadan gönderemez
app.Use(async (context, next) =>
{
bool isCall = context.Request.Path.StartsWithSegments("/api")
|| (context.Request.Path.StartsWithSegments("/bff") && !context.Request.Path.StartsWithSegments("/bff/login"));
if (isCall && context.Request.Headers["X-CSRF"] != "1")
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Missing X-CSRF header");
return;
}
await next();
});
Başka bir sitedeki bir form ya da <img> özel header gönderemez; fetch ile göndermeye kalkarsa tarayıcı önce bir CORS (Cross-Origin Resource Sharing; başka origin'e giden istekler için tarayıcının izin kuralları) preflight isteği yapar ve BFF buna izin vermez. SameSite=Lax da cookie'nin başka sitelerden gelen POST'larla gitmesini engelliyor; iki önlem birbirini tamamlıyor. /api istekleri YARP (Yet Another Reverse Proxy; Microsoft'un .NET için reverse proxy kütüphanesi) ile Bookstore.Api'ye gidiyor; siparişler oturum istiyor:
"ReverseProxy": {
"Routes": {
"orders": {
"ClusterId": "bookstore-api",
"AuthorizationPolicy": "default",
"Match": { "Path": "/api/orders/{**rest}" }
},
"api": {
"ClusterId": "bookstore-api",
"Match": { "Path": "/api/{**rest}" }
}
},
"Clusters": {
"bookstore-api": {
"Destinations": {
"api": { "Address": "http://localhost:5080/" }
}
}
}
}
Gerçek sign in bir kimlik sağlayıcısına OpenID Connect yönlendirmesi; bu yazıda bulut hesabı ya da Keycloak kurmadık. Geliştirme ortamında /bff/login, kullanıcıyı Ada olarak sign in yapıp geri gönderen bir yer tutucu:
if (app.Environment.IsDevelopment())
{
// Yalnız geliştirmede: kimlik sağlayıcısına yapılan OpenID Connect yönlendirmesinin yerini tutar
app.MapGet("/bff/login", async (string? returnUrl, HttpContext http) =>
{
ClaimsIdentity identity = new(
[new Claim(ClaimTypes.Name, "Ada Lovelace"), new Claim(ClaimTypes.Email, "ada@example.com")],
CookieAuthenticationDefaults.AuthenticationScheme);
await http.SignInAsync(new ClaimsPrincipal(identity));
// Yalnız bu sitenin içindeki yollar: open redirect yok
bool isLocal = returnUrl is not null && returnUrl.StartsWith('/') && !returnUrl.StartsWith("//") && !returnUrl.StartsWith("/\\");
return Results.Redirect(isLocal ? returnUrl! : "/");
});
}
app.MapGet("/bff/user", (ClaimsPrincipal user) => user.Identity?.IsAuthenticated == true
? Results.Ok(new { Name = user.Identity.Name, Email = user.FindFirstValue(ClaimTypes.Email) })
: Results.Unauthorized());
app.MapPost("/bff/logout", async (HttpContext http) =>
{
await http.SignOutAsync();
return Results.NoContent();
});
OIDC (OpenID Connect; OAuth 2.0 üzerine kurulu kimlik katmanı) akışı ve tarayıcı uygulamaları için BFF'i en güvenli seçenek olarak öneren RFC (Request for Comments; internet standartlarının yayımlandığı doküman serisi) bağlamı, access token'ın BFF'te tutulup YARP ile API'ye eklenmesi ve token yenileme Authentication serisindeki "SPA için BFF Pattern: Angular + ASP.NET Core + YARP ile RFC 10017'nin Önerdiği Mimari" yazısında; orada istemci Angular, BFF tarafı aynı. Bizim API kopyamız token beklemediği için burada BFF yalnız oturum kontrolü yapıyor. BFF'i 5100'de çalıştırıp (bizim koşumuzda başka bir portta) curl ile denedik; header'ların ilgili satırları:
$ curl -i $BFF/bff/user
HTTP/1.1 400 Bad Request
Missing X-CSRF header
$ curl -i -H "X-CSRF: 1" $BFF/bff/user
HTTP/1.1 401 Unauthorized
$ curl -i -X POST -H "X-CSRF: 1" -H "Content-Type: application/json" -d "{}" $BFF/api/orders
HTTP/1.1 401 Unauthorized
$ curl -i "$BFF/bff/login?returnUrl=/checkout"
HTTP/1.1 302 Found
Location: /checkout
Set-Cookie: __Host-bookstore=<ticket>; path=/; secure; samesite=lax; httponly
$ curl -b cookies.txt -H "X-CSRF: 1" $BFF/bff/user
{"name":"Ada Lovelace","email":"ada@example.com"}
$ curl -i "$BFF/bff/login?returnUrl=//evil.example.com"
HTTP/1.1 302 Found
Location: /
React Tarafı: Kullanıcıyı BFF'e Sormak
Zustand'daki auth store'u sildik. Kim olduğumuzu artık sunucu biliyor, React yalnız soruyor; bu da server state, yani TanStack Query'nin işi:
import { queryOptions } from '@tanstack/react-query';
import { ApiError, apiFetch } from '@/shared/api';
export type User = { name: string; email: string };
// Kimin sign in olduğunu yalnız BFF bilir: oturum cookie'si HttpOnly, JavaScript onu okuyamaz
export const userQuery = queryOptions({
queryKey: ['user'],
queryFn: async ({ signal }) => {
try {
return await apiFetch<User>('/bff/user', { signal });
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return null;
}
throw error;
}
},
// Yalnız sign in (tam sayfa yükleme) ve sign out'ta (cache'i kendimiz güncelleriz) değişir
staleTime: Infinity,
});
export const authApi = {
signOut: () => apiFetch<void>('/bff/logout', { method: 'POST' }),
};
// Route'a her gezinmede loader/action'dan önce çalışır: CanActivate guard'ın karşılığı.
// Yalnız neyin gösterileceğine karar verir; oturum yoksa /api/orders'ı yine BFF reddeder.
export const requireUser: MiddlewareFunction = async ({ request, context }) => {
const user = await queryClient.ensureQueryData(userQuery);
if (user === null) {
const { pathname } = new URL(request.url);
throw redirect(`/sign-in?redirectTo=${encodeURIComponent(pathname)}`);
}
context.set(userContext, user);
};
Sign in butonu artık bir link: fetch değil tam sayfa gezinme, çünkü gerçek akışta tarayıcı kimlik sağlayıcısının sayfasına gidip geri dönecek. apiFetch de her isteğe X-CSRF header'ını ekliyor:
{/* fetch değil, tam sayfa gezinme: BFF redirect ile cevap verir ve oturum cookie'sini yazar */}
<a className="button" href={`/bff/login?returnUrl=${encodeURIComponent(redirectTo)}`}>Sign in</a>
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
// BFF bu header olmadan /api ve /bff çağrılarını reddeder (anti-CSRF); buradaki her yol kendi origin'imizde
'X-CSRF': '1',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
},
});
Geliştirmede Vite dev server /api ve /bff'i BFF'e proxy'liyor; tarayıcı için her şey tek origin. Çalıştırmak için üç süreç gerekiyor:
# 1. Bookstore.Api (README: http://localhost:5080)
# 2. BFF: http://localhost:5100, /api → http://localhost:5080
cd bff/Bookstore.Bff && dotnet run
# 3. SPA: /api ve /bff → http://localhost:5100
npm run dev
Playwright ile akışı baştan sona yürüttük: sepete bir kitap, /checkout, sign in, sipariş, sign out:
1 /checkout while signed out → /sign-in?redirectTo=%2Fcheckout
2 after "Sign in" → /checkout · name field: Ada Lovelace
3 cookie __Host-bookstore: httpOnly=true secure=true sameSite=Lax
4 document.cookie in the page → ""
5 order placed → Order #1
6 after "Sign out" → / · cookie left: false
Önce Tahmin Et'teki ikinci sorunun cevabı dördüncü satırda: cookie tarayıcıda var ama document.cookie boş. Chromium localhost'u ve 127.0.0.1'i güvenli origin saydığı için Secure cookie yerelde HTTP üzerinden de çalıştı; production'da HTTPS şart. Middleware'in sign in olmamış kullanıcıyı yönlendirmesi yalnız arayüz kararı; asıl kontrol BFF'te, curl çıktısındaki 401 bunu gösteriyor. Testlerde de store sıfırlamak yerine MSW'de (Mock Service Worker; testlerde API'yi taklit eden kütüphane) /bff/user için bir handler kullandık.
Build Çıktısı, SPA Fallback ve Cache Header'ları
npm run build sonucu:
$ npm run build
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-DxuAccDu.css 3.19 kB │ gzip: 1.09 kB
dist/assets/admin-_JfYAWWS.js 40.11 kB │ gzip: 12.88 kB
dist/assets/checkout-CBZ3kQ-j.js 132.97 kB │ gzip: 40.57 kB
dist/assets/index-C75X_fI2.js 377.34 kB │ gzip: 119.32 kB
✓ built in 582ms
Bizim seçimimiz dist/'i BFF'in sunması: SPA, /api ve /bff aynı origin'de kalıyor, CORS ve cookie sorunları çıkmıyor. İki kural var. /assets altındaki dosya adlarında içerik hash'i var; içerik değişince ad da değiştiği için bir yıl cache'lenebilirler. index.html ise her seferinde sunucuya sorulmalı, yoksa yeni deploy'dan sonra kullanıcı eski hash'li dosyalara bakan eski index.html'i görmeye devam eder:
// SPA'nın build çıktısı (npm run build → dist); BFF onu /api ve /bff ile aynı origin'den sunar
PhysicalFileProvider spaFiles = new(Path.GetFullPath(app.Configuration["Spa:Root"]!, app.Environment.ContentRootPath));
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = spaFiles,
OnPrepareResponse = context => context.Context.Response.Headers.CacheControl =
// Vite /assets altındaki her dosya adına içerik hash'i koyar: böyle bir dosya hiç değişmez
context.Context.Request.Path.StartsWithSegments("/assets") ? "public, max-age=31536000, immutable" : "no-cache"
});
// ...
// SPA fallback: /books/9780441172719 bir dosya değil; index.html cevap verir, gerisini React Router yapar
app.MapFallbackToFile("index.html", new StaticFileOptions
{
FileProvider = spaFiles,
OnPrepareResponse = context => context.Context.Response.Headers.CacheControl = "no-cache"
});
$ curl -I $BFF/books/9780441172719
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
$ curl -I $BFF/assets/index-C75X_fI2.js
HTTP/1.1 200 OK
Content-Type: text/javascript
Cache-Control: public, max-age=31536000, immutable
$ curl -I $BFF/assets/missing-abc123.js
HTTP/1.1 404 Not Found
Önce Tahmin Et'teki üçüncü sorunun cevabı: dosya olmayan bir yol için index.html geliyor ve React Router sayfayı açıyor. MapFallbackToFile uzantılı yolları kapsamadığı için eksik bir JavaScript dosyası HTML değil 404 dönüyor; HTML dönseydi tarayıcı onu script olarak çalıştırmaya çalışırdı. SPA'yı nginx gibi bir static host'a koyarsan aynı iki kural orada da gerekiyor:
# Static host'ta (nginx) SPA. BFF'teki iki kuralın aynısı: SPA fallback ve cache header'ları.
server {
listen 80;
root /usr/share/nginx/html;
# Vite /assets altındaki her dosya adına içerik hash'i koyar: bir yıl cache'le
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Varsa gerçek dosya, yoksa index.html: /books/9780441172719'u React Router karşılar
location / {
add_header Cache-Control "no-cache";
try_files $uri /index.html;
}
}
$ curl -I $WEB/books/9780441172719
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
$ curl -I $WEB/assets/index-C75X_fI2.js
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
$ curl -I $WEB/assets/missing-abc123.js
HTTP/1.1 404 Not Found
Bunu nginx:1.30 image'ıyla, dist/'i container'a salt okunur bağlayarak çalıştırdık. BFF'le birlikte kullanırsan nginx'e /api ve /bff'i BFF'e ileten bir location daha eklemek gerekir; onu çalıştırmadık. Azure Static Web Apps'te aynı fallback staticwebapp.config.json'daki navigationFallback ile, IIS'te (Internet Information Services; Windows için Microsoft'un web sunucusu) URL Rewrite kuralıyla yapılıyor; ikisini de bu yazıda denemedik.
Bundle Analizi
Hangi paketin ne kadar yer kapladığını görmek için rollup-plugin-visualizer'ı yalnız analyze mode'unda açtık:
npm install -D rollup-plugin-visualizer@7.1.3
// `vite build --mode analyze`: dist/stats.html her chunk'ı hangi module'lerin doldurduğunu gösterir
mode === 'analyze' && visualizer({ filename: 'dist/stats.html', template: 'treemap', gzipSize: true }),
dist/stats.html bir treemap açıyor. Yazıya koyabilmek için aynı veriyi plugin'in raw-data şablonuyla aldık ve paket bazında topladık:
# raw-data report (template: 'raw-data'), rendered module sizes summed per chunk and package
index-C75X_fI2.js (904.9 kB before minification)
react-dom 536.7 kB
react-router 213.0 kB
@tanstack/query-core 64.8 kB
src (our code) 43.7 kB
react 17.7 kB
scheduler 9.2 kB
admin-_JfYAWWS.js (72.9 kB before minification)
@tanstack/virtual-core 57.0 kB
src (our code) 11.9 kB
@tanstack/react-virtual 4.0 kB
checkout-CBZ3kQ-j.js (271.0 kB before minification)
zod 155.6 kB
react-hook-form 102.4 kB
src (our code) 8.3 kB
@hookform/resolvers 4.7 kB
Ana chunk'ın büyük kısmı react-dom ve React Router; bunlar her React SPA'sının sabit maliyeti. Asıl bilgi checkout chunk'ında: zod, react-hook-form'dan büyük. zod 4'ün daha küçük zod/mini giriş noktası var; checkout'un ilk açılışı ölçülmüş bir sorun olursa ilk bakılacak yer bu. Code splitting ve tree shaking'in ayrıntısı Build Araçları serisindeki "Code Splitting, Tree Shaking ve Bundle Analizi: Bundle'ı Neyin Şişirdiğini Bulmak" yazısında.
Angular'la Son Karşılaştırma
Uygulama bitti: testler ve boş satırlar hariç yaklaşık 1.170 satır TypeScript (katalog 243, sepet 212, checkout 170, admin 160 satır), 9 runtime bağımlılığı ve 92 satırlık bir BFF. Angular'da aynı ekranları yazarken alacağın kararların çoğunu framework önceden vermiş olurdu:
| Konu | Angular | Bu seride React için seçtiğimiz |
|---|---|---|
| Routing | @angular/router | React Router 8 (data mode) |
| Veri çekme ve cache | HttpClient, httpResource | fetch + TanStack Query |
| Form ve validation | Reactive Forms, Signal Forms | react-hook-form + zod |
| Paylaşılan state | Service + signal, dependency injection | Context, Zustand |
| Feature sınırları | Lazy route, standalone component | index.ts + Oxlint kuralı |
| Test | TestBed, Vitest | Vitest, Testing Library, MSW |
Her satır, React'te ekibin bir kez tartışıp yazıya döktüğü bir karar demek. Küçük bir ekip için bu esneklik: ihtiyacın olanı seçiyorsun. Ekip ve kod büyüdükçe kararların belgelenmesi ve lint kurallarıyla korunması gerekiyor; Angular'da bunların çoğu framework'ün varsayılanı. İki ekosistemde de ürün aynı Bookstore; değişen, kararın kimde olduğu.
Üretimde Dikkat
Persist edilen veri bir sözleşme. On birinci yazıda sepet kimliğini bookstore.client anahtarına taşırken eski bookstore.cartId anahtarının okunmadığını ve gerçek uygulamada bunu taşımak gerektiğini söylemiştik. Store oluşmadan önce tek seferlik çalışan bir fonksiyon ekledik; iki testi var (eski anahtar taşınıyor, iki anahtar birden varsa yenisi kalıyor):
const storageKey = 'bookstore.client';
const legacyCartIdKey = 'bookstore.cartId';
// Tek seferlik taşıma: onuncu yazıya kadar sepet kimliğinin kendi anahtarı vardı. Bu olmadan sepeti
// olan kullanıcılar onu kaybederdi (sunucu saklar ama bu tarayıcı kimliğini unuturdu).
export function migrateLegacyCartId(storage: Storage) {
const legacyCartId = storage.getItem(legacyCartIdKey);
if (legacyCartId === null) {
return;
}
if (storage.getItem(storageKey) === null) {
// persist'in yazdığı şekil: { state, version }
storage.setItem(storageKey, JSON.stringify({ state: { cartId: legacyCartId, recentlyViewed: [] }, version: 1 }));
}
storage.removeItem(legacyCartIdKey);
}
// Module yüklenirken bir kez, aşağıdaki store localStorage'ı okumadan önce çalışır
migrateLegacyCartId(localStorage);
Deploy sırasında eski sekmeler açık kalır: eski index.html'i yüklemiş bir kullanıcı lazy bir chunk'ı istediğinde o dosya sunucudan silinmiş olabilir. Bir önceki sürümün /assets dosyalarını bir süre yayında tutmak bunu çözer. BFF ile SPA'yı aynı anda deploy etmek de önemli; X-CSRF gibi bir sözleşme iki tarafta birden değişir.
Sık Düşülen Tuzaklar
Secret'ı VITE_ değişkenine koymak: yukarıdaki çıktı yeterince açık. Bir API key'in tarayıcıda olması gerekiyorsa o key zaten public'tir; değilse BFF'te ya da sunucuda kalmalı.
Route guard'ı güvenlik sanmak: requireUser yalnız sayfayı göstermeyi engelliyor; middleware'i atlayan biri doğrudan /api/orders'a istek atabilir. Yetkiyi sunucu kontrol eder.
index.html'i uzun süre cache'lemek ya da hash'siz dosyalara immutable vermek: deploy sonrası kullanıcılar eski sürümde kalır ya da yeni kodla eski dosyalar karışır.
SPA fallback'i her şeye uygulamak: /api/... ya da eksik bir .js için index.html dönen bir sunucu, 404 yerine 200 ve HTML verir; hata ayıklamak zorlaşır.
Küçük Bir Deneme
★☆☆ npm run build sonrası dist/assets altındaki bir dosyanın içinde support@example.com'u ara. Sonra .env.production oluşturup değeri değiştir ve yeniden build et; hangi dosya adları değişti?
★★☆ BFF'e /api/orders için sipariş sahibini kontrol eden bir kural ekle: sipariş verirken müşteri e-postasını istekten değil, oturumdaki claim'den al. YARP'ta bir request transform yeterli mi, yoksa API'nin de değişmesi mi gerekir?
★★★ Checkout'ta zod yerine zod/mini kullan ve bundle analizini tekrarla. Kaç KB kazandın, kodun okunabilirliğinden ne kaybettin?
Bookstore'un React SPA'sı bitti. Sıradaki yazıda aynı ekranlardan birini, kitap detayını, Next.js ile sunucuda render edeceğiz: Server Components, Server Function'lar, Cache Components ve SPA'nın ne zaman yettiği.