ฟีดข่าวของเว็บเลือกตั้ง กทม. 2569 ของไทยรัฐเป็น masonry ที่โหลดต่อเรื่อยๆ สร้างด้วย Next.js กับไลบรารี masonic หน้าแรกของฟีดดึงบนเซิร์ฟเวอร์ใน Server Component ที่ห่อด้วย Suspense หลังจากนั้น useInfiniteLoader ของ masonic จะเรียกหน้าถัดไปด้วย cursor เมื่อเลื่อนใกล้ท้าย โดยดูจากรายการที่ masonry เรนเดอร์อยู่ ไม่ต้องใช้ IntersectionObserver ทั้งสี่แท็บ (ทั้งหมด, ข่าว, Shorts, Video) เก็บรายการและ cursor แยกกัน (ข้อมูล ณ ตุลาคม 2026)
ฟีดนี้อยู่ในหน้า /news ของ
เว็บเลือกตั้งไทยรัฐและใช้คอมโพเนนต์เดียวกันแบบไม่โหลดต่อในหน้าแรกด้วย หน้า /news ไม่ใช่หน้าที่คนเข้าเยอะที่สุดของเว็บ บทความนี้จึงไม่ใช่เรื่อง scale แต่เป็นเทคนิคที่หยิบไปใช้ได้กับฟีดแบบ masonry ทุกแบบ ผมเขียนส่วนฟีดและ logic การโหลดต่อ ทีมช่วยเรื่อง UI, animation และ SEO ของหน้านี้

ทำไมเลือก masonic แทนการเขียน masonry เอง?
ฟีดนี้มีการ์ดสามแบบที่สูงไม่เท่ากัน คือข่าว (ภาพแนวนอน), วิดีโอ และ Shorts (แนวตั้ง 9:16) ถ้าใช้ CSS columns ลำดับจะไหลจากบนลงล่างทีละคอลัมน์ ไม่ใช่ซ้ายไปขวา และโหลดต่อแล้วการ์ดเดิมจะกระโดด masonic แก้สองเรื่องนี้ในตัว คือวางการ์ดลงคอลัมน์ที่สั้นที่สุด และ
virtualizeเรนเดอร์เฉพาะการ์ดที่อยู่ใกล้หน้าจอ ฟีดที่ยาวเป็นร้อยการ์ดจึงไม่หนักขึ้นเรื่อยๆ
โครงสร้างหน้าแบ่งงานระหว่างเซิร์ฟเวอร์กับ client อย่างไร?
- 1.หน้า /news เป็น Server Component
แบ่งเป็นสามส่วน (ข่าวเด่น, คู่มือเลือกตั้ง, ฟีด) แต่ละส่วนเป็น async Server Component ห่อด้วย Suspense ที่มี skeleton ของตัวเอง ส่วนไหนเสร็จก่อนก็แสดงก่อน
<Suspense fallback={<FeedSkeleton />}> <FeedSection /> </Suspense> - 2.หน้าแรกของฟีดดึงบนเซิร์ฟเวอร์
18 รายการ แล้วส่งทั้งข้อมูลและ query ให้คอมโพเนนต์ client
export async function FeedSection() { const feedsQuery = { feedLimit: 18 } const feeds = await feedsApi(null, feedsQuery) return <NewsFeed feedsQuery={feedsQuery} feeds={feeds} /> } - 3.คอมโพเนนต์ client โหลด Masonry ด้วย next/dynamic แบบ ssr: false
เพราะ masonic ต้องรู้ขนาดหน้าจอจริงในการวาง ส่วน hook useInfiniteLoader import ตรงๆ ได้ตามปกติ
const Masonry = dynamic( () => import('masonic').then((m) => m.Masonry), { ssr: false } )

useInfiniteLoader ของ masonic ทำงานอย่างไร แทน IntersectionObserver?
masonic รู้อยู่แล้วว่าตอนนี้เรนเดอร์การ์ดช่วงไหน useInfiniteLoader อาศัยข้อมูลนี้ ส่ง callback ที่ได้ให้ prop onRender ของ Masonry แล้วทุกครั้งที่ช่วงที่เรนเดอร์ขยับ มันจะเช็กว่าใกล้รายการที่ยังไม่โหลดหรือยัง ไม่ต้องวาง element ไว้ท้ายฟีดแล้วคอยสังเกต
const maybeLoadMore = useInfiniteLoader(loadMore, {
isItemLoaded: (index, items) => index < items.length,
minimumBatchSize: 1,
threshold: 2,
totalItems: hasNext ? items.length + 1 : items.length,
})
<Masonry
items={items}
render={Card}
onRender={maybeLoadMore}
columnWidth={140}
overscanBy={200}
/>จุดสำคัญคือ
totalItemsถ้ายังมีหน้าถัดไป ให้บอกว่ามีมากกว่าที่ถืออยู่หนึ่งรายการ loader จะได้รู้ว่ามีของที่ยังไม่โหลดรออยู่ พอหมดแล้วให้บอกจำนวนจริง มันจะหยุดเรียกเอง ส่วน overscanBy ที่ตั้งไว้สูงทำให้ masonic เรนเดอร์ล่วงหน้าเยอะ ผู้อ่านที่เลื่อนเร็วจึงไม่ค่อยเห็นช่องว่าง (ผมเพิ่มค่านี้ในรอบปรับจูนปลายเดือนพฤษภาคม)
Cursor pagination แยกต่อแท็บต้องจัดการอะไรบ้าง?
API ฟีดคืนรายการพร้อม pagination ที่มี hasMore และ nextCursor การโหลดหน้าถัดไปคือการส่ง query เดิมพร้อม cursor
const loadMore = async () => {
if ((!pagination.hasMore && !pagination.nextCursor) || isLoading) return
await fetchMore({ ...feedsQuery, cursor: pagination.nextCursor })
}ทั้งสี่แท็บใช้ endpoint ต่างกัน และ
แต่ละแท็บเก็บรายการกับ pagination ของตัวเองสลับแท็บไปมาแล้วกลับมา รายการเดิมยังอยู่ และแท็บที่ยังว่างจะโหลดหน้าแรกเองเมื่อถูกเลือกครั้งแรก
ปัญหาที่เจอจริงคือ request ซ้ำ ระหว่างที่หน้าถัดไปกำลังโหลด masonic อาจเรียก loadMore อีกรอบ ผมกันด้วย Set ใน useRef ที่จำว่าแท็บไหนกำลังโหลดอยู่ ใช้ ref แทน state เพราะต้องเห็นค่าล่าสุดทันทีโดยไม่ต้องรอ re-render
const inFlightTabs = useRef(new Set())
async function fetchMore(query) {
const tab = activeTab
if (inFlightTabs.current.has(tab)) return
inFlightTabs.current.add(tab)
try {
const page = await feedsApi(tab, query)
appendItems(tab, page)
} finally {
inFlightTabs.current.delete(tab)
}
}Masonry ยังได้ key ที่ผูกกับแท็บ สลับแท็บแล้ว masonic จะเริ่มวางใหม่ ไม่เอาตำแหน่งของแท็บเก่ามาใช้
ทำให้การ์ดสูงต่ำสลับกันสวยและไม่กระโดดได้อย่างไร?
- 1.จังหวะสูงต่ำ
ฟังก์ชันเล็กๆ คำนวณจากลำดับการ์ดและจำนวนคอลัมน์ว่าการ์ดไหนเป็นแบบสูง เป็นรูปแบบที่วนทุกสี่แถว ฟีดจึงดูมีจังหวะไม่สุ่ม
- 2.จำนวนคอลัมน์ตามความกว้างจริง
ResizeObserver วัดความกว้างของกล่องฟีด แล้วคำนวณจำนวนคอลัมน์จากความกว้างคอลัมน์กับระยะห่าง จำกัดไว้ที่ 2–6 คอลัมน์
- 3.ประมาณความสูงให้ใกล้ความจริง
itemHeightEstimate คิดจากสัดส่วนภาพของการ์ดที่มีอยู่ วิดีโอและ Shorts จะวัดสัดส่วนภาพจริงตอนภาพโหลด แล้วใช้กรอบแนวตั้งหรือแนวนอนตามนั้น ค่าประมาณที่ใกล้ความจริงทำให้ masonic วางการ์ดถูกตั้งแต่แรก การ์ดไม่กระโดด
ใช้คอมโพเนนต์เดียวทั้งหน้าแรกและหน้าฟีดได้อย่างไร?
คอมโพเนนต์มี prop loadmore ในหน้าแรกตั้งเป็น false แล้วมันจะเรนเดอร์เป็น grid ธรรมดาที่ไม่มี virtualization พร้อมปุ่ม "ดูทั้งหมด" ไปหน้า /news ส่วนหน้า /news ใช้แบบ Masonry ที่โหลดต่อได้ การ์ด, การคำนวณขนาด และการโหลดรูปใช้โค้ดชุดเดียวกัน
ถ้าทำใหม่ จะแก้อะไร?
- กันรายการซ้ำ
ตอนนี้หน้าใหม่ถูกต่อท้ายตรงๆ ถ้ามีข่าวใหม่เข้ามาระหว่างที่ผู้อ่านเลื่อน cursor อาจคืนรายการที่เคยเห็นแล้ว แก้ง่ายๆ ด้วยการกรองตาม id ก่อนต่อท้าย
setItems((prev) => { const seen = new Set(prev.map((i) => i.id)) return [...prev, ...page.items.filter((i) => !seen.has(i.id))] }) - คืนตำแหน่งเลื่อน
กดเข้าข่าวแล้วกดย้อนกลับ ฟีดจะเริ่มจากหน้าแรกใหม่ ครั้งหน้าผมจะเก็บรายการ, cursor และตำแหน่งเลื่อนไว้ใน sessionStorage แล้วคืนค่าเมื่อกลับมา
- แสดงสถานะตอนโหลดท้ายฟีด
ระหว่างดึงหน้าถัดไปควรมี skeleton เล็กๆ ท้ายฟีด ผู้อ่านจะได้รู้ว่ายังมีต่อ
FAQ
ต้องใช้ IntersectionObserver ทำ infinite scroll ไหม?
ไม่จำเป็นถ้าใช้ไลบรารีที่ virtualize อยู่แล้วอย่าง masonic เพราะมันรู้อยู่แล้วว่าเรนเดอร์การ์ดช่วงไหน useInfiniteLoader อ่านข้อมูลนั้นผ่าน onRender ได้เลย ถ้าเป็นรายการธรรมดาที่ไม่ virtualize IntersectionObserver ยังเป็นวิธีที่ง่ายที่สุด
ทำไมต้องโหลด Masonry ด้วย ssr: false?
masonic วางการ์ดจากขนาดหน้าจอและความกว้างจริงของกล่อง ซึ่งไม่มีบนเซิร์ฟเวอร์ ส่วนข้อมูลหน้าแรกยังดึงบนเซิร์ฟเวอร์ตามปกติ และ Suspense แสดง skeleton ระหว่างรอ ผู้อ่านจึงเห็นโครงหน้าทันที
Cursor pagination ดีกว่า page number ตรงไหน?
ฟีดข่าวมีของใหม่เข้ามาด้านบนตลอด ถ้าใช้เลขหน้า ข่าวใหม่หนึ่งชิ้นจะดันทุกอย่างลงไปหนึ่งตำแหน่ง หน้าถัดไปจะมีรายการซ้ำหรือขาดหาย cursor ชี้ไปที่ "ต่อจากชิ้นนี้" จึงคงที่กว่า แต่ก็ควรกันรายการซ้ำฝั่ง client ไว้ด้วย
ฟีดแบบนี้ดีต่อ SEO ไหม?
หน้าแรกของฟีดเรนเดอร์บนเซิร์ฟเวอร์ search engine จึงเห็น 18 รายการแรกใน HTML ส่วนที่โหลดต่อด้วย JavaScript ไม่ควรพึ่งให้ถูก index ถ้าต้องการให้ทุกข่าวถูกค้นเจอ ควรมี sitemap หรือหน้ารายการแบบมีลิงก์แยก ดูเพิ่มใน
checklist SEO สำหรับ Next.jsฟีดต้องรีเฟรชถี่แค่ไหน?
ฝั่ง API ของโปรเจกต์นี้ cache ฟีดไว้ 10 นาทีบนเซิร์ฟเวอร์ ข่าวเลือกตั้งไม่จำเป็นต้องสดระดับวินาที ดูวิธีตั้ง cache ได้ใน
บทความ API ผลเลือกตั้งสรุป
✅ หน้าแรกเรนเดอร์บนเซิร์ฟเวอร์ใน Suspense ส่วน Masonry โหลดฝั่ง client
✅ useInfiniteLoader + onRender แทน IntersectionObserver โดยตั้ง totalItems ให้มากกว่าที่มีหนึ่งรายการตราบที่ยังมีหน้าถัดไป
✅ แยกรายการและ cursor ต่อแท็บ และกัน request ซ้ำด้วย useRef
✅ ประมาณความสูงจากสัดส่วนภาพจริง การ์ดไม่กระโดด
❌ ยังขาดการกันรายการซ้ำและการคืนตำแหน่งเลื่อน ซึ่งเป็นสองอย่างแรกที่จะเพิ่ม
👉 อ่านต่อ:
API ผลเลือกตั้งเบื้องหลังฟีดนี้,
ภาพรวมโปรเจกต์และ
ทำ animation ให้ลื่นแบบแอปด้วย View Transitions ใน Next.jsLast verified: 31 ตุลาคม 2026 จาก source code ของโปรเจกต์ (Next.js 16.2, React 19.2, masonic 4.1)
แหล่งอ้างอิง:



