# Infinite Scroll Feed A feed page that shows the most recent posts at the top, prepends new posts live as they arrive, and pages older posts in as the user scrolls down. Three primitives compose: a `LiveTable` keeps the first page live (broadcasts new inserts), an `@rpc` returns older pages one at a time, and an `IntersectionObserver` on a sentinel element at the bottom of the feed fires the next-page rpc when it scrolls into view. The recipe uses a single posts table. Newest posts go in via `LiveTable.insert`; older posts come back via SQL with `where createdAt < `. The cursor is the `createdAt` of the oldest post the browser currently has, so paging never duplicates or skips rows even if new posts arrive during a long scroll. ## Migration ```bash elements create migration 'add posts' -tables=posts ``` `app/migrations/-add-posts.migration.sql`: ```sql -- add posts -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table posts ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), userName text not null, body text not null ); create index postsCreatedAtIdx on posts (createdAt desc); create trigger postsTouchUpdatedAt before update on posts for each row execute function touchUpdatedAt(); ``` `postsCreatedAtIdx` is the index the feed query reads off. Without it, every "load more" scan would walk the table. ## Page setup ```bash elements create page feed ``` The `Post` interface, the live first page, and the `loadOlder` rpc are used only by the `/feed` page, so they live in the page's own `services.ts`. The route passes the LiveTable; the template renders the live and older slices in one continuous scroll with an intersection-observer sentinel that fires `loadOlder` as the user scrolls. `app/pages/feed/services.ts`: ```ts import { LiveTable, sql } from "@elements/app"; export interface Post { id: string; createdAt: Date; userName: string; body: string; } const PAGE_SIZE = 20; export let posts = new LiveTable({ sort: "createdAt desc", select: () => sql( `select id, createdAt, userName, body from posts order by createdAt desc limit ${PAGE_SIZE}`, ), }); /** @rpc */ export function loadOlder(before: Date): Post[] { return sql( `select id, createdAt, userName, body from posts where createdAt < ${before} order by createdAt desc limit ${PAGE_SIZE}`, ).all(); } ``` The custom `select` on the LiveTable returns the most recent `PAGE_SIZE` posts on first render. After that, `LiveTable.insert` broadcasts new rows to every browser watching, and each browser prepends them to its in-memory view; the table grows past the initial limit as new posts arrive. `loadOlder(before)` takes the cursor (the oldest post the browser currently shows) and returns the next batch of older posts. Cursor-based paging is reliable in a real-time feed: inserts at the top don't shift older offsets, so a user scrolling down never sees a duplicate or a gap from new posts arriving while they read. `app/pages/feed/index.ts`: ```ts import feed from "./template"; import { posts } from "./services"; export default function route(req, res) { return new feed({ posts }); } ``` The route passes the LiveTable. The first page is server-rendered into the HTML, so the first paint shows posts. The browser receives a live, subscribed handle to the same table. `app/pages/feed/template.html`: ```html import "./style.css"; import { LiveTable } from "@elements/app"; import { Post, loadOlder } from "./services"; function onSentinelInsert(el: HTMLElement, posts: LiveTable, older: { value: Post[] }, done: { value: boolean }) { let observer = new IntersectionObserver((entries) => { let entry = entries[0]; if (!entry) { return; } if (!entry.isIntersecting) { return; } onLoadMore(posts, older, done); }); observer.observe(el); } function onLoadMore(posts: LiveTable, older: { value: Post[] }, done: { value: boolean }) { if (done.value) { return; } let cursor = older.value.length > 0 ? older.value[older.value.length - 1].createdAt : posts.length > 0 ? posts.at(posts.length - 1)!.createdAt : new Date(); let batch = loadOlder(cursor); if (batch.length === 0) { done.value = true; return; } older.value = [...older.value, ...batch]; } function onCompose(posts: LiveTable, userName: string, body: { value: string }) { if (userName.trim().length === 0) { return; } if (body.value.trim().length === 0) { return; } posts.insert( { userName: userName.trim(), body: body.value }, () => body.value = "", ); } , private userName: string = "", private body: { value: string } = { value: "" }, private older: { value: Post[] } = { value: [] }, private done: { value: boolean } = { value: false })>

feed

onCompose(posts, userName, body)}>
  • {p.userName} {p.body}
  • {p.userName} {p.body}
onSentinelInsert(e.target as HTMLElement, posts, older, done)}> loading more…

you've reached the bottom.

``` Two iterations in the feed list: the LiveTable holds the most-recent slice (live, auto-prepended), and `older` holds the older pages loaded on demand. Concatenating them in render order keeps the visual order continuous. `onSentinelInsert` runs on the sentinel `
`'s `oninsert` and attaches an `IntersectionObserver`. When the sentinel scrolls into view, the observer fires `onLoadMore`. The sentinel disappears once `done` flips, which also stops the observer naturally because the element is removed from the DOM. `onLoadMore` picks the cursor from the oldest row currently on the page: the last entry in `older` if any have loaded, otherwise the last entry in `posts`. An empty result means the feed is exhausted and the sentinel hides. The composer at the top uses `posts.insert(...)`. The optimistic local insert shows the new post immediately; the broadcast then reaches every other browser's LiveTable and prepends there too. ## Routes Register the page in `index.ts`: ```ts import feed from "#app/pages/feed"; // ... app.route("/feed", feed); ``` ## Notes - **Cursor over offset.** Using `where createdAt < cursor` rather than `offset N` means a flood of new inserts at the top during a long scroll doesn't shift the older boundary. The user keeps reading the same older posts they expected. - **Live + paged in one feed.** The LiveTable always holds the live, recent slice. The `older` array holds the static pages. Concatenating them in the template gives one continuous scroll. New inserts from `posts.insert(...)` only affect the live half. - **Auth.** This recipe is open-access for clarity. To require sign-in, add `insert: { auth: (item, s) => s.isLoggedIn() }` to the LiveTable config and `session.isLoggedInOrThrow()` at the top of the route handler. - **Server-side rendering of the first page.** The route serializes the LiveTable's initial result into the HTML response, so the first paint shows posts. No empty state, no fetching spinner on mount, no layout shift. - **Resetting the observer.** The `e:if={!done.value}` on the sentinel unmounts it when the feed ends. `IntersectionObserver` instances tied to unmounted elements are eligible for GC; explicit `observer.disconnect()` isn't required for the recipe pattern.