İçeriğe geç
Bölümlere göz at 11

11 — Next.js'ten taşıma

11 v0.6.0 belgeleri
Bu sayfada

Bu belge Next.js App Router kullanan bir projeyi JSkelet'e taşımayı anlatır: kavram ve API karşılıklarının tablosu, taşınamayan şeylerin açıkça listesi ve adım adım bir plan. JSkelet'in yüzeyi bilinçli olarak Next'in fiilen kullanılan alt kümesine benzetildi — next.config sözdizimi, Metadata API, notFound(), revalidate, cache() gibi kavramlar tanıdık gelecek. Farkların nedenleri Mimari'de.

jskelet migrate (codemod)

App Router ağacına karşı codemod'u çalıştırın. Babel (@babel/parser, @babel/types) JSkelet ile birlikte gelir — ek kurulum yok.

bash
npx jskelet migrate scan ../my-next-app
npx jskelet migrate apply ../my-next-app --out . --write
npx jskelet migrate config ../my-next-app --write
KomutNe yapar
migrate / migrate scanSayfa, layout, "use client" modülleri ve engelleri (iç içe layout, Server Actions, Suspense) listeler.
migrate applyOtomatik çeviri: page.* → feature controller + .jsk; presentational bileşenler → views/components/*.js; client → island mount() iskeleti. Varsayılan dry-run; yazmak için --write. Üzerine yazmaz (çakışmada .migrate soneki).
migrate confignext.config'ten jskelet.config.mjs taslağı (headers / redirects / rewrites, images.widths, NEXT_PUBLIC_* → clientEnv).

Bayraklar: --out <dir>, --only pages,components,islands, --json, --strict (partial/skipped → exit 1).

Otomatik çevrilenler: className, {{ }} / {{{ }}}, {#if} / {#each}, next/image → <Image />, next/link → <Link />, dangerouslySetInnerHTML, revalidate, basit controller prelude.

Çevrilmeyenler (raporlanır): React hooks, Server Actions, iç içe layout düzleştirme, Streaming/Suspense, client routing. Dosya başına ok / partial / skipped.

Karşılık tablosu

Yapılandırma

Next.jsJSkeletNot
next.config.mjsjskelet.config.mjsAynı ruh, daha küçük yüzey (07)
headers()headers()Aynı şekil: { source, headers: [{ key, value }] }
redirects()redirects()permanent → 308, aksi hâlde 307; statusCode ile ezilebilir
trailingSlashtrailingSlashtrue → kanonik URL / ile biter (308); varsayılan false slash kırpmaz (07)
rewrites()rewrites()beforeFiles / afterFiles fazları var; fallback yok
compress: trueOtomatiknode:zlib ile brotli + gzip
images.deviceSizesimages.widthsBuild zamanı webp üretimi (08)
NEXT_PUBLIC_*clientEnv: [...]Hangi anahtarın açık olduğu isimden değil config'ten belli
experimental.*—Yok

Routing ve render

Next.jsJSkeletNot
app/page.js (dosya bazlı routing)routes/*.mjs içinde app.get(...)Sıra açık yazılır (03)
app/[slug]/page.jsapp.get("/:slug", route(...))Express desen sözdizimi
params, searchParamsctx.params, ctx.queryController'ın tek argümanı
layout.jsviews/layout.jsk + hooks.layoutContext()Tek layout; iç içe layout yok
Sunucu bileşeni (RSC)Controller + .jsk şablonu + views/components/**Fonksiyon HTML string döndürür
İstemci bileşeni ("use client")Island (data-island + mount)Sayfanın tamamı hidre edilmez (05)
notFound()notFound()Aynı ad, aynı kontrol akışı
redirect()redirect() (307)Kalıcı için permanentRedirect() (308)
not-found.jshooks.notFound()Bir sayfa tanımı döndürür
error.jsExpress hata yöneticisiFramework 500 için minimal HTML döner
loading.js / Suspense—Sunucu HTML'i tam; iskelet gerekmiyor
Streaming SSR—Yanıt tek parça
generateMetadata()Controller metadata + hooks.metadata()Aynı alan adları (04)
opengraph-image.tsx / ImageResponseogHandler + ImageResponse / sendOgImageSVG veya kart alanları → PNG (sharp); 04
generateStaticParams()hooks.prewarmPaths()Build zamanı değil, açılış zamanı ısıtma
Route Handlers (route.js)Düz Express handler'ıapp.get/post(...)
Middleware (middleware.ts)Express middleware + config rewrites/headers/redirectsapp.use(...)

Veri ve önbellek

Next.jsJSkeletNot
export const revalidate = 60route(controller, { revalidate: 60 })Ya da cache().html (06)
ISR (dosyaya yazılan prerender)Bellek içi TTL cache + stale-while-revalidateDiske yazılmaz
fetch(..., { next: { revalidate } })—Önbellek sayfa düzeyinde
unstable_cache—İstek üstü veri önbelleği yok; sayfa önbelleği var
React cache()cache()Aynı davranış: istek içi memoizasyon
revalidatePath()invalidateHtmlCache("/haber/:slug")Yol, desen ya da RegExp; varsayılan olarak siler değil bayatlatır
revalidateTag()clearDataCache("haber:")Tag bildirmeye gerek yok: bağımlılık render sırasında gözlenir (06)
cookies(), headers()ctx.req.headers, ctx.req.cookies*Express nesnesine doğrudan erişim
dynamic = "force-dynamic"revalidate vermemekÖnbellek kapalı demek

\* Express 5 çerezleri kendiliğinden ayrıştırmaz; cookie-parser ekleyin ya da başlığı elle okuyun.

Bileşenler ve yardımcılar

Next.jsJSkeletNot
next/linklink({ href, text }) — jskelet/tagstitle otomatik, dış bağlantıya rel/target otomatik
next/link prefetch'inavigation: { prefetch, prerender }Speculation Rules; client runtime'ı yok (07)
next/imageimage({ src, alt, priority }) — jskelet/tagssrcset build manifest'inden
next/og ImageResponseImageResponse / ogHandler — jskeletJSX yok; SVG veya title/description kartı
next/font/googlefonts: [{ family, weights }]Self-host woff2, commit edilir
@phosphor-icons/reacticon({ name, weight }) — jskelet/tagsBuild zamanı SVG sprite
react-dom preconnect/preloadpreconnect: [...] + headHints()(04)
clsxcx() — jskelet/html—
cn() (clsx + tailwind-merge)cn() — jskelet/htmlAynı davranış
JSX otomatik kaçışıesc() — jskelet/htmlElle çağırmanız gerekir
React ContextcreateStore() — jskelet/clientMinimal pub/sub
useState / useEffectIsland mount() içinde düz JS—
useSyncExternalStorestore.subscribe()—
<Script>Layout'ta <script> ya da island—

Neyin karşılığı yok

Bunları taşıma planında baştan hesaba katın:

  • React'in kendisi. Bileşenler HTML string döndüren fonksiyonlara dönüşür. JSX yok, hook yok, sanal DOM yok.
  • TypeScript. Framework kaynağı düz JS + JSDoc'tur ve tüketiciler için .d.ts yayınlar. Client entry/island'lar .ts / .mts olabilir (esbuild tip siler; manifest anahtarı *.js kalır). Sunucu route, hook ve jskelet.config.mjs Node ESM JavaScript kalır — orada editör denetimi için jsconfig.json içinde checkJs: true kullanın.
  • İç içe layout'lar. Tek bir layout var; ortak bölümleri {#include} ya da bileşen fonksiyonlarıyla paylaşırsınız (legacy EJS’te include).
  • Streaming / Suspense / kısmi prerender. Yanıt tek parça üretilir.
  • İstemci tarafı yönlendirme. Gezinme gerçek sayfa yüklemesidir. Sunucu HTML'i önbellekten geldiği için pratikte çok hızlıdır, ama SPA geçişleri yoktur. Aradaki farkı kapatan şey navigation bölümü: prefetch/prerender tıklamadan önce belgeyi hazırlar, viewTransition geçişi yumuşatır (07).
  • Server Actions. Form gönderimleri normal app.post(...) handler'larıdır.
  • Otomatik görsel optimizasyonu (istek anında). Yerel public/ görselleri hâlâ build zamanında optimize edilir. Uzak http(s) görseller images.remote.allowHosts verilirse çalışma anında proxy edilir (/_jskelet/image → webp); config yoksa olduğu gibi basılır.

Yan yana örnek

Next.js (App Router):

jsx
// app/haber/[slug]/page.jsx
import { notFound } from "next/navigation";
import Image from "next/image";
import { getArticle } from "@/lib/api";

export const revalidate = 300;

export async function generateMetadata({ params }) {
  const article = await getArticle(params.slug);
  return {
    title: article?.title,
    description: article?.summary,
    alternates: { canonical: `/haber/${params.slug}` },
  };
}

export default async function Page({ params }) {
  const article = await getArticle(params.slug);
  if (!article) notFound();

  return (
    <article className="wrapper">
      <h1 className="text-3xl font-bold">{article.title}</h1>
      <Image src={article.cover} alt={article.title} priority width={1200} height={630} />
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
    </article>
  );
}

JSkelet:

js
// routes/50-haber.mjs
import { getArticle } from "@/lib/api.js";

export default function register(app, { route, notFound }) {
  app.get(
    "/haber/:slug",
    route(
      async ({ params }) => {
        const article = await getArticle(params.slug);
        if (!article) notFound();

        return {
          view: "pages/article",
          data: { article },
          metadata: {
            title: article.title,
            description: article.summary,
            canonical: `/haber/${params.slug}`,
            openGraph: { type: "article", image: article.cover },
          },
        };
      },
      { revalidate: 300 },
    ),
  );
}
jsk
{# views/pages/article.jsk #}
<article class="wrapper">
  <h1 class="text-3xl font-bold">{{ article.title }}</h1>
  <Image :src="article.cover" :alt="article.title" priority :width="1200" :height="630" />
  <div>{{{ article.body }}}</div>
</article>

getArticle'ın cache() ile sarılması, aynı render'da hooks.layoutContext() de aynı yazıyı isterse tek upstream isteği yapılmasını sağlar (Cache).

Adım adım plan

1. İskeleti kur (yarım gün)

Yeni bir dizinde npx jskelet init çalıştırın ve jskelet devin açıldığını görün. Mevcut Next projesini olduğu gibi bırakın; taşıma paralel yürüsün. İsterseniz önce jskelet migrate scan <next-root> ile sayfa ve engel listesine bakın.

jsconfig.json içindeki paths alias'larınızı taşıyın — @/ gibi önekler hem sunucuda hem bundle'da aynı şekilde çalışır (Mimari).

2. next.config.mjs'i çevir (1-2 saat)

jskelet migrate config <next-root> --write çoğunu taslaklar; ardından gözden geçirin:

headers(), redirects() ve rewrites() bölümleri neredeyse birebir kopyalanır. Desen sözdizimini kontrol edin: JSkelet :slug, :path*, /a-:b ve /:path*.svg biçimlerini destekler; daha karmaşık path-to-regexp ifadeleri desteklenmez ve uyarı üretir (Yapılandırma).

NEXT_PUBLIC_* değişkenlerini clientEnv listesine taşıyın ve adlarını sadeleştirin (ön ek artık anlam taşımıyor).

3. Veri katmanını taşı (en kolay adım)

lib/ altındaki API istemcisi ve veri fonksiyonları genelde React'e bağımlı değildir; olduğu gibi kopyalanır. İki değişiklik yapın:

  • React cache() yerine import { cache } from "jskelet".
  • Başarısız upstream yanıtlarında reportUpstreamFailure({ status, path }) çağırın. Bu, eksik veriyle üretilmiş sayfaların önbelleğe yazılmasını önler (Cache).

4. Layout'u kur (yarım gün)

app/layout.jsx'i views/layout.jsk'e çevirin (veya migrate apply taslağını kullanın). Framework'ün varsayılan layout'unu (jskelet/layout → .jsk) kopyalayıp üzerine yazmak en hızlı yol.

layout.jsx içinde veri çekiyorsanız (navigasyon, site ayarları) bunu hooks.layoutContext() içine taşıyın: gövde render'ıyla paralel çalışır ve döndürdüğü her alan layout local'i olur.

Global metadata varsayılanlarını (titleTemplate, siteUrl, description) hooks.metadata() içine koyun.

5. Bileşenleri çevir (en uzun adım)

jskelet migrate apply --only components --write hooks'suz presentational bileşenleri çevirir. Gerisini elle bitirin:

Her React bileşeni bir fonksiyona dönüşür:

jsx
// Önce
export function Badge({ label, tone = "neutral", className }) {
  return <span className={cn("rounded px-2 py-1", TONES[tone], className)}>{label}</span>;
}
js
// Sonra — views/components/badge.js
import { attrs, cn, esc } from "jskelet/html";

export function badge({ label, tone = "neutral", class: className }) {
  return `<span${attrs({ class: cn("rounded px-2 py-1", TONES[tone], className) })}>${esc(label)}</span>`;
}

Dikkat edilecekler:

  • Kaçış artık elinizde. JSX otomatik kaçıyordu; burada dış veriyi basarken esc() çağırmak zorundasınız.
  • className → class. JS'te class ayrılmış sözcük olduğu için props'ta class: className biçiminde yeniden adlandırın.
  • Children yerine html alanı. İç içe içerik string olarak geçirilir.
  • Named export olarak views/components/** altına koyduğunuz her fonksiyon şablonlarda import gerektirmeden kullanılabilir (Render ve şablonlar).

Bileşenleri küçük ve saf tutun; veri çekmeyi controller'da bırakın.

6. Sayfaları taşı (sayfa başına saatler)

jskelet migrate apply --only pages --write her page.* dosyasını feature controller + .jsk şablonuna böler. partial / skipped satırlarını gözden geçirip TODO'ları bitirin. Sırayı düşünerek dosyalayın:

routes/
├── 00-health.mjs        sağlık kontrolü
├── 10-pages.mjs         sabit yollar: /, /hakkinda
├── 50-haber.mjs         /haber/:slug
└── 99-catch-all.mjs     /:slug  (varsa, en sonda)

generateStaticParams() kullandığınız yerler hooks.prewarmPaths()e dönüşür. Sitemap üreten fonksiyonunuz varsa aynısını kullanın.

export const revalidate değerlerini ya route()un ikinci argümanına ya da tek bir yerden yönetmek için cache().html desenlerine taşıyın.

7. İstemci bileşenlerini island'a çevir (sayfa başına saatler)

Her "use client" bileşeni bir island olur. Süreç:

  1. Bileşenin statik çıktısını sunucu şablonuna taşıyın. İlk render'da görünen her şey HTML'de olmalı.
  2. Kalan davranışı mount(element, props) içine yazın: useState yerel değişken, useEffect doğrudan çağrı, event handler'lar on()/onClick().
  3. Props'u data-island-props ile JSON olarak geçirin.
  4. Entry'de registerAll() haritasına ekleyin.
  5. Bağlanma stratejisini seçin: varsayılan (görünürlük), data-island-eager (global davranış) ya da data-island-idle (ağır ve kritik olmayan).

Context kullanan bileşenler için createStore() en yakın karşılık (Island'lar).

Bu adımın en büyük kazancı burada: hidre edilen alan sayfanın tamamı değil, yalnızca gerçekten etkileşimli parçalar.

8. CSS'i taşı (1-2 saat)

Tailwind yapılandırmanız v4 formatındaysa styles/globals.css neredeyse aynı kalır. Tek kritik ekleme @source direktifleri:

css
@import "tailwindcss" source(none);

@source "../views";
@source "../client";
@source "../routes";
@source "../lib";

Bunlar olmadan şablonlarda geçen sınıflar (özellikle data-[state=open]:… gibi varyantlar) sessizce düşer (Build).

next/font kullanıyorsanız fonts: [{ family, weights }] ekleyin ve @font-face bloklarını elle yazın; üretilen dosyalar public/fonts/ altında sabit isimlerle durur.

9. Doğrula ve ölç

  • jskelet dev ile gezip dev overlay'inde hata olmadığını doğrulayın.
  • Rapor sayfasında (/__jskelet/dev/report) her sayfanın Web Vitals ölçümlerine, SSR boyutuna ve island durumuna bakın (Dev araçları).
  • Eksik ikon uyarılarını temizleyin.
  • jskelet build çıktısındaki boyutları eski Next bundle'ıyla karşılaştırın.
  • X-JSkelet-Cache başlığının beklediğiniz sayfalarda HIT döndüğünü kontrol edin.

10. Yayına al

Dağıtım içindeki kontrol listesini geçin. Eski Next kurulumunu bir süre yanında tutup trafiği kademeli çevirmek, özellikle redirect kurallarının doğruluğunu ölçmek için işe yarar.

Taşıma sırasında sık yapılan hatalar

  • esc() unutmak. JSX'ten gelen alışkanlıkla ${value} yazmak XSS demektir. .jsk'de {{ }} (kaçışlı) / {{{ }}} (ham); bileşenlerde esc() kendiniz.
  • @source eklemeden yeni bir dizin açmak. Sınıflar sessizce düşer.
  • Yakalayıcı route'u yanlış sıraya koymak. /:slug her zaman en sonda.
  • Sayfanın tamamını island yapmak. Kazanç sunucu HTML'inin tam olmasından geliyor; island'ı yalnızca gerçekten etkileşimli parçaya bağlayın.
  • revalidate vermeyi unutmak. Önbellek kapalı kalır, her istek render edilir ve X-JSkelet-Cache: MISS döner.
  • reportUpstreamFailure() çağırmamak. Upstream düştüğünde eksik veriyle üretilen sayfa tüm TTL boyunca servis edilir.
  • clientEnve gizli anahtar koymak. Değerler bundle'da düz metin durur.

Sırada ne var