รูปแบบ localization ที่สะอาดสำหรับ route แบบ Next.js
สถาปัตยกรรม localization ที่ให้ route เป็นจุดตั้งต้น แล้วทำให้ dictionary, data fetching, metadata, navigation, API, fallback และแผน migration ใช้ข้อตกลงเดียวกัน
- เผยแพร่
- 2026-06-12
- เวลาอ่าน
- 8 นาที
Localization จะดูแลง่ายเมื่อ locale เป็นส่วนหนึ่งของ request contract ตั้งแต่ต้น ไม่ใช่ขั้นแทนข้อความตอนท้าย สำหรับ Next.js เราสามารถให้ route ประกาศภาษาหนึ่งครั้ง แล้วให้ server rendering, metadata, link และ API เดินตามอย่างสม่ำเสมอ
ให้ locale ปรากฏอยู่ใน route
สำหรับ portfolio หรือ product site ที่ต้องการให้แต่ละหน้าถูกค้นเจอ ใช้ locale segment ที่ชัดเจน เช่น /{lang}/articles/{slug} URL จะระบุทั้ง resource และภาษาในตัวเอง จึงแชร์ cache, log, render ฝั่ง server และทำดัชนีได้โดยไม่ต้องพึ่งค่าที่มีเฉพาะใน browser
รายการภาษาที่รองรับควรเล็ก ชัด และมี type กำกับ ใน portfolio นี้รองรับ en กับ th และใช้ en เป็นค่าเริ่มต้น route ราก redirect ไปภาษานั้น ส่วน localized layout ตรวจ segment และส่ง not found เมื่อค่าไม่รองรับ ขณะเดียวกัน static params ก็สร้างจาก locale list ชุดเดียวกัน แหล่งข้อมูลเดียวช่วยไม่ให้ routing, rendering และขั้น build ค่อย ๆ ใช้กติกาคนละชุด
Cookie หรือ Accept-Language อาจช่วยเลือก redirect ครั้งแรกได้ แต่ไม่ควรเปลี่ยน URL ที่ระบุภาษาอยู่แล้วแบบเงียบ ๆ เมื่อผู้ใช้เปิด /th/articles/... route ต้องเป็นผู้ตัดสิน สิ่งนี้ทำให้พฤติกรรมคาดเดาได้ทั้งสำหรับผู้ใช้ crawler, test และทีม support
เก็บ dictionary ไว้ฝั่ง server และมี type เดียวกัน
แยกข้อความ interface เป็นหนึ่ง dictionary ต่อภาษา โดยทุกไฟล์ใช้ key รูปเดียวกันและอ้างอิง TypeScript type ร่วมกัน โหลดเฉพาะ dictionary ที่ route เลือก implementation ปัจจุบัน import en.json หรือ th.json ผ่าน loader ที่ key ด้วย locale, ระบุโมดูลเป็น server-only และ cache การอ่านในระดับ request ด้วย React จากนั้น component รับ dictionary หรือ slice เล็กที่สุดที่ต้องใช้ผ่าน props
วิธีนี้ช่วยสามเรื่องพร้อมกัน key ที่ขาดกลายเป็นปัญหาตอนพัฒนาแทนที่จะไปแตกใน production ภาษาที่ไม่ได้ใช้ไม่จำเป็นต้องเข้า client bundle และทุกภาษายังใช้ markup ชุดเดียว จึงไม่เผลอสร้างหน้าอีก implementation หนึ่งเพียงเพราะแปลข้อความ
ไม่จำเป็นต้องยัด content ทุกชนิดลง dictionary เดียวกัน label ของ navigation และข้อความใน form เหมาะกับ dictionary แต่บทความหรือ product record ควรอยู่ใน content store ที่ผูก translation เข้ากับ canonical record เดียว backend ของ portfolio เก็บ slug บทความหนึ่งค่า แล้วเลือก title, summary, lead, reading time และ Markdown body ตามภาษาที่ร้องขอ ตัวตนของบทความจึงคงที่ ขณะที่สำนวนของแต่ละภาษาเขียนให้เป็นธรรมชาติได้
ขีดเส้น server กับ client อย่างตั้งใจ
ถ้าเป็นไปได้ ให้ Server Component เป็นผู้ resolve locale, โหลด dictionary, fetch content ตามภาษา และสร้าง metadata ส่วน Client Component รับเพียง locale กับข้อความที่ถูกเลือกแล้วเท่าที่ต้องใช้ ตัวอย่างเช่น contact form รับ label ที่แปลแล้วและส่ง locale ผ่าน hidden field ได้ โดยไม่ต้องถือ dictionary ทั้งก้อนไว้ฝั่ง client
อย่า import server dictionary loader เข้า Client Component นอกจาก bundle จะใหญ่ขึ้นแล้ว ยังทำให้เกิด language state สองชุด คือ route ที่ server เห็นกับ state หลัง hydration หาก language switch เปลี่ยน URL การ navigation จะขอ server tree ใหม่ตามธรรมชาติ Client state ยังเหมาะกับ interaction ชั่วคราว แต่ไม่ควรเป็นตัวกำหนดว่าหน้าปัจจุบันเป็นภาษาอะไร
หลักเดียวกันใช้กับข้อมูล article adapter ของ frontend นี้เป็น server-only ส่ง lang ไป API ตรวจชนิดและฟิลด์ของ response แล้วคืนเฉพาะ record ที่มีรูปครบ ขอบเขตนี้ช่วยไม่ให้ข้อมูลที่ยังไม่น่าเชื่อถือจาก API ไหลเข้า page component โดยตรง
แปล metadata ไปพร้อมกับหน้า
ข้อความบนหน้าและ metadata ต้องอ้างอิง locale เดียวกัน สร้าง title, description, canonical URL, Open Graph locale และ language alternate ภายใน localized route สำหรับบทความ ให้ใช้ title และ summary ของภาษานั้น ขณะที่ slug หลักยังคงเดิม
repository นี้สร้าง canonical path อย่าง /en/articles/{slug} พร้อม alternate สำหรับอังกฤษและไทย และ x-default ที่ชี้ไปอังกฤษ Layout ตั้งค่า lang ของ document และแปลง locale เป็นค่า Open Graph เช่น en_US กับ th_TH รายละเอียดเหล่านี้ช่วยทั้ง accessibility และ discovery รวมถึงป้องกันบั๊กที่เกิดขึ้นจริงบ่อยครั้ง คือหน้าไทยแต่ preview เวลาแชร์ยังเป็นข้อความอังกฤษ
Metadata generation ต้องใช้ not-found behavior เดียวกับตัวหน้า ถ้า locale หรือบทความไม่มีอยู่ อย่าสร้าง metadata กลาง ๆ ให้ URL ที่สุดท้าย render ไม่ได้
สร้าง link ที่รู้จัก locale ไม่ใช่ต่อ string กระจัดกระจาย
Internal link ทุกจุดควรผ่าน path helper หรือ component ที่เข้าใจ locale ไม่ว่าจะเป็นหน้าแรก สารบัญบทความ หน้ารายละเอียด legal page, pagination หรือ return path ของ form ทุกเส้นทางต้องพกภาษาปัจจุบันไปด้วย การรวมวิธีสร้าง path ไว้ที่เดียวช่วยกันบั๊กคลาสสิกที่ link ใน footer เพียงจุดเดียวพาผู้ใช้กลับภาษา default
Language switch ต้องคิดมากกว่าเปลี่ยนปุ่ม หาก resource เดียวกันมี translation ควรรักษาหน้านั้นไว้ เช่น /en/articles/designing-systems ควรไป /th/articles/designing-systems ไม่ใช่กลับ /th เฉย ๆ เก็บ query parameter และ fragment ที่ยังมีความหมายไว้ด้วย ถ้า identifier ของแต่ละภาษาไม่เหมือนกัน ให้มี mapping ชัดเจน อย่าเดาโดยแปล path string
ใช้ Link สำหรับ navigation ภายในแอป ส่วน anchor ปกติเก็บไว้สำหรับปลายทางภายนอกหรือพฤติกรรม document ที่ตั้งใจ และควร test ค่า href ที่สร้างขึ้นโดยตรง เพราะบั๊ก localization จำนวนมากซ่อนอยู่ใน navigation ไม่ได้อยู่ในข้อความ dictionary
กำหนด fallback ให้ชัดก่อนระบบเลือกให้เอง
อย่างน้อยมี fallback สามเรื่องที่ต้องตัดสินใจแยกกัน Route fallback บอกว่า / ไปไหน Dictionary fallback บอกว่า interface copy ขาดแล้ว build ต้องพังหรือยอมใช้ภาษาหลัก และ content fallback บอกว่า API จะตอบอย่างไรเมื่อบทแปลไม่มี นโยบายทั้งสามไม่จำเป็นต้องเหมือนกัน แต่ต้องประกาศให้ชัด
backend ปัจจุบันเลือก translation ตามภาษาที่ขอ ถ้าไม่มีจึงเลือกอังกฤษ แล้วจึงเลือก translation แรกที่มี นี่เป็นนโยบาย availability-first ที่ใช้ได้ แต่ response ควรเปิดเผย resolved locale หาก client ต้องติดป้ายว่ากำลังแสดงภาษาใด สำหรับเนื้อหากฎหมาย งานที่ถูกกำกับ หรือข้อมูลอ่อนไหว การตอบ not found อย่างเคร่งครัดอาจปลอดภัยกว่าการสลับภาษาเงียบ ๆ
อย่าจับ fetch error ทุกชนิดแล้วแสดงว่า “ยังไม่มีคำแปล” record ไม่พบ, API ใช้งานไม่ได้, ข้อมูลผิดรูป และ locale ไม่รองรับ เป็นคนละสถานะกัน Frontend นี้กำหนด request timeout, มอง 404 ว่าไม่พบ, throw เมื่อ response อื่นไม่สำเร็จ และตรวจฟิลด์ที่ตอบกลับ การแยกสถานะเหล่านี้ทำให้ UI สื่อสารปัญหาได้ตรงไปตรงมา
ทำให้ localization contract ของ API ธรรมดาและคาดเดาได้
เลือก convention เดียว เช่น ?lang=th แล้วใช้ทั้ง list และ detail endpoint ตรวจ locale ที่ขอบระบบ เลือก translation ใน service layer และตอบ localized view เพียงชุดเดียว แทนการส่งทุกภาษาให้ public client แยก canonical field อย่าง slug, category, status และ publication date ออกจาก translated field
ฝั่ง write ให้ validate แต่ละ translation แยกกันและบังคับภาษาที่จำเป็นต่อการ publish article validation ใน backend นี้ต้องมีทั้งอังกฤษและไทย trim ฟิลด์บังคับ จำกัด status, normalize slug, จำกัดขนาด Markdown และปฏิเสธ raw HTML หรือ URL scheme ที่ไม่ปลอดภัย นี่เป็น contract ที่แข็งแรงกว่าการหวังว่า admin UI จะส่งข้อมูลสะอาดเสมอ
หาก client ต้องรู้ว่าเกิด fallback หรือไม่ ให้เพิ่ม resolved locale ใน response อย่าพยายามเดาจาก title และต้องรวม locale ไว้ใน cache key กับแผน invalidation ด้วย เพราะ response คนละภาษาของ slug เดียวกันคือ representation คนละชุด
ทดสอบ contract ทุกชั้น
เพิ่ม unit test ให้ locale guard, path helper, ความครบของ key ระหว่าง dictionary, การเลือก translation ใน API และลำดับ fallback เพิ่ม route test สำหรับ locale ที่รองรับและไม่รองรับ metadata ตามภาษา canonical กับ alternate link และบทความที่ไม่พบ Render หน้าตัวแทนให้ครบสองภาษา แล้ว assert ว่า navigation ยังอยู่ในภาษาเดิม
ทดสอบ client boundary ด้วย form ต้องส่ง locale, validation error ต้องใช้ข้อความภาษาที่ตรงกัน และ language switch ต้องรักษา resource ปัจจุบัน ขั้น build ควร generate ทุก locale ที่รองรับ การ crawl route ที่รู้จักสั้น ๆ สามารถจับ internal link ที่ลืม prefix ได้ก่อน deploy
ย้ายระบบโดยไม่ต้องเขียนใหม่ทั้งก้อน
เริ่มจาก inventory public route และแยก canonical data ออกจากข้อความที่แสดง เพิ่ม locale type, supported list และ path helper จากนั้นสร้าง localized layout แล้ว redirect root route เก่าไป default ที่เลือกไว้ ย้าย interface copy ที่ใช้ร่วมกันเข้า typed dictionary โดยยังไม่เปลี่ยน markup ต่อด้วยการให้ data endpoint รับ locale และย้าย content record ไปอยู่หลัง slug เดิม
เมื่อ route tree ทั้งสองภาษาใช้งานได้ จึงเพิ่ม localized metadata กับ alternate, อัปเดต internal link และทดสอบ parity ระหว่างภาษา เก็บ redirect จาก URL เก่าในช่วงเปลี่ยนผ่าน และหลีกเลี่ยงการเปลี่ยนทั้ง slug กับโครงสร้างภาษาใน release เดียวถ้าไม่จำเป็น ลบหน้าที่ซ้ำกับ fallback ชั่วคราวหลัง log และ test ยืนยันว่า localized route รองรับ traffic จริงได้แล้วเท่านั้น
รูปแบบที่สะอาดไม่ได้ขึ้นกับ library ใด library หนึ่ง แต่เกิดจากการตัดสินใจที่ต่อกันอย่างชัดเจน route ประกาศ locale, server เลือก content, API คืน representation เดียว, metadata ตรงกับหน้า และ link รักษาบริบท เมื่อ contract เหล่านี้เรียงตรงกัน การเพิ่มภาษาใหม่จะเป็นงานด้าน content และ verification ไม่ใช่การสร้างแอปอีกชุดหนึ่ง