Docs
Views

Feed view

Show a view as a feed of posts, loaded page by page from your server, newest first.

The Feed view works like Notion's Feed: a view of the table shows each record as a post in a centered column (720px wide at most), with its title, author and date, a body, media and a row of properties. Posts are loaded page by page from your server, newest first, and follow the view's search, filters and grouping. React and Vue behave the same.

Enable

The Feed mode ships in the table items and adds no dependency. List "feed" in displayModes:

feed-config.ts
export const updatesConfig = defineTableConfig({
  ...projectConfig,
  table: {
    ...projectConfig.table,
    displayModes: ["table", "feed"],
    feed: {
      titleColumn: "name",
      authorColumn: "author",
      dateColumn: "postedAt",
      bodyColumn: "update",
      propertyColumnIds: ["status", "category", "dueDate"],
    },
  },
});

table.feed is optional: an object of default settings (the shape below) that every Feed view starts from, plus the renderBody hook, or false to turn the mode off. With false, "feed" is not offered even if listed in displayModes, and a link asking for it falls back to the default mode. A renderer passed as displayModeRenderers.feed replaces the built-in one.

Try the feed

Each post is a project's latest update, newest first and five per page: the title, the owner with initials, a relative date, a body clamped to three lines with Show more, and the status, category and start date. Open View settings › Card settings to change the columns, the number of lines or the page size. The preview uses the store-opening projects shared by every view guide; see the example setup for project-config.ts and the in-memory host.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Posts

Each post shows:

  • Title: a button that opens the record view, like a row click. Empty titles read "Untitled".

  • Author: a text value, or a person-like value { name, avatarUrl } (label or email also work as the name). Without an avatar, the author's initials are shown.

  • Date: relative by default in the table locale ("3 hr. ago", "yesterday", "il y a 3 h"), with the full date on hover and in a <time datetime> element. Date-only values such as 2026-09-24 compare by local calendar day. Set dateDisplay: "absolute" to show the full date instead.

  • Body: plain text with its line breaks kept, clamped to bodyLines lines. Show more and Show less are buttons (with aria-expanded), offered only when the body overflows, so they work from the keyboard.

  • Media: up to four images from the media column (URLs, lists of URLs or { url, name, type } objects, with the same safe URL rule as the gallery), with a "+N" count for the rest. Other files are listed as links. Images load lazily in fixed-ratio boxes (loading="lazy", width and height), so nothing shifts when they arrive. Videos show their poster with native controls, never autoplay and load nothing before they play. When table.gallery.media.enabled covers the feed's media column, the source it resolves is used.

  • Properties: option columns as tags (colored-tags settings apply), numbers and dates formatted like the table, safe links, and text otherwise. Empty values are left out.

When a view is grouped (one level), posts are shown in sections under the group's label and count, in order of appearance.

Feed settings

Choose Feed under View settings › Display mode, then open Card settings in the same menu. Settings are saved with the view (config.feed) and in the <tableId>-feed URL key, for example ?projects-display=feed&projects-feed={"bodyLines":8}. Reset returns to table.feed, then to the defaults below.

SettingDefaultDescription
titleColumnFirst text columnThe post title.
authorColumnGuessed from the column name (author, owner, created by…)The author. null shows none.
dateColumnGuessed from the column name (posted, published, created…), else the first date columnThe publication date, also used to sort the feed. null shows none.
dateDisplay"relative""relative" ("3 hr. ago", full date on hover) or "absolute" (full date).
bodyColumnGuessed from the column name (body, content, update, description…) among text columnsThe post body. null shows none.
mediaColumnFirst image or files columnImages and files shown under the body. null shows none.
propertyColumnIdsOption columns (select, multi-select, tags)Columns shown at the bottom of each post, in order. The title, author, date, body, media and grouped columns are never repeated.
showPropertyLabelsfalseShow each property's column name before its value.
bodyLines4Lines of body shown before Show more, 0 to 20. 0 always shows the full text. The panel offers 2, 3, 4, 6, 8, 12 and full text.
density"comfortable""comfortable" or "compact" cards.
pageSize10Posts requested per page, 1 to 100. The panel offers 5, 10, 20 and 50.
infiniteScrolltrueLoad the next page when the end of the feed comes within one screen of the viewport. false keeps Load more only.

Columns that no longer exist are ignored and guessed again.

table.feed.windowing is a table option, never saved in views. Past 60 loaded posts (or the number you set), only the posts near the viewport are rendered; the others keep their measured height, so the scroll position holds. The focused post and posts playing a video stay rendered. false renders every post, for example so the browser's find in page sees them all.

Loading from your server

The feed asks actions.list for one page at a time, with the view's query (search, filters, advanced filters) and:

  • page (starting at 1) and pageSize (also sent as limit);

  • when the view has no sort of its own, the date column newest first: sorting: [{ id: dateColumn, desc: true }] and orderBy: { [dateColumn]: "desc" };

  • grouping: [columnId] when the view is grouped.

{
  search: "",
  filters: {},
  sorting: [{ id: "postedAt", desc: true }],
  orderBy: { postedAt: "desc" },
  page: 2,
  pageSize: 10,
  limit: 10,
}

Return only that page's rows in data, as in the list response, with meta.pageCount or meta.totalCount so the feed knows when it has reached the end. Without either, a page shorter than pageSize ends the feed. A list that returns every row at once shows them all on the first page.

server/list-updates.ts
export async function listUpdates(params: Record<string, unknown>) {
  const page = Math.max(1, Number(params.page) || 1);
  const pageSize = Math.min(100, Math.max(1, Number(params.pageSize) || 10));
  const [rows, totalCount] = await Promise.all([
    db.updates.findMany({
      where: buildWhere(params), // your list filtering
      orderBy: buildOrderBy(params.sorting), // e.g. postedAt desc
      skip: (page - 1) * pageSize,
      take: pageSize,
    }),
    db.updates.count({ where: buildWhere(params) }),
  ]);
  return {
    data: rows,
    meta: { pageCount: Math.ceil(totalCount / pageSize), totalCount },
  };
}

By default (infiniteScroll), the next page loads when the end of the feed comes within one screen of the viewport, or of the nearest scrolling ancestor such as a dashboard widget. Pages load one request at a time, without repeating records already shown. Load more stays as the keyboard and assistive-technology fallback: it is in the tab order, shows when focused, and is visible when infiniteScroll is false, without IntersectionObserver and after an error. Each loaded page is announced, for example "10 more posts loaded, 30 shown." A failed page keeps the posts shown, says so and pauses loading until Retry. At the end the feed reads "You're all caught up". After a create, edit or delete, the pages shown so far are reloaded. Without a list action, the table's local rows are paged and sorted the same way.

While the first page loads, skeleton posts are shown; an empty result shows the table's empty state, and a failed first page shows an error with Retry. The Feed view's code loads with the first feed shown (React lazy, Vue defineAsyncComponent), with the skeleton as the fallback.

Markdown and HTML bodies

Bodies are plain text by default, and nothing is ever inserted as HTML. To render markdown or rich text, set table.feed.renderBody(value, row): React returns a node, Vue a VNode or a string. The rendered body is clamped by height with the same Show more toggle. renderBody is a runtime hook: it is never saved in views or URLs.

If you render HTML, sanitize it first. Body values come from your records, so unsanitized HTML would let anyone who can edit a record inject scripts into every reader's page. This example parses markdown with marked and cleans the result with DOMPurify:

feed-body.tsx
import DOMPurify from "dompurify";
import { marked } from "marked";

function renderBody(value: unknown) {
  const markdown = typeof value === "string" ? value : "";
  // Sanitize before rendering: record content is untrusted.
  const html = DOMPurify.sanitize(marked.parse(markdown, { async: false }));
  return (
    <div className="prose prose-sm" dangerouslySetInnerHTML={{ __html: html }} />
  );
}

export const updatesConfig = defineTableConfig({
  ...projectConfig,
  table: {
    ...projectConfig.table,
    displayModes: ["table", "feed"],
    feed: { bodyColumn: "update", renderBody },
  },
});
feed-body.ts
import DOMPurify from "dompurify";
import { marked } from "marked";
import { h } from "vue";

function renderBody(value: unknown) {
  const markdown = typeof value === "string" ? value : "";
  // Sanitize before rendering: record content is untrusted.
  const html = DOMPurify.sanitize(marked.parse(markdown, { async: false }));
  return h("div", { class: "prose prose-sm", innerHTML: html });
}

export const updatesConfig = defineTableConfig({
  ...projectConfig,
  table: {
    ...projectConfig.table,
    displayModes: ["table", "feed"],
    feed: { bodyColumn: "update", renderBody },
  },
});

A markdown component that renders no raw HTML, such as react-markdown without rehype-raw, needs no sanitizer: return it directly from renderBody.

Translations

The feed has built-in English and French labels; French is used when the locale starts with fr. Override any of them with flat feed.<key> keys in the table's translations, in React and Vue, for example "feed.loadMore": "Older posts".

  • Feed: showMore, showLess, loadMore, loading, loadingMore, error, loadMoreError, retry, end, loadedOne (with {total}), loadedMany (with {count} and {total}), untitled, noValue, yes, no, moreImages (with {count}), by, media and video (with {title}).

  • Settings: titleColumn, authorColumn, dateColumn, dateDisplay, relative, absolute, bodyColumn, mediaColumn, properties, showPropertyLabels, bodyLines, lines (with {count}), noLimit, density, comfortable, compact, pageSize, infiniteScroll, on, off, none.

The Reset button reads common.reset. The mode's name in the display mode picker is views.display.feed in React and display.feed in Vue.

Compatibility notes

TableDisplayMode now includes "feed". If your code keeps an exhaustive Record<TableDisplayMode, …> map, such as icons or labels per mode, add a feed entry so it keeps compiling:

const modeLabels: Record<TableDisplayMode, string> = {
  // ...
  feed: "Feed",
};

Renderer contexts gain groupBy, which custom display mode renderers can use the same way.