Documentation

Public lists

Build a public VitNode Content Engine list page with search, filters, sorting and pagination kept in the URL, using listFields, defineContentList and the ContentList components.

A public list is a page such as /discussions that shows published records a page at a time, with search, filters and sorting in its URL. The Content Engine provides the data and the list behavior; your plugin defines the route, the item layout and the words. This guide builds the example plugin's discussion list: search by title, a board filter, three sorts, ten discussions a page, and links to each discussion.

The Content Engine providesYour plugin defines
The list route GET /content/{publicApi.path} with paging, search, filters, locale fallback, caching and cache tagsThe page() route and where it lives
Only published, already-live records. Drafts and scheduled records never appear, and hidden ones only for their authors and editorsWhich sorts and filters the URL accepts
defineContentList, contentListQueryOptions, contentListPageHeadThe fetcher call and the item layout
ContentListResults, ContentListPagination, useContentListSearchInputThe search field, the selects and every label

The Content Engine never creates list pages on its own, and it ships no sort or filter controls. You add a list where it belongs and draw the controls you want.

Before you begin

You need a content type with a public API and pages for its records, such as public pages. This guide uses example.discussion from the example plugin, which members write through public write.

Choose what the list can read

Tell the public API which fields a list row carries and what readers may search, sort and filter by:

plugins/example/src/content/discussion.ts
publicApi: {
  path: "discussions",
  fields: ["id", "title", "slug", "body", "board", "publishedAt"],
  listFields: ["id", "title", "slug", "board", "publishedAt"],
  filterableFields: ["board"],
  searchableFields: ["title"],
  orderableFields: ["title"],
  people: ["author"],
},
  • listFields must be a subset of fields. The list route selects only these, so a page of ten discussions never loads ten rich text bodies. The detail route still returns every field in fields.
  • searchableFields takes text, textarea and slug fields. orderableFields adds sorts; publishedAt is always orderable.
  • filterableFields adds one query parameter per field. A container field such as board also accepts includeDescendants=true.
  • people: ["author"] adds a small public profile of each discussion's author to every row. A user field never appears through fields.

These options change the API, not the table, so there is nothing to migrate. Every option is in the publicApi reference.

Declare the list and its query

Create src/pages/discussion-list.ts. defineContentList from @vitnode/core/tanstack/content names the sorts, the URL filters and the page size once. contentListQueryOptions turns one URL state into TanStack Query options. Write the fetcher call inline in fetch, so the route stays typed:

plugins/example/src/pages/discussion-list.ts
import type { QueryClient } from "@tanstack/react-query";
import type { ContentListSearch } from "@vitnode/core/tanstack/content";

import {
  contentListIdFilter,
  contentListQueryOptions,
  defineContentList,
} from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";

import { manifest } from "@/manifest.gen";

export const DISCUSSIONS_HREF = "/discussions";

export const discussionList = defineContentList({
  defaultSort: "newest",
  filters: { board: contentListIdFilter },
  pageSize: 10,
  sorts: {
    newest: { order: "desc", orderBy: "publishedAt" },
    oldest: { order: "asc", orderBy: "publishedAt" },
    title: { order: "asc", orderBy: "title" },
  },
});

export type DiscussionListSearch = ContentListSearch<
  (typeof discussionList.sorts)[number],
  { board: typeof contentListIdFilter }
>;

export const discussionListSearch = (
  input: Record<string, unknown>,
): DiscussionListSearch => discussionList.parseSearch(input);

const discussionQueryKeys = {
  all: [manifest.id, "discussions"] as const,
};

export const discussionsQueryOptions = (request: {
  locale: string;
  search: DiscussionListSearch;
}) =>
  contentListQueryOptions({
    ...request,
    queryKey: discussionQueryKeys.all,
    fetch: async ({ search }) => {
      const res = await fetcher({
        plugin: manifest.id,
        method: "get",
        module: "content/discussions",
        path: "/",
        args: {
          query: {
            ...discussionList.apiQuery(search),
            ...(search.board === undefined
              ? {}
              : { board: String(search.board), includeDescendants: "true" }),
          },
        },
      });
      if (res.status !== 200) {
        throw new Error(`Loading discussions answered ${res.status}.`);
      }

      return await res.json();
    },
  });

export const invalidateDiscussionLists = async (queryClient: QueryClient) => {
  await queryClient.invalidateQueries({ queryKey: discussionQueryKeys.all });
};
  • The URL keys are q, sort, page and one per filter, here board. Each orderBy must be in publicApi.orderableFields or be publishedAt.
  • apiQuery turns the URL state into first, page, order, orderBy and search. Filters are yours to map, because a URL name and an API name can differ.
  • The response is typed from the content type. Each row carries exactly listFields plus author, with dates as ISO strings. There is nothing to re-validate.
  • invalidateDiscussionLists expires every cached page of the list. The create and edit pages call it after a save. See Public create form.

A localized content type also passes locale in the query, so each language gets its own response and cache entry. Discussions are not localized, so the locale only keys the cache.

Add the route

Register the page with the list's search validator and a skeleton for the first load:

plugins/example/src/routes.ts
import { FeedPendingSkeleton } from "@vitnode/core/tanstack/pending";

import { discussionListSearch } from "./pages/discussion-list";

export const routes = definePluginRoutes([
  page("/discussions", {
    component: lazy(() => import("./pages/discussions-page")),
    messages: ["@vitnode/example.discussions"],
    pendingComponent: FeedPendingSkeleton,
    search: discussionListSearch,
  }),
]);

messages loads the @vitnode/example.discussions namespace for the page, its head and every component inside it. pendingComponent shows while the loader runs on a client-side navigation. A server render waits for the loader instead.

Load the page and build its head

Create src/pages/discussions-page.tsx. The loader warms the query, so the server renders the list and the browser starts with it:

plugins/example/src/pages/discussions-page.tsx
import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client";
import { contentListPageHead } from "@vitnode/core/tanstack/content";
import {
  defineRoute,
  type RouteLoadContext,
} from "@vitnode/core/tanstack/plugin-routes";

import {
  DISCUSSIONS_HREF,
  type DiscussionListSearch,
  discussionsQueryOptions,
} from "./discussion-list";
import { boardsQueryOptions } from "./example-queries";

const loadDiscussions = async (
  queryClient: RouteLoadContext["queryClient"],
  { locale, search }: { locale: string; search: DiscussionListSearch },
) => {
  // A failed list is an error state on the page, not an error page.
  const [discussions, boards] = await Promise.all([
    queryClient
      .query({
        ...discussionsQueryOptions({ locale, search }),
        meta: LOADER_DATA_QUERY_META,
      })
      .catch(() => null),
    queryClient.query(boardsQueryOptions()).catch(() => []),
  ]);

  return { boards, discussions };
};

type DiscussionsPageData = Awaited<ReturnType<typeof loadDiscussions>>;

export const route = defineRoute<DiscussionsPageData, DiscussionListSearch>({
  load: async ({ context, search }) =>
    await loadDiscussions(context.queryClient, {
      locale: context.locale,
      search,
    }),

  head: ({ search, t }) =>
    contentListPageHead({
      description: t("@vitnode/example.discussions.list.desc"),
      pathname: DISCUSSIONS_HREF,
      search,
      title: t("@vitnode/example.discussions.list.title"),
    }),
});
  • LOADER_DATA_QUERY_META keeps the result out of the dehydrated cache, because the loader data already carries it to the browser.
  • .catch(() => null) turns a failed request into the list's error state instead of the app's error page.
  • contentListPageHead gives the plain list index, follow and a canonical link, and every searched, filtered, sorted or paged view noindex, follow.
  • boardsQueryOptions is the example's own query for the board picker. It is not part of the list.

Render the controls and the results

Add the component to the same file. ContentListResults decides between the loading, error, empty, no-results and item states. ContentListPagination renders page links from pageInfo:

plugins/example/src/pages/discussions-page.tsx
const DiscussionsPage = ({
  loaderData: { boards, discussions },
  navigate,
  search,
}: PluginRoutePageProps<DiscussionsPageData, DiscussionListSearch>) => {
  const t = useTranslations("@vitnode/example.discussions.list");
  const [isPending, startTransition] = React.useTransition();
  const update = (patch: Partial<DiscussionListSearch>) => {
    startTransition(async () => {
      await navigate({
        resetScroll: false,
        search: discussionList.withSearch(search, patch),
      });
    });
  };
  const input = useContentListSearchInput({
    onCommit: q => {
      update({ q });
    },
    value: search.q,
  });

  return (
    <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
      <div className="flex flex-col gap-3 md:flex-row" role="search">
        <Input
          aria-label={t("search_label")}
          onChange={event => {
            input.setValue(event.target.value);
          }}
          onKeyDown={event => {
            if (event.key === "Enter") input.commit();
          }}
          placeholder={t("search_placeholder")}
          type="search"
          value={input.value}
        />
        <NativeSelect
          aria-label={t("board_label")}
          onChange={event => {
            const board = Number(event.target.value);
            update({ board: board > 0 ? board : undefined });
          }}
          value={String(search.board ?? "")}
        >
          <NativeSelectOption value="">{t("all_boards")}</NativeSelectOption>
          {boards.map(board => (
            <NativeSelectOption key={board.id} value={String(board.id)}>
              {board.name}
            </NativeSelectOption>
          ))}
        </NativeSelect>
        <NativeSelect
          aria-label={t("sort_label")}
          onChange={event => {
            update({
              sort: discussionList.sorts.find(
                sort => sort === event.target.value,
              ),
            });
          }}
          value={search.sort ?? discussionList.defaultSort}
        >
          {discussionList.sorts.map(sort => (
            <NativeSelectOption key={sort} value={sort}>
              {t(`sort.${sort}`)}
            </NativeSelectOption>
          ))}
        </NativeSelect>
      </div>

      <ContentListResults
        className="flex flex-col divide-y rounded-lg border"
        empty={<ListState title={t("empty.title")} />}
        error={<ListState title={t("error.title")} />}
        getKey={discussion => discussion.id}
        isError={discussions === null}
        isFetching={isPending}
        isFiltered={discussionList.isFiltered(search)}
        itemClassName="flex flex-col gap-2 p-4 md:flex-row md:justify-between"
        items={discussions?.edges}
        label={t("title")}
        loading={null}
        noResults={
          <ListState
            action={<HrefLink href={DISCUSSIONS_HREF}>{t("no_results.clear")}</HrefLink>}
            title={t("no_results.title")}
          />
        }
        renderItem={discussion =>
          discussion.slug === null ? (
            <span>{discussion.title}</span>
          ) : (
            <HrefLink href={discussionHref(discussion.slug)}>
              {discussion.title}
            </HrefLink>
          )
        }
      />

      {discussions ? (
        <ContentListPagination
          hrefFor={page =>
            discussionList.href(DISCUSSIONS_HREF, { ...search, page })
          }
          label={t("pagination")}
          pageInfo={discussions.pageInfo}
        />
      ) : null}
    </div>
  );
};

export default DiscussionsPage;

The excerpt trims styling, the author line and the per-row actions. ListState is a small wrapper around the Empty components from @vitnode/core/components/ui/empty. The full file is plugins/example/src/pages/discussions-page.tsx.

  • withSearch returns the new URL state and drops page when anything else changes, because page 2 of the old results says nothing about the new ones.
  • useContentListSearchInput waits 500 ms after the last keystroke before it navigates, commits at once on Enter, and follows the URL when the reader goes back or forward.
  • navigate inside startTransition keeps the current rows on screen, dimmed and aria-busy, while the next page loads.
  • empty shows when nothing is published and noResults when a search or filter matches nothing, so only the second one offers Clear filters.
  • loading renders while items is undefined. This page always has data from its loader, so it passes null; a list that fetches in the browser shows a skeleton there.
  • Link each row to its record's page with the same helper that page's route uses, here discussionHref(slug). A row whose slug is null has no page to link to.
The discussion list with the search term kangaroo and the Help & questions board selected, showing No discussions match and a Clear filters button

To put Edit and Report buttons on each row, see Public edit form and Report content.

Add the messages

Every label lives in the plugin's locale file, under the namespace the route loads:

plugins/example/src/locales/en.json
{
  "@vitnode/example": {
    "discussions": {
      "list": {
        "title": "Discussions",
        "desc": "Questions, ideas and show-and-tell from the community, newest first.",
        "search_label": "Search discussions",
        "search_placeholder": "Search by title",
        "board_label": "Board",
        "all_boards": "All boards",
        "sort_label": "Sort by",
        "sort": { "newest": "Newest", "oldest": "Oldest", "title": "Title (A-Z)" },
        "pagination": "Discussion pages",
        "empty": { "title": "No discussions yet" },
        "no_results": { "title": "No discussions match", "clear": "Clear filters" },
        "error": { "title": "Couldn't load discussions" }
      }
    }
  }
}

How URL parameters, locales and pagination interact

  • Normalized URLs. parseSearch drops unknown keys, invalid values, page=1 and the default sort. /discussions?sort=newest&page=1&utm_source=x and /discussions are the same state, so they share a query key and a canonical link.
  • Locales. On a localized content type, pass locale in the query. A record with no translation in that language falls back to the default language when the type uses fallback: "default", and each row says which language it is in under locale. An unknown ?locale= answers 404 rather than an English list.
  • Pagination. ContentListPagination reads currentPage, totalPages, hasNextPage and hasPreviousPage from pageInfo, and every page is a real link, so it works before hydration. A page past the end gets the last page. The list route also accepts cursor for "Older" and "Newer" links; Containers on the frontend uses it.
  • Caching. The API caches each list read per query and locale, and every publish, edit, unpublish, hide or delete of the content type expires it. TanStack Query keeps its own copy in the browser for RECORD_STALE_TIME, which is why a page that writes records invalidates its list.
  • Hidden records. With visibility, a signed-in author, editor or moderator also gets the hidden records they may see. Those rows carry hidden: true, so mark them, for example with HiddenIcon from @vitnode/core/components/hidden-icon. That response is private, no-store and skips the API cache. A route with a page cache serves signed-in visitors the HTML rendered for guests, so refetch the list in the browser under a key that includes the viewer, like the blog's blogViewerPostsQueryOptions. For your own counts or queries, combine contentListHiddenAccess and contentPublicListCondition from @vitnode/core/content/server.

Records reach crawlers through their own pages and the sitemap. The sitemap route lists records, not lists, so add the list's own URL, such as /discussions, where your app builds /sitemap.xml.

The blog plugin builds /blog and /blog/category/:slug from the same pieces with only pages and a category, in plugins/blog/src/pages/blog-list.ts and blog-queries.ts.

Check the result

Open http://localhost:3000/discussions:

  1. Type a word from a title. After half a second the URL gets ?q=... and the list narrows.
  2. Pick a board, then Title (A-Z). The URL keeps both, and the list jumps back to page 1.
  3. Search for a word no title contains. No discussions match shows with Clear filters.
  4. Move to page 2, refresh, then press back. Each view comes back exactly as the URL describes it.

The plain list is the indexable one; every other state stays out of the index but keeps its links followable:

curl -s http://localhost:3000/discussions | grep -oE '<meta content="[^"]*" name="robots"/>'
curl -s "http://localhost:3000/discussions?board=1" | grep -oE '<meta content="[^"]*" name="robots"/>'
<meta content="index, follow" name="robots" />
<meta content="noindex, follow" name="robots" />
The Discussions page with a search field, an All boards select and a Newest select above ten discussions, each with its author, its date and an Edit or Report button, and pagination below