Written & drawn bycreaml4tt3© 2026

Home/Blogs/ทำ infinite scroll แบบ masonry ใน Next.js อย่างไร? ใช้ masonic กับ cursor pagination

ทำ infinite scroll แบบ masonry ใน Next.js อย่างไร? ใช้ masonic กับ cursor pagination

PersonalPublished 31 Oct 2026
ทำ infinite scroll แบบ masonry ใน Next.js อย่างไร? ใช้ masonic กับ cursor pagination

In short

วิธีทำฟีดข่าวแบบ masonry ที่โหลดต่อเรื่อยๆ ใน Next.js จากเว็บเลือกตั้ง กทม. 2569 ของไทยรัฐ: หน้าแรกเรนเดอร์บนเซิร์ฟเวอร์, cursor แยกต่อแท็บ และ useInfiniteLoader ของ masonic

On this page

9 sections
  1. 01ทำไมเลือก masonic แทนการเขียน masonry เอง?
  2. 02โครงสร้างหน้าแบ่งงานระหว่างเซิร์ฟเวอร์กับ client อย่างไร?
  3. 03useInfiniteLoader ของ masonic ทำงานอย่างไร แทน IntersectionObserver?
  4. 04Cursor pagination แยกต่อแท็บต้องจัดการอะไรบ้าง?
  5. 05ทำให้การ์ดสูงต่ำสลับกันสวยและไม่กระโดดได้อย่างไร?
  6. 06ใช้คอมโพเนนต์เดียวทั้งหน้าแรกและหน้าฟีดได้อย่างไร?
  7. 07ถ้าทำใหม่ จะแก้อะไร?
  8. 08FAQ
    1. ต้องใช้ IntersectionObserver ทำ infinite scroll ไหม?
    2. ทำไมต้องโหลด Masonry ด้วย ssr: false?
    3. Cursor pagination ดีกว่า page number ตรงไหน?
    4. ฟีดแบบนี้ดีต่อ SEO ไหม?
    5. ฟีดต้องรีเฟรชถี่แค่ไหน?
  9. 09สรุป
สรุปสั้นๆ:

ฟีดข่าวของเว็บเลือกตั้ง กทม. 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 ของหน้านี้

หน้า /news ของเว็บเลือกตั้ง กทม. 2569 แสดงฟีดข่าวแบบ masonry การ์ดสูงต่ำสลับกัน

ทำไมเลือก masonic แทนการเขียน masonry เอง?

ฟีดนี้มีการ์ดสามแบบที่สูงไม่เท่ากัน คือข่าว (ภาพแนวนอน), วิดีโอ และ Shorts (แนวตั้ง 9:16) ถ้าใช้ CSS columns ลำดับจะไหลจากบนลงล่างทีละคอลัมน์ ไม่ใช่ซ้ายไปขวา และโหลดต่อแล้วการ์ดเดิมจะกระโดด masonic แก้สองเรื่องนี้ในตัว คือวางการ์ดลงคอลัมน์ที่สั้นที่สุด และ

virtualize

เรนเดอร์เฉพาะการ์ดที่อยู่ใกล้หน้าจอ ฟีดที่ยาวเป็นร้อยการ์ดจึงไม่หนักขึ้นเรื่อยๆ

โครงสร้างหน้าแบ่งงานระหว่างเซิร์ฟเวอร์กับ client อย่างไร?

  1. 1.
    หน้า /news เป็น Server Component

    แบ่งเป็นสามส่วน (ข่าวเด่น, คู่มือเลือกตั้ง, ฟีด) แต่ละส่วนเป็น async Server Component ห่อด้วย Suspense ที่มี skeleton ของตัวเอง ส่วนไหนเสร็จก่อนก็แสดงก่อน

    <Suspense fallback={<FeedSkeleton />}>
      <FeedSection />
    </Suspense>
  2. 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. 3.
    คอมโพเนนต์ client โหลด Masonry ด้วย next/dynamic แบบ ssr: false

    เพราะ masonic ต้องรู้ขนาดหน้าจอจริงในการวาง ส่วน hook useInfiniteLoader import ตรงๆ ได้ตามปกติ

    const Masonry = dynamic(
      () => import('masonic').then((m) => m.Masonry),
      { ssr: false }
    )
แผนภาพการโหลดฟีด: เซิร์ฟเวอร์เรนเดอร์ 18 การ์ดแรกใน Suspense, client ใช้ rendered range ของ masonic ผ่าน onRender และ useInfiniteLoader, การ์ดถัดไปที่ยังไม่โหลดจะเรียก fetch ด้วย cursor และแต่ละแท็บ (All, News, Shorts, Video) มี cursor ของตัวเอง

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. 1.
    จังหวะสูงต่ำ

    ฟังก์ชันเล็กๆ คำนวณจากลำดับการ์ดและจำนวนคอลัมน์ว่าการ์ดไหนเป็นแบบสูง เป็นรูปแบบที่วนทุกสี่แถว ฟีดจึงดูมีจังหวะไม่สุ่ม

  2. 2.
    จำนวนคอลัมน์ตามความกว้างจริง

    ResizeObserver วัดความกว้างของกล่องฟีด แล้วคำนวณจำนวนคอลัมน์จากความกว้างคอลัมน์กับระยะห่าง จำกัดไว้ที่ 2–6 คอลัมน์

  3. 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.js

Last verified: 31 ตุลาคม 2026 จาก source code ของโปรเจกต์ (Next.js 16.2, React 19.2, masonic 4.1)

แหล่งอ้างอิง:

  • masonic บน GitHub
  • Next.js: Lazy loading (next/dynamic)
  • Next.js: Loading UI and streaming
  • React: Suspense
  • MDN: ResizeObserver
#nextjs#react#infinite-scroll#masonry-layout#masonic#cursor-pagination#server-components#suspense#react-server-components#resizeobserver#virtualization#frontend#performance#ux#case-study#ทำ-infinite-scroll#ฟีดข่าว#เลือกตั้ง-กทม-2569#ไทยรัฐ#เขียนเว็บ-nextjs
Teekayu Poongkawabutr (Pon)
Teekayu Poongkawabutr (Pon)(Creaml4tt3)

Developer ท่านหนึ่งที่ทำงานบ้าง ไม่ทำงานบ้าง ทำงานเป็น dev แต่จบ graphic ชีวิตสับสน website: creaml4tt3.me

Further Reading

Related Blogs

ทำ API ผลเลือกตั้งเรียลไทม์ให้รับ traffic สูงอย่างไร? Redis cache และรับมือ 429

ทำ API ผลเลือกตั้งเรียลไทม์ให้รับ traffic สูงอย่างไร? Redis cache และรับมือ 429

เบื้องหลัง API ผลเลือกตั้งผู้ว่าฯ กทม. 2569 ของไทยรัฐ: Redis cache ที่ใช้ key จาก URL, TTL ตามประเภทข้อมูล, สลับ API key เมื่อเจอ 429 และสวิตช์ให้บรรณาธิการตรวจก่อนเผยแพร่

31 Oct 2026
Next.js View Transitions ใช้ยังไง? พร้อมตัวอย่างจริง

Next.js View Transitions ใช้ยังไง? พร้อมตัวอย่างจริง

ทำให้ Next.js ของคุณลื่นเหมือน native app ด้วย View Transitions API พร้อมวิธีเปิดใช้งาน, ทำ global transition และทำ shared element animation สำหรับ blog image แบบ production-ready

03 May 2026
Next.js SEO Checklist 2026: 12 เทคนิคเพิ่มอันดับ Google สำหรับเว็บไซต์ Next.js

Next.js SEO Checklist 2026: 12 เทคนิคเพิ่มอันดับ Google สำหรับเว็บไซต์ Next.js

รวม Next.js SEO Checklist 2026 ตั้งแต่ Metadata API, Open Graph, Sitemap, Robots.txt, JSON-LD และ Core Web Vitals เพื่อช่วยให้เว็บไซต์ติดอันดับ Google ได้ดีขึ้น

02 Jun 2026
Husky + lint-staged: ทำให้ Git Commit สะอาดขึ้นแบบอัตโนมัติ

Husky + lint-staged: ทำให้ Git Commit สะอาดขึ้นแบบอัตโนมัติ

ตั้งค่า Git workflow ให้สะอาดและอัตโนมัติด้วย Husky + lint-staged พร้อมสอนติดตั้ง วิธีใช้งาน ข้อดีข้อเสีย และ optional setup สำหรับ formatter/linter อย่าง Prettier และ Biome

10 May 2026

More Field Notes

Latest Blogs

View all
ทำ API ผลเลือกตั้งเรียลไทม์ให้รับ traffic สูงอย่างไร? Redis cache และรับมือ 429Personal

ทำ API ผลเลือกตั้งเรียลไทม์ให้รับ traffic สูงอย่างไร? Redis cache และรับมือ 429

เบื้องหลัง API ผลเลือกตั้งผู้ว่าฯ กทม. 2569 ของไทยรัฐ: Redis cache ที่ใช้ key จาก URL, TTL ตามประเภทข้อมูล, สลับ API key เมื่อเจอ 429 และสวิตช์ให้บรรณาธิการตรวจก่อนเผยแพร่

31 Oct 2026
9Router vs claude-code-router vs OmniRoute vs CLIProxyAPI vs LiteLLM เลือกตัวไหนดี (2026)Personal

9Router vs claude-code-router vs OmniRoute vs CLIProxyAPI vs LiteLLM เลือกตัวไหนดี (2026)

เทียบ AI router 5 ตัวสำหรับ Claude Code และ Codex ทั้งวิธียืนยันตัวตน ความเสี่ยงเรื่อง ToS ฟีเจอร์ทีม ประวัติช่องโหว่ และความยากในการติดตั้ง พร้อมสรุปว่าแบบไหนควรใช้ตัวไหน

30 Oct 2026
9Router ปลอดภัยไหม? รวมช่องโหว่ปี 2026 และ checklist ตั้งค่าให้ปลอดภัยก่อนใช้จริงPersonal

9Router ปลอดภัยไหม? รวมช่องโหว่ปี 2026 และ checklist ตั้งค่าให้ปลอดภัยก่อนใช้จริง

9Router มีประกาศช่องโหว่ 19 รายการในปี 2026 ใช้ต่อได้ไหม? สรุปว่าช่องโหว่แต่ละแบบแก้ในเวอร์ชันไหน อะไรที่ยังอยู่ใน code ล่าสุด และ checklist 12 ข้อสำหรับตั้งค่าให้ปลอดภัย

30 Oct 2026