เว็บนี้เป็น static site ล้วน ๆ — Astro build ออกมาเป็นไฟล์ HTML แล้วโยนขึ้น Vercel
ไม่มีเซิร์ฟเวอร์ ไม่มีฐานข้อมูล ไม่มี API สักตัว
ปัญหาคือเว็บที่ไม่มี backend ก็ไม่มีที่รับข้อมูล พอจะทำ 3 อย่างนี้ก็ติดหมด:
- อยากรู้ว่ามีคนเข้าเว็บไหม
- อยากให้คนสมัครรับจดหมายข่าว
- อยากให้คนติดต่อกลับได้
ทางออกที่ใช้คือ ไม่ต้องมี backend ของตัวเอง แต่ไปยืมของคนอื่น ฟอร์มบนเว็บยิงตรงไปที่บริการภายนอก
แล้วบริการนั้นเป็นคนเก็บข้อมูลและส่งอีเมลแจ้งเรา
ในกรอบเส้นประคือทั้งหมดที่เราดูแลเอง — ไฟล์ HTML ล้วน ไม่มีเซิร์ฟเวอร์ ไม่มีฐานข้อมูล งานที่ต้องมีคนรับข้อมูลถูกส่งข้ามกรอบออกไปทั้งหมด
บทความนี้บันทึกว่าผมเลือกอะไร เพราะอะไร ต้องเอาอะไรมาแปะบ้าง และเจอกับดักอะไรระหว่างทาง
บริการที่ใช้จริง
| งาน | บริการ | ค่าใช้จ่ายตอนเริ่ม | ต้องเอาอะไรมาแปะ |
|---|
| Analytics | Umami Cloud | Hobby plan ฟรี | website ID |
| จดหมายข่าว | Buttondown | ฟรี 100 subscribers แรก | username |
| ฟอร์มติดต่อ | Formspree | ฟรี 50 submissions/เดือน | form endpoint URL |
ทั้งสามตัวเริ่มได้โดยไม่ต้องผูกบัตร และไม่ต้องเขียนโค้ดฝั่งเซิร์ฟเวอร์เลย
Umami — analytics ที่ไม่ต้องขึ้น cookie banner
เลือกเพราะเป็น cookieless — ไม่ฝัง cookie ไม่เก็บข้อมูลส่วนบุคคล เลยไม่ต้องมีแถบขออนุญาต cookie
ให้คนกดรำคาญ และตัวโปรเจกต์เป็น open source ถ้าวันหนึ่งไม่อยากใช้ cloud ก็ย้ายไป self-host ได้
วิธีขอ: สมัครที่ cloud.umami.is → Add website → ใส่ชื่อกับโดเมน
จากนั้นหน้า Tracking code จะให้ script tag มาแบบนี้
<script defer src="https://cloud.umami.is/script.js" data-website-id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"></script>
ที่ต้องเก็บไว้คือค่าใน data-website-id
ตัวเลข event ต่อเดือนของ Hobby plan ผมยืนยันตัวเลขไม่ได้ (หน้า pricing เป็น JavaScript ล้วน ดึงมาอ่านตรง ๆ ไม่ได้)
เอกสารระบุแค่ว่า Hobby plan ฟรีและเหมาะกับโปรเจกต์ส่วนตัวหรือเว็บทราฟฟิกไม่สูง
ถ้าต้องการตัวเลขแน่นอนให้ดูที่ หน้า pricing โดยตรง
เลือกเพราะฟรีช่วงเริ่มต้น เขียนด้วย Markdown ได้ และมีหน้า archive ให้อัตโนมัติ
วิธีขอ: สมัครที่ buttondown.com → ตั้ง username ของ newsletter
→ หน้า Embedding จะให้ฟอร์มตัวอย่างมา หน้าตาแบบนี้
<form action="https://buttondown.com/api/emails/embed-subscribe/USERNAME" method="post">
<input type="email" name="email" />
<input type="submit" value="Subscribe" />
</form>
ที่ต้องเก็บคือ URL ใน action ทั้งเส้น ไม่ใช่แค่ username
ราคา: ฟรีสำหรับ 100 subscribers แรก เกินจากนั้นคิดตามจำนวน
ส่วนฟีเจอร์เสริมแยกขายเป็นรายตัว เริ่มที่ $9/เดือน (เช่น tagging, segmentation, analytics)
ไปจนถึง $29 และ $79/เดือน สำหรับของอย่าง custom domain archive หรือ whitelabel
(รายละเอียด)
ข้อดีคือช่วงเริ่มต้นที่ยังไม่มีคนอ่าน ไม่ต้องจ่ายอะไรเลย
เลือกเพราะติดตั้งง่ายที่สุดในสามตัว แค่เปลี่ยน action ของฟอร์มเป็น URL ที่เขาให้มา ก็จบ
วิธีขอ: สมัครที่ formspree.io → New Form → ตั้งชื่อและใส่อีเมลที่จะให้ส่งเข้า
จะได้ endpoint มาหน้าตาแบบนี้
https://formspree.io/f/xxxxxxxx
ข้อจำกัดของ free plan: 50 submissions ต่อเดือน, สร้างฟอร์มได้ไม่จำกัด,
เก็บประวัติย้อนหลัง 30 วัน, ผูกอีเมลรับได้ 2 address
(รายละเอียด)
50 ครั้งต่อเดือนสำหรับเว็บ portfolio ถือว่าพอ และเขาจะส่งเมลเตือนเมื่อใช้ถึง 50%, 75% และ 90%
วิธีต่อเข้าเว็บ: อย่า hardcode
จุดที่อยากเน้นคือ อย่าเอาค่าพวกนี้ไปเขียนแปะไว้ในโค้ดตรง ๆ ให้อ่านจาก environment variable แทน
ในเว็บนี้ใช้ astro:env ประกาศ schema ไว้ที่ astro.config.ts ทุกตัวเป็น optional
PUBLIC_NEWSLETTER_PROVIDER: envField.enum({
context: 'client',
access: 'public',
values: ['buttondown', 'beehiiv', 'convertkit', 'custom'],
optional: true,
}),
ได้ประโยชน์ 3 อย่าง:
1. เปลี่ยนเจ้าได้โดยไม่แตะโค้ด — ถ้าวันหนึ่งย้ายจาก Buttondown ไป ConvertKit
แก้แค่ค่าใน environment variable ตัว component ไม่ต้องแก้เลย เพราะชื่อ field ที่แต่ละเจ้าต้องการ
ถูก map ไว้ในที่เดียว
2. เว็บ build ผ่านแม้ยังไม่ได้ต่ออะไรเลย — ตอนที่ยังไม่ใส่ค่า ฟอร์มจะไม่หายไปเฉย ๆ
แต่จะแสดงข้อความบอกว่ายังไม่ได้เชื่อมต่อ ดีกว่าปล่อยให้ฟอร์มดูเหมือนใช้ได้แต่ข้อมูลหายจริง
3. ไม่มีความลับหลุด — ค่าพวกนี้ขึ้นต้นด้วย PUBLIC_ หมด เพราะมันถูก compile
เข้าไปในไฟล์ที่ browser โหลด แปลว่าห้ามเอา API key หรือ secret มาใส่ตรงนี้เด็ดขาด
ทั้งสามค่าที่ใช้อยู่เป็นข้อมูลที่เปิดเผยได้อยู่แล้ว (website ID, username, form URL)
ไฟล์ .env หน้าตาสุดท้ายเป็นแบบนี้
PUBLIC_ANALYTICS_PROVIDER=umami
PUBLIC_ANALYTICS_SITE_ID=<website-id-จาก-umami>
PUBLIC_NEWSLETTER_PROVIDER=buttondown
PUBLIC_NEWSLETTER_ENDPOINT=https://buttondown.com/api/emails/embed-subscribe/<username>
PUBLIC_CONTACT_ENDPOINT=https://formspree.io/f/<form-id>
กับดัก 3 ข้อที่เจอมาเอง
1. endpoint ต้องเป็น URL เต็ม ไม่งั้นข้อมูลหายเงียบ
อันนี้เจ็บที่สุด ตอนแรกผมเอา id ที่ copy มาใส่ตรง ๆ แบบนี้
PUBLIC_NEWSLETTER_ENDPOINT=2517b869-6aa5-4ff5-ba18-a45871477602
โค้ดเช็คแค่ว่า “มีค่าไหม” ไม่ได้เช็คว่า “เป็น URL ไหม” มันเลยผ่านเป็นสถานะเชื่อมต่อแล้ว
แล้ว build ออกมาเป็น
<form action="2517b869-6aa5-4ff5-ba18-a45871477602" method="POST">
action ที่ไม่ขึ้นต้นด้วย https:// browser จะตีความเป็น path บนเว็บเราเอง
คนกดสมัครก็จะถูกส่งไปที่หน้าที่ไม่มีอยู่จริง เจอ 404 และอีเมลหายไปเฉย ๆ
อาการนี้แย่กว่าตอนที่ยังไม่ได้ต่ออะไรเลย เพราะหน้าเว็บดูปกติทุกอย่าง ไม่มีอะไรฟ้อง
กฎง่าย ๆ: ทุก endpoint ต้องขึ้นต้นด้วย https:// ถ้าที่ copy มาเป็น id เปล่า ๆ แปลว่ายังไม่ครบ
2. ใส่ค่าในเครื่องอย่างเดียวไม่พอ
.env อยู่ใน .gitignore — ซึ่งถูกแล้ว ไม่ควร commit ขึ้น git
แต่ผลคือ โฮสต์มองไม่เห็นค่าพวกนี้เลย ต้องไปใส่ในหน้า Environment Variables ของโฮสต์อีกรอบ
และเพราะค่า PUBLIC_* ถูกฝังเข้าไฟล์ตอน build ใส่เฉย ๆ แล้วไม่ deploy ใหม่จะไม่มีผล
ผมเจอเองตอนไปเช็คเว็บจริงแล้วพบว่าไม่มี script ของ analytics โผล่มาสักตัว
3. CSP บล็อกสคริปต์ที่เพิ่งใส่
ถ้าเว็บมี Content Security Policy อยู่ การเพิ่ม analytics เจ้าใหม่ต้องเปิดโดเมนของเขาใน CSP ด้วย
ทั้งใน script-src (สำหรับโหลดสคริปต์) และ connect-src (สำหรับตอนสคริปต์ส่งข้อมูลกลับ)
ที่ต้องระวังคือบางเจ้า โหลดสคริปต์จากโดเมนหนึ่ง แต่ส่งข้อมูลกลับไปอีกโดเมนหนึ่ง
ถ้าเปิดแค่โดเมนแรกจะกลายเป็นสคริปต์โหลดได้แต่ส่งข้อมูลไม่ออก — เงียบสนิท ไม่มี error ให้เห็นชัด ๆ
กรณีของ Umami คือตัวอย่างของกับดักนี้เต็ม ๆ และตอนเขียนบทความนี้รอบแรกผมเขียนตรงนี้ผิด —
ผมสรุปไปเองว่าโหลดสคริปต์กับส่งข้อมูลอยู่โดเมนเดียวกัน เลยไม่ได้แก้ CSP อะไรเพิ่ม
ความจริงคือสคริปต์โหลดจาก cloud.umami.is แต่ ส่ง event ไปที่ gateway.umami.is คนละโดเมนกัน
พอ connect-src เปิดไว้แค่โดเมนแรก เบราว์เซอร์ก็บล็อกทุก request ที่ส่งออก
ผลคือหน้าเว็บปกติดีทุกอย่าง สคริปต์โหลดสำเร็จ ตรวจด้วย curl ก็เห็น data-website-id ครบ
แต่ dashboard ไม่ขึ้นสักตัวเลข เพราะไม่มีข้อมูลออกไปถึงเลยสักครั้งเดียว
จับได้ตอนเปิดเว็บจริงด้วยเบราว์เซอร์แล้วดู console เจอบรรทัดนี้
Connecting to 'https://gateway.umami.is/api/send' violates the following
Content Security Policy directive: "connect-src 'self' ... https://cloud.umami.is"
วิธีดูว่าเจ้าที่ใช้อยู่ส่งข้อมูลไปโดเมนไหน คืออ่านจากตัวสคริปต์เขาตรง ๆ
curl -s https://cloud.umami.is/script.js | grep -o 'gateway\.umami\.is'
แล้วเติมโดเมนที่ได้เข้าไปใน connect-src ไม่ใช่แค่ script-src
ตรวจว่าติดจริงไหม อย่าเดา
บทเรียนที่ได้จากรอบนี้คือ ต้องไปดูเว็บจริง ไม่ใช่ดูแค่ในเครื่องแล้วเชื่อว่าน่าจะขึ้น
ตรวจว่า analytics ติดแล้วหรือยัง
curl -s https://<โดเมนคุณ>/ | grep -o 'data-website-id="[^"]*"'
ตรวจว่าฟอร์มชี้ไปถูกที่
curl -s https://<โดเมนคุณ>/contact | grep -o '<form[^>]*>'
สิ่งที่ควรเห็นคือ action ที่ขึ้นต้นด้วย https:// และเป็นโดเมนของบริการที่ต่อไว้
ถ้าเห็นเป็น path สั้น ๆ หรือ id เปล่า ๆ แปลว่าโดนกับดักข้อ 1 เข้าแล้ว
ส่วนที่ curl ตรวจแทนไม่ได้คือการกดส่งจริง — ต้องลองสมัครด้วยอีเมลตัวเองแล้วดูว่าชื่อขึ้นในระบบไหม
และลองส่งฟอร์มติดต่อแล้วดูว่าเมลเข้า inbox จริงหรือเปล่า
อีกอย่างที่ curl ตรวจไม่ได้เลยคือ CSP เพราะ curl ดาวน์โหลด HTML มาเฉย ๆ ไม่ได้บังคับใช้ CSP
เหมือนเบราว์เซอร์ กับดักข้อ 3 ข้างบนผมเลยหลุดรอดมาได้ทั้งที่ curl บอกว่าทุกอย่างเรียบร้อย
ต้องเปิดเว็บจริงด้วยเบราว์เซอร์แล้วดู console ถึงจะเห็น
สรุป
เว็บ static ทำงานพวกนี้ได้ครบโดยไม่ต้องมีเซิร์ฟเวอร์ของตัวเอง แค่เปลี่ยนวิธีคิดจาก
“ฉันต้องเขียน API มารับ” เป็น “ฉันส่งต่อให้คนที่ทำเรื่องนี้อยู่แล้ว”
สิ่งที่อยากให้จำกลับไป 3 ข้อ:
- ให้ค่าทุกอย่างมาจาก environment variable เปลี่ยนเจ้าได้ทีหลังโดยไม่ต้องรื้อโค้ด
- endpoint ต้องเป็น URL เต็ม ไม่งั้นฟอร์มจะพังแบบไม่มีอะไรฟ้อง
- เช็คบนเว็บจริงด้วยเบราว์เซอร์จริง
curl บอกได้แค่ว่า HTML ถูกต้องไหม
แต่บอกไม่ได้ว่าเบราว์เซอร์ยอมให้ส่งข้อมูลออกจริงหรือเปล่า
ค่าใช้จ่ายรวมตอนเริ่มต้นคือ ศูนย์บาท — และจะเริ่มจ่ายก็ต่อเมื่อมีคนอ่านมากพอจนเกิน free tier
ซึ่งถึงตอนนั้นก็เป็นปัญหาที่น่ามีมากกว่า