Environment และ Configuration
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”env var เป็น server-only เป็น default มีแค่ตัวที่ขึ้นต้นด้วย NEXT_PUBLIC_ เท่านั้นที่ถูก inline เข้า client bundle — และ inline ก็ต่อเมื่อเข้าถึงแบบ static literal process.env.NEXT_PUBLIC_X เท่านั้น เพราะฉะนั้น secret ห้ามมี prefix นั้นเด็ดขาด และการเข้าถึงแบบ dynamic จะไม่มีทางไปถึง browser
Server-only เทียบกับ NEXT_PUBLIC_
หัวข้อที่มีชื่อว่า “Server-only เทียบกับ NEXT_PUBLIC_”env var ธรรมดาใช้ได้แค่ฝั่ง server พอใส่ prefix NEXT_PUBLIC_ Next.js จะ inline ค่านั้นเข้าไปใน JavaScript ที่ส่งไป browser ตอน build เหมาะกับ analytics id สาธารณะ แต่หายนะถ้าเป็น API secret
// Server Component or route — both work on the serverconst dbUrl = process.env.DATABASE_URL // server-only, safeconst analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID // also in the client bundleDATABASE_URL="postgres://user:pass@host:5432/db" # never prefix a secretNEXT_PUBLIC_ANALYTICS_ID="abcdefghijk" # safe to exposeเพราะค่า NEXT_PUBLIC_ ถูกฝังตอน build การเปลี่ยนค่าแปลว่าต้อง rebuild ใหม่ ไม่ได้อ่านตอน runtime บน browser
NEXT_PUBLIC_ ต้องเป็น static literal — พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “NEXT_PUBLIC_ ต้องเป็น static literal — พิสูจน์แล้ว”Next.js inline ตัวแปร NEXT_PUBLIC_ ด้วยการแทนที่ข้อความตรง ๆ ตอน build: มันมองหา source pattern ที่ตรงเป๊ะ ๆ คือ process.env.NEXT_PUBLIC_X แล้วแทนด้วยค่าจริง การ lookup แบบ dynamic — process.env[someVariable] — ไม่ match pattern นั้น เลยไม่ถูกแทนที่ และบน browser process.env ก็ไม่มีอยู่จริงอยู่แล้วนอกจากนี้
พิสูจน์ทั้งสองแบบใน probe ด้วย Client Component:
// app/page.tsx — 'use client', both variants read the same variable'use client'import { useEffect } from 'react'
function readEnv(name: string) { return process.env[name] // dynamic — the key is a runtime argument}
export default function Page() { useEffect(() => { console.log('STATIC', process.env.NEXT_PUBLIC_ANALYTICS_ID) console.log('DYNAMIC', readEnv('NEXT_PUBLIC_ANALYTICS_ID')) }, []) return <div>env inlining probe</div>}หลัง next build grep chunk ฝั่ง client ที่ generate มาดูทั้งสอง call site เห็นตัว static ถูกแทนที่ด้วยค่าจริงจาก .env.local เลย ส่วนตัว dynamic ยังเป็น property read สด ๆ (และบน browser คือค่าว่างเปล่า):
console.log("STATIC","abcdefghijk123"),console.log("DYNAMIC",i.default.env.NEXT_PUBLIC_ANALYTICS_ID)i.default.env.NEXT_PUBLIC_ANALYTICS_ID คือ property access บน process shim ฝั่ง client ตอน runtime ไม่ใช่ literal — และ shim นั้นมีอยู่ก็เพื่อให้ reference ที่ ตรวจจับได้แบบ static เท่านั้น resolve ได้ ส่วน key ที่คำนวณตอน runtime ไม่เคยถูกตรวจจับ มันเลย resolve เป็น undefined บน browser การห่อ access เดียวกันนี้ไว้ใน helper function ที่รับ key เป็น parameter ก็เพียงพอที่จะทำให้การแทนที่ล้มเหลว ถึงแม้ helper จะถูกเรียกด้วย string literal เดิมทุกครั้งก็ตาม
ไฟล์ .env และลำดับการโหลด
หัวข้อที่มีชื่อว่า “ไฟล์ .env และลำดับการโหลด”Next.js โหลดไฟล์ .env หลายตัวและตัวที่ match ก่อนชนะ สำหรับ production ลำดับคือ .env.production.local แล้ว .env.local แล้ว .env.production แล้ว .env เก็บ secret ไว้ใน .env.local (gitignore) แล้ว commit เฉพาะ default ที่ปลอดภัย
.env # committed defaults, safe values only.env.local # gitignored, real secrets.env.development # loaded during next dev.env.production # loaded during next build / next startEnv var ใน proxy และ instrumentation
หัวข้อที่มีชื่อว่า “Env var ใน proxy และ instrumentation”proxy.ts กับ instrumentation.ts เป็นแค่ Node.js module ธรรมดาที่รันฝั่ง server ดังนั้นมันอ่าน process.env เหมือน Server Component ทั่วไป ไม่มี case พิเศษ และตัวแปร NEXT_PUBLIC_ ก็ใช้ได้ที่นี่เหมือนกัน (แค่ไม่เคยถูก inline เข้า client bundle สำหรับไฟล์พวกนี้ เพราะมันไม่ได้ถูกส่งไป browser)
// instrumentation.ts — env vars are available in register() as-isexport function register() { console.log('runtime:', process.env.NEXT_RUNTIME) // 'nodejs' or 'edge'}พิสูจน์ใน probe: start standalone server แล้ว log ออกมาว่า INSTRUMENTATION_REGISTER_CALLED nodejs — register() รันครั้งเดียวต่อ server instance ก่อนที่มันจะเริ่มรับ traffic โดยที่ process.env ฝั่ง server เต็มตัวถูก populate ไว้แล้ว
Build-time vs Runtime config
หัวข้อที่มีชื่อว่า “Build-time vs Runtime config”ค่า NEXT_PUBLIC_ ถูก freeze ทันทีที่ next build รัน การ promote build เดียวกันข้าม environment (หรือ Docker image เดียวกันข้าม stage) แปลว่าค่าพวกนั้นติดไปกับ image ไม่ใช่ติดกับ environment ที่มันไปลง สำหรับค่าที่ต้องอ่านใหม่ทุก request ให้อ่านตัวแปร server-only ข้างใน component ที่ opt-in dynamic rendering แล้ว — เรียก connection() (หรืออ่าน cookies()/headers()) จะบังคับแบบนั้น:
// app/page.tsx — read a server env var at request time, not at build timeimport { connection } from 'next/server'
export default async function Page() { await connection() // cookies, headers, and other request-time APIs also opt into // dynamic rendering, so this is evaluated per request, not baked in const value = process.env.MY_VALUE return <div>{value}</div>}นี่คือเหตุผลตรง ๆ ว่าทำไม Docker image เดียวที่ใช้ output: 'standalone' ถึง promote ผ่าน staging กับ production ได้โดยไม่ต้องแก้อะไร: env var แบบ server-only ถูกอ่านสด ๆ จาก environment ของ container ตอน runtime ส่วนตัวที่มี NEXT_PUBLIC_ เท่านั้นที่ถูกล็อกไว้ตั้งแต่ build ที่สร้าง image นั้น
ของสำคัญใน next.config.ts
หัวข้อที่มีชื่อว่า “ของสำคัญใน next.config.ts”next.config.ts คือที่ที่เรา config ตัว framework เอง สี่ตัวที่ใช้บ่อยสุดคือ images.remotePatterns (อนุญาต image host ภายนอก), redirects, rewrites และ headers
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 's3.amazonaws.com', pathname: '/my-bucket/**' }, ], }, async redirects() { return [{ source: '/about', destination: '/', permanent: true }] }, async rewrites() { return [{ source: '/docs/:slug', destination: '/help/:slug' }] }, async headers() { return [ { source: '/(.*)', headers: [{ key: 'X-Frame-Options', value: 'DENY' }], }, ] },}
export default nextConfigMental Model
หัวข้อที่มีชื่อว่า “Mental Model”นึกภาพ next build เป็นแท่นพิมพ์ ไม่ใช่เครื่องถ่ายเอกสาร อะไรก็ตามที่ mark ด้วย NEXT_PUBLIC_ และเข้าถึงแบบ literal จะถูกเรียงพิมพ์ลงหน้ากระดาษตรง ๆ — หมึกถาวร เปลี่ยนไม่ได้หลังพิมพ์เสร็จ ส่วนที่เหลือคือช่องว่างที่รอเติมตอนมี request จริง อ่านจาก environment ที่ container นั้นอาศัยอยู่ ณ ตอนนั้น การ lookup แบบ dynamic (process.env[key]) คือช่องว่างที่แท่นพิมพ์ไม่รู้ว่าต้องเรียงพิมพ์ — มันไม่ใช่ secret รั่ว มันแค่ไม่เคยถูกเติมให้คนอ่าน (browser) เท่านั้นเอง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
เข้าถึง NEXT_PUBLIC_ แบบ static literal | ค่าไปถึง browser ได้ ไม่มี cost lookup ตอน runtime | ถูก freeze ตอน build — เปลี่ยนค่าต้อง rebuild |
เข้าถึง process.env[key] แบบ dynamic | ดูยืดหยุ่น อ่านเหมือน object lookup ธรรมดา | ไม่เคยถูก inline ให้ client — เป็น undefined แบบเงียบ ๆ บน browser |
อ่าน server-only var หลัง connection() | สดใหม่ทุก request, image เดียว promote ข้าม environment ได้ | route ถูก opt-in dynamic rendering — ไม่ใช่ static shell อีกต่อไป |
.env.local สำหรับ secret | ไม่ถูก commit, override .env ในเครื่องได้ | ต้อง provision แยกในทุก environment (CI, container) เอง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”process.env.NEXT_PUBLIC_Xแบบไหนก็ปลอดภัยเหมือนกันหมด — มันปลอดภัยจากการเป็น secret รั่ว แค่นั้น แต่การ inline เกิดเฉพาะ form แบบ static เป๊ะ ๆ ตัวแปรเดียวกันที่เข้าถึงแบบ dynamic จะคืนค่าundefinedแบบเงียบ ๆ บน browser แทนprocess.env[key]กับprocess.env.KEYทำงานเหมือนกันหลัง bundle เสร็จ — พิสูจน์แล้วว่าไม่จริงnext buildแทนที่เฉพาะ form ที่เป็น literal-property เท่านั้น form แบบ bracket (หรือ indirection ผ่าน helper function ใด ๆ) มองไม่เห็นโดยตัวแทนที่นี้เลย- เปลี่ยนค่า
NEXT_PUBLIC_ใน.env.localแล้ว restartnext startจะได้ค่าใหม่ — ไม่จริง ค่าNEXT_PUBLIC_ถูก inline ตอนnext buildไม่ใช่อ่านตอนnext startต้อง rebuild ใหม่เท่านั้น - server-only env var ที่อ่านใน page จะเป็น “runtime” และสดเสมอโดยอัตโนมัติ — สดก็ต่อเมื่อ route นั้น opt-in dynamic rendering (ผ่าน
connection(),cookies(),headers()หรือคล้ายกัน) เท่านั้น route ที่ static เต็มตัวจะ evaluate การอ่านนั้นแค่ครั้งเดียวตอน build/prerender
graph TD A["process.env.SECRET"] --> B["Server only"] C["process.env.NEXT_PUBLIC_ID (literal)"] --> B C --> D["Inlined into client bundle at build time"] E["process.env[dynamicKey]"] --> B E -.->|never inlined| F["undefined in the browser"]
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ snippet ที่ generate มาแตะ env var หรือ config ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- Client Component (หรืออะไรก็ตามที่ถูกส่งไป browser) เข้าถึง
process.envผ่านตัวแปร, key ที่คำนวณมา หรือ helper function แทนที่จะเป็น literalprocess.env.NEXT_PUBLIC_Xตรง ๆ หรือเปล่า? แบบนั้นจะไม่ถูก inline และคืนค่าundefinedแบบเงียบ ๆ ตอน runtime - มีตัวแปรที่ไม่มี prefix
NEXT_PUBLIC_ถูกส่งเข้า Client Component เป็น prop หรือแทรกเข้าไปใน JS ฝั่ง client หรือเปล่า? นั่นคือ secret รั่วผ่านประตูอีกบาน — กฎเรื่อง prefix ป้องกันแค่การเข้าถึงprocess.envโดยตรงเท่านั้น - โค้ดสมมติว่าเปลี่ยน
.env.localแล้ว restartnext start(โดยไม่ rebuild) จะได้ค่าNEXT_PUBLIC_ใหม่หรือเปล่า? ไม่ได้ — ค่าพวกนั้นถูก inline ตอนnext build - page อ่าน server-only env var แล้วสมมติว่ามันสดทุก request โดยไม่มี request-time API (
connection(),cookies(),headers()) บังคับ dynamic rendering เลยหรือเปล่า? route ที่ static จะฝังการอ่านนั้นไว้แค่ครั้งเดียว next.config.tsอ้างอิงprocess.envสำหรับค่าที่ควรต่างกันในแต่ละ deployment ของ image เดียวกัน (เช่น self-host ด้วยoutput: 'standalone') หรือเปล่า? ค่าใน config ก็ถูก evaluate ตอน build เหมือนกัน เว้นแต่จะ defer ไว้ชัดเจน
🐛 หา bug ในโค้ดนี้
'use client'
const PUBLIC_KEYS = {analytics: 'NEXT_PUBLIC_ANALYTICS_ID',region: 'NEXT_PUBLIC_REGION',}
function getPublicEnv(name: keyof typeof PUBLIC_KEYS) {return process.env[PUBLIC_KEYS[name]]}
export function Analytics() {const id = getPublicEnv('analytics')return <span data-analytics-id={id} />}เฉลย
getPublicEnv อ่าน process.env[PUBLIC_KEYS[name]] — bracket access ผ่าน indirection สองชั้น ถึงแม้ PUBLIC_KEYS.analytics จะเป็น string literal 'NEXT_PUBLIC_ANALYTICS_ID' ตลอด แต่การแทนที่ตอน build ของ Next.js match แค่ source text ที่ตรงเป๊ะ ๆ คือ process.env.NEXT_PUBLIC_ANALYTICS_ID มันไม่เห็น pattern นี้เลย ดังนั้นไม่มีอะไรถูก inline — data-analytics-id จะ render เป็น undefined ให้ผู้ใช้ทุกคน โดยไม่มี build error หรือ warning ใด ๆ ทั้งสิ้น ทางแก้คือ reference ตัวแปร NEXT_PUBLIC_ แต่ละตัวตรง ๆ แบบ literal ที่ทุก call site ในโค้ดฝั่ง client ที่ต้องการมัน