ข้ามไปยังเนื้อหา

Routing & Pages

ใน SvelteKit โครงสร้าง folder ใต้ src/routes/ คือ โครงสร้าง URL ไฟล์ +page.svelte ทำให้ folder ของตัวเองกลายเป็นเพจที่เข้าถึงได้

src/routes/
├── +page.svelte → /
├── about/
│ └── +page.svelte → /about
├── blog/
│ ├── +page.svelte → /blog
│ └── [slug]/
│ └── +page.svelte → /blog/:slug (dynamic)
└── +layout.svelte → wraps every page
flowchart LR
  f1["routes/+page.svelte"] --> u1["/"]
  f2["routes/about/+page.svelte"] --> u2["/about"]
  f3["routes/blog/[slug]/+page.svelte"] --> u3["/blog/:slug"]
folder แมปเป็น URL

วงเล็บเหลี่ยมในชื่อ folder จะจับ segment ของ URL ไว้ src/routes/blog/[slug]/+page.svelte จะ match /blog/hello และค่านั้นจะอยู่ที่ params.slug (ใน load function — บทเรียนถัดไป) รูปแบบต่าง ๆ:

  • [slug] — parameter ที่บังคับต้องมี
  • [...rest] — rest parameter ที่ match ได้หลาย segment (/docs/a/b/c)
  • [[optional]] — optional parameter ที่ match ได้แม้ไม่มีค่า

[id] ธรรมดา match segment อะไรก็ได้ matcher — function ใน src/params/ — ทำให้ route match เฉพาะเมื่อ segment ผ่านเงื่อนไข ทำให้ static route ที่ไม่เกี่ยวข้องข้างล่างชนะแทนได้:

src/params/integer.ts
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => /^\d+$/.test(param);
// src/routes/products/[id=integer]/+page.server.js
export function load({ params }) {
return { id: params.id }; // only reached when params.id is all digits
}

พิสูจน์ทั้งการ match และการ fallthrough กับ route จริงที่รันอยู่ (/products/[id=integer] กับ static /products/latest ธรรมดา):

$ curl -s http://localhost:5196/products/42 | grep -o '<h1[^<]*</h1>'
<h1 data-testid="product">product 42</h1>
$ curl -s http://localhost:5196/products/latest | grep -o '<h1[^<]*</h1>'
<h1 data-testid="latest">latest products (non-numeric, matcher rejects it so this static route wins)</h1>

/products/42 ผ่าน regex ของ integer เลยไปโดน dynamic route; /products/latest ไม่ผ่าน SvelteKit จึง fallthrough ไปที่ products/latest/+page.svelte ธรรมดาแทนที่จะเป็น 404 — matcher ช่วยแยกความกำกวมของสอง route ที่ปกติจะชนกันที่ segment เดียวกันได้

การครอบชื่อ folder ด้วยวงเล็บ — (app) — สร้าง route group: จัดระเบียบ route (และมี +layout.svelte ร่วมได้) โดยไม่เพิ่ม segment ให้ URL

src/routes/
└── (app)/
└── grouped/
└── +page.svelte → /grouped (NOT /app/grouped or /(app)/grouped)

พิสูจน์กับ route จริง:

$ curl -s http://localhost:5196/grouped | grep -o '<h1[^<]*</h1>'
<h1 data-testid="grouped">grouped page, no (app) segment in the URL</h1>

URL คือ /grouped ไม่ใช่ /app/grouped — group folder เป็นแค่การจัดระเบียบล้วน ๆ (มักใช้ให้ทั้งเซกชัน เช่นทุก route ที่ต้อง login มี layout ร่วมกันโดยไม่โผล่ใน URL เลย)

+layout.svelte มีผลกับ folder ของตัวเองและทุก route ที่อยู่ข้างใต้ — เป็นที่วางองค์ประกอบที่ใช้ร่วมกันอย่าง header และ footer โดย layout render child route ผ่าน children snippet:

src/routes/+layout.svelte
<script>
let { children } = $props(); // the active child page
</script>
<nav>…site navigation…</nav>
<main>
{@render children()} <!-- the current page renders here -->
</main>
<footer>© 2026</footer>

layout ซ้อนกันได้ (nest): layout ที่อยู่ลึกกว่าในต้นไม้จะถูกครอบด้วยตัวที่อยู่เหนือขึ้นไป คุณจึงประกอบ global shell กับ shell เฉพาะแต่ละส่วนได้

บางครั้งเพจเดียวต้องการ ข้าม layout ทั้งหมดข้างบนไปเลย — เพจ landing แบบเต็มจอ, print view ตั้งชื่อไฟล์ [email protected] (หรือ [email protected]) จะ reset กลับไปที่ root ข้ามทุก layout ระหว่างทาง; +page@(group).svelte จะ reset ไปที่ ancestor ที่ตั้งชื่อไว้แทนที่จะเป็น root ตรง ๆ regex ของไฟล์ route ใน SvelteKit เองยืนยัน syntax นี้ว่าเป็น page(@(.*))? / layout(@(.*))? — @ ตามด้วยชื่อ group ที่จะใส่หรือไม่ก็ได้

<!-- src/routes/(app)/wide/+layout.svelte — the (app) section's shared shell -->
<script lang="ts">
let { children } = $props();
</script>
<div>
<nav>app nav</nav>
{@render children()}
</div>
<!-- src/routes/(app)/wide/[email protected] — @ resets to root, skipping (app)'s own layout -->
<h1>wide page — reset with @, skips (app)/+layout.svelte and the root layout's own markup</h1>

พิสูจน์ด้วยการ curl HTML ที่ render จริง — <nav>app nav</nav> ของ layout (app) ไม่มีอยู่ใน response เลย มีแค่ <h1> ของเพจเอง:

$ curl -s http://localhost:5197/wide | grep -o '<h1[^<]*</h1>\|<nav>[^<]*</nav>'
<h1 data-testid="wide">wide page — reset with @, skips (app)/+layout.svelte and the root layout's own markup</h1>

+error.svelte ใน folder หนึ่งจะ render เมื่อ load (หรือตัวเพจ/layout เอง) throw ใน subtree นั้น — เป็น error boundary แบบ scoped ที่อ่าน error ผ่าน page.error (จาก $app/state) SvelteKit เดินขึ้นบนต้นไม้ route หา +error.svelte ที่ใกล้ที่สุด เริ่มจาก route ที่ throw

พิสูจน์ด้วยสอง route: errors/deep throw ใต้ folder ที่มี errors/+error.svelte ของตัวเอง และ errors2/deep2 throw ใต้ folder ที่ไม่มี — มันเลยเด้งขึ้นไปถึง root src/routes/+error.svelte:

src/routes/errors/deep/+page.server.js
import { error } from '@sveltejs/kit';
export function load() {
error(500, 'boom from errors/deep');
}
// src/routes/errors2/deep2/+page.server.js — no scoped +error.svelte anywhere in errors2/
import { error } from '@sveltejs/kit';
export function load() {
error(500, 'boom from errors2/deep2');
}
$ curl -s http://localhost:5196/errors/deep | grep -o '<h1[^<]*</h1>'
<h1 data-testid="err">SCOPED-ERROR errors/+error.svelte: boom from errors/deep</h1>
$ curl -s http://localhost:5196/errors2/deep2 | grep -o '<h1[^<]*</h1>'
<h1 data-testid="err">ROOT-ERROR +error.svelte: boom from errors2/deep2</h1>

error แบบเดียวกัน (error(500, ...)) throw ที่ความลึกเท่ากัน — แต่ +error.svelte ตัวไหน render ขึ้นกับว่า folder ancestor มี +error.svelte จริง ๆ หรือเปล่าเท่านั้น

  • navigation ใช้ลิงก์ <a href="/blog"> ปกติ SvelteKit จะ intercept ไว้เพื่อทำ client-side navigation ที่เร็ว (ไม่ reload ทั้งหน้า) ส่วนการ navigate แบบ programmatic ให้เรียก goto('/blog') จาก $app/navigation
<script>
import { goto } from '$app/navigation';
</script>
<a href="/about">About</a> <!-- client-side nav -->
<button onclick={() => goto('/dashboard')}>Go</button>

เวลารีวิวโค้ด routing ที่ generate มา เช็คสิ่งเหล่านี้:

  • สมมติว่า +error.svelte ที่ “อยู่ใกล้ ๆ กันในโครงสร้าง folder” จะจับ error ได้ โดยไม่เช็คว่า folder นั้นเป็น ancestor จริงของ route ที่ throw หรือเปล่า? พิสูจน์แล้วข้างบน: +error.svelte ของ sibling subtree ไม่เคยจับอะไรได้เลย — มีแค่ ancestor เท่านั้น ถ้าไม่มีเลยมันจะเด้งไปที่ root
  • [id] route บัง static route ที่เจาะจงกว่าแบบเงียบ ๆ (หรือกลับกัน) เพราะไม่มี param matcher มาแยกความกำกวมหรือเปล่า?
  • (group) โผล่เป็น segment จริงใน <a href> หรือ goto() ที่ generate มา เหมือนวงเล็บเป็นส่วนหนึ่งของ URL หรือเปล่า? มันไม่เคยเป็นแบบนั้น — พิสูจน์แล้วข้างบนด้วย /grouped
  • ใช้ [email protected] โดยไม่รู้ว่ามันข้าม layout ทั้งหมด กลับไปถึงจุด reset — รวมถึงอันที่เพจยังต้องการอยู่ (เช่น <svelte:boundary> รายงาน error ร่วม, analytics beacon ที่อยู่ใน layout ที่ถูกข้ามไป) หรือเปล่า?
  • navigation ใช้ window.location.href = '/path' (reload เต็มหน้า) ทั้งที่ตั้งใจจะใช้ goto('/path') (client-side พิสูจน์แล้วข้างบน) หรือเปล่า?

🐛 หา bug ในโค้ดนี้

src/routes/
// project layout:
// errors/
// +error.svelte <- exists here
// deep/+page.server.js
// errors2/
// deep2/+page.server.js <- NO +error.svelte anywhere in errors2/
// src/routes/errors2/deep2/+page.server.js
import { error } from '@sveltejs/kit';
export function load() {
error(500, 'something failed in errors2/deep2');
// "the errors/+error.svelte will catch this too, it's right there in routes/"
}
เฉลย

errors/+error.svelte ครอบคลุมเฉพาะ folder ของตัวเองกับ folder ที่ซ้อนอยู่ข้างใต้เท่านั้น — errors2/ เป็น sibling ไม่ใช่ descendant boundary นั้นจึงไม่เห็น error นี้เลย พิสูจน์แล้วข้างบนด้วย curl จริง: การยิงไปที่ /errors2/deep2 render root src/routes/+error.svelte ไม่ใช่ errors/+error.svelte เพราะ SvelteKit เดินขึ้นจาก route ที่ throw หา +error.svelte ที่เป็น ancestor ที่ใกล้ที่สุด — และ errors2/deep2 ไม่มี ancestor ที่มี +error.svelte เลยจนถึง root ทางแก้คือเพิ่ม errors2/+error.svelte เพื่อจัดการแบบ scoped หรือยอมรับ (และออกแบบให้รองรับ) ว่า root error page จะจับมันแทน

routing คือต้นไม้ และมีสามสิ่งเกี่ยวกับ request ที่แต่ละอันถูก resolve ด้วยการเดินต้นไม้เดียวกันนี้ แต่หยุดคนละจุด: page คือ leaf ที่ match พอดี; layout คือทุก ancestor จาก root ลงมาถึง leaf นั้น แต่ละอันซ้อนอยู่ข้างในตัวที่อยู่เหนือขึ้นไป (เว้นแต่ @ จะ reset การเดินก่อนถึงจุดนั้น); error boundary คือ ancestor ที่ใกล้ที่สุด (เดินจาก leaf ย้อนขึ้นไป) ที่บังเอิญมี +error.svelte group ((name)) กับ matcher ([x=name]) ไม่เปลี่ยนรูปร่างต้นไม้นี้ต่อ URL หรือการ render — มันแค่เปลี่ยนวิธีจัดระเบียบ folder (group) หรือว่า segment นั้นมีสิทธิ์ match หรือเปล่า (matcher)

ได้อะไรประโยชน์ต้นทุน
param matcher ([id=integer])สอง route แชร์ตำแหน่ง segment เดียวกันได้โดยไม่ชนกัน (พิสูจน์แล้วข้างบน)ต้องดูแลไฟล์เพิ่ม (src/params/x.ts) ต่อเงื่อนไขหนึ่งอัน
route group (name)แชร์ layout/จัดระเบียบโดยไม่เสีย URL เลย (พิสูจน์แล้วข้างบน)ลืมง่ายว่าวงเล็บไม่โผล่ใน link — href="/(app)/grouped" ที่ generate มาผิดตรง ๆ
reset ด้วย [email protected]คุมได้เต็มที่ว่าจะข้าม chrome ของ ancestor ที่ไม่ต้องการ (พิสูจน์แล้วข้างบน)ข้ามทุกอย่างกลับไปถึงจุด reset รวมถึงของมีประโยชน์ที่อยู่ใน layout เหล่านั้นด้วย
+error.svelte แบบ scoped ต่อ folderจำกัดวงระเบิดของความล้มเหลวให้อยู่แค่เซกชันนั้นทุก folder ที่ไม่มีจะพึ่ง ancestor บางตัวแบบเงียบ ๆ — ควรรู้ว่าตัวไหน พิสูจน์แล้วข้างบนว่าไม่ใช่เสมอตัวที่ “ดูใกล้ที่สุด”
  • “+error.svelte ตัวไหนก็ตามในไฟล์ route เดียวกันที่อยู่ใกล้ ๆ จะจับ error ได้” พิสูจน์แล้วข้างบน: มีแค่ +error.svelte ของ folder ที่เป็น ancestor เท่านั้นที่จับได้ — ของ sibling subtree ไม่เคยจับได้เลย ไม่ว่าจะดูใกล้แค่ไหนใน file tree
  • “route group (name) เพิ่ม segment /name ให้ URL” พิสูจน์แล้วข้างบน: ได้ /grouped ไม่ใช่ /app/grouped — group เป็นแค่การจัดระเบียบล้วน ๆ
  • “[id] ธรรมดากับ static route ที่ตำแหน่งเดียวกันอยู่ด้วยกันไม่ได้” param matcher ทำให้อยู่ด้วยกันได้: พิสูจน์แล้วข้างบน /products/latest fallthrough ไปที่ static route เพราะ matcher ของ integer ปฏิเสธมัน
  • “[email protected] ข้ามแค่ parent layout ที่ใกล้ที่สุด” พิสูจน์แล้วข้างบน: @ เปล่า ๆ reset กลับไปถึง root เลย ข้ามทุก layout ระหว่างทาง ไม่ใช่แค่ระดับเดียว
route ของเพจใน SvelteKit นิยามยังไง
folder src/routes/blog/[slug]/ สร้างอะไร
request มาที่ /products/latest จะ render อะไร
src/routes/products/[id=integer]/+page.server.js
src/routes/products/latest/+page.svelte
folder (app) ใน src/routes/(app)/grouped/ โผล่ใน URL ไหม
ตั้งชื่อไฟล์ [email protected] ทำอะไร
load throw ข้างใน src/routes/errors2/deep2/ และไม่มี +error.svelte เลยใต้ errors2/ จะ render อะไร