Documentation

Content list

Build a public VitNode Content Engine list page with pagination using defineContentList, contentListQueryOptions, ContentListResults and ContentListPagination, then add search and sorting.

A content list is a page such as /news that shows published records a page at a time. The Content Engine provides the data, the paging and the list states; your plugin owns the route, the row layout and the words. This tutorial builds /news with article titles, links and page numbers, then adds search and sorting.

This continues the article from Public API, whose public routes live at /api/newsroom/content/news. Publish a few more articles in the AdminCP first, so there is more than one page; this tutorial shows 10 a page.

Declare the list

Create src/pages/article-list.ts. defineContentList names the sorts and the page size once. contentListQueryOptions turns one URL state into TanStack Query options; write the fetcher call inline in fetch, so the route and its response stay typed:

plugins/newsroom/src/pages/article-list.ts
import type { ContentListSearch } from "@vitnode/core/tanstack/content";

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

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

/** `/news`: published articles, newest first, 10 a page. */
export const articleList = defineContentList({
  defaultSort: "newest",
  pageSize: 10,
  sorts: {
    newest: { order: "desc", orderBy: "publishedAt" },
  },
});

export type ArticleListSearch = ContentListSearch<
  (typeof articleList.sorts)[number]
>;

export const articlesQueryOptions = (request: {
  locale: string;
  search: ArticleListSearch;
}) =>
  contentListQueryOptions({
    ...request,
    queryKey: [manifest.id, "articles"],
    fetch: async ({ search }) => {
      const res = await fetcher({
        plugin: manifest.id,
        method: "get",
        module: "content/news",
        path: "/",
        args: { query: articleList.apiQuery(search) },
      });
      if (res.status !== 200) {
        throw new Error(`Loading articles answered ${res.status}.`);
      }

      return await res.json();
    },
  });
  • sorts maps a URL value to the API's orderBy and order. publishedAt is always orderable.
  • ArticleListSearch is the list's state as the URL holds it: page, and later q and sort.
  • apiQuery(search) builds first, page, orderBy and order for the list route.

Add the route

Edit src/routes.ts and add the page. This is an excerpt:

plugins/newsroom/src/routes.ts
import { articleList } from "./pages/article-list"; 

export const routes = definePluginRoutes([
  page("/news", {
    component: lazy(() => import("./pages/articles-page")),
    messages: ["newsroom.articles"],
    search: articleList.parseSearch,
  }),
]);

search cleans the query string before the loader sees it, so /news?page=2 becomes { page: 2 } and /news?page=1 the same state as /news. messages loads the newsroom.articles translations for the page and its head.

Write the page

Create src/pages/articles-page.tsx. This is the complete file:

plugins/newsroom/src/pages/articles-page.tsx
import type { PluginRoutePageProps } from "@vitnode/core/routing";

import { DateFormat } from "@vitnode/core/components/date-format";
import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client";
import {
  contentListPageHead,
  ContentListPagination,
  ContentListResults,
} from "@vitnode/core/tanstack/content";
import { HrefLink } from "@vitnode/core/tanstack/link";
import {
  defineRoute,
  type RouteLoadContext,
} from "@vitnode/core/tanstack/plugin-routes";
import { useTranslations } from "use-intl";

import type { ArticleListSearch } from "./article-list";

import { articleList, articlesQueryOptions } from "./article-list";

const loadArticles = async (
  { locale, queryClient }: RouteLoadContext,
  search: ArticleListSearch,
) =>
  // A failed list becomes a message on the page, not an error page.
  await queryClient
    .query({
      ...articlesQueryOptions({ locale, search }),
      meta: LOADER_DATA_QUERY_META,
    })
    .catch(() => null);

type ArticlesPageData = Awaited<ReturnType<typeof loadArticles>>;

export const route = defineRoute<ArticlesPageData, ArticleListSearch>({
  load: async ({ context, search }) => await loadArticles(context, search),

  head: ({ search, t }) =>
    contentListPageHead({
      description: t("newsroom.articles.desc"),
      pathname: "/news",
      search,
      title: t("newsroom.articles.title"),
    }),
});

const ArticlesPage = ({
  loaderData: articles,
  search,
}: PluginRoutePageProps<ArticlesPageData, ArticleListSearch>) => {
  const t = useTranslations("newsroom.articles");

  return (
    <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
      <h1 className="text-3xl font-semibold tracking-tight text-balance">
        {t("title")}
      </h1>

      <ContentListResults
        className="flex flex-col divide-y rounded-lg border"
        empty={<p className="text-muted-foreground">{t("empty")}</p>}
        error={<p className="text-destructive">{t("error")}</p>}
        getKey={article => article.id}
        isError={articles === null}
        isFiltered={articleList.isFiltered(search)}
        itemClassName="flex flex-col gap-1 p-4"
        items={articles?.edges}
        label={t("title")}
        loading={null}
        noResults={<p className="text-muted-foreground">{t("empty")}</p>}
        renderItem={article => (
          <>
            <HrefLink
              className="text-lg font-medium hover:underline"
              href={`/news/${article.slug}`}
            >
              {article.title}
            </HrefLink>
            {article.publishedAt ? (
              <span className="text-muted-foreground text-sm">
                <DateFormat date={article.publishedAt} />
              </span>
            ) : null}
          </>
        )}
      />

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

export default ArticlesPage;
  • The loader runs on the server for the first visit and in the browser on later navigations. It warms the query with queryClient.query, so the page is in the server-rendered HTML. LOADER_DATA_QUERY_META keeps the query out of the HTML's cache payload, because the router already sends the loader data.
  • A failed request becomes null, and ContentListResults shows error instead of breaking the page. It also decides between empty (nothing published) and noResults (nothing matches a search).
  • ContentListPagination renders real links built by articleList.href, so paging works before hydration and crawlers can follow it. It renders nothing when there is one page.
  • contentListPageHead sets the title and description. The plain list is indexable; page 2 and later get noindex, follow.

Add the labels

Add the articles keys to src/locales/en.json, inside newsroom:

plugins/newsroom/src/locales/en.json
{
  "newsroom": {
    "articles": {
      "title": "News",
      "desc": "The latest articles from our team.",
      "empty": "No articles yet. Check back soon.",
      "error": "The articles could not be loaded. Try again in a moment.",
      "pagination": "Article pages"
    }
  }
}

Check the result

Open http://localhost:3000/news:

The News page listing ten published articles with their dates, and Previous, 1, 2 and Next links below
  1. The newest ten published articles show, newest first. Release notes for October is a draft, so it never appears.
  2. Click 2. The URL becomes /news?page=2 and the remaining articles show.
  3. Titles link to /news/{slug}. The next tutorial, Content page, creates that page.

Searching narrows the list on the server. Add searchableFields to publicApi in src/content/article.ts:

plugins/newsroom/src/content/article.ts
  publicApi: {
    path: "news",
    fields: ["id", "title", "slug", "body", "publishedAt"],
    searchableFields: ["title"], 
  },

Then add a search field to the page, in three places in src/pages/articles-page.tsx. First, the imports:

plugins/newsroom/src/pages/articles-page.tsx
import { Input } from "@vitnode/core/components/ui/input";
import {
  contentListPageHead,
  ContentListPagination,
  ContentListResults,
  useContentListSearchInput, 
} from "@vitnode/core/tanstack/content";
import { useTransition } from "react";

Second, the search state at the top of ArticlesPage, which now also reads navigate from its props:

plugins/newsroom/src/pages/articles-page.tsx
const ArticlesPage = ({
  loaderData: articles,
  navigate, 
  search,
}: PluginRoutePageProps<ArticlesPageData, ArticleListSearch>) => {
  const t = useTranslations("newsroom.articles");
  const [isPending, startTransition] = useTransition();
  const update = (patch: Partial<ArticleListSearch>) => {
    startTransition(async () => {
      await navigate({
        resetScroll: false,
        search: articleList.withSearch(search, patch),
      });
    });
  };
  const input = useContentListSearchInput({
    onCommit: q => {
      update({ q });
    },
    value: search.q,
  });

Third, the field below the <h1>, and two props on ContentListResults:

plugins/newsroom/src/pages/articles-page.tsx
<div className="flex flex-col gap-3 md:flex-row md:items-center" role="search">
  <Input
    aria-label={t("search_label")}
    className="md:flex-1"
    onChange={event => {
      input.setValue(event.target.value);
    }}
    onKeyDown={event => {
      if (event.key === "Enter" && !event.nativeEvent.isComposing) {
        input.commit();
      }
    }}
    placeholder={t("search_label")}
    type="search"
    value={input.value}
  />
</div>

<ContentListResults
  isFetching={isPending} 
  noResults={<p className="text-muted-foreground">{t("no_results")}</p>} 
  // ...the other props stay as they are
/>
  • useContentListSearchInput waits until typing pauses, then calls onCommit. Enter commits at once.
  • withSearch writes ?q= and goes back to page 1, because page 4 of the old results says nothing about the new ones.
  • isFetching keeps the current results on screen, dimmed and marked aria-busy, while the next ones load.

Add "search_label": "Search articles" and "no_results": "No articles match your search." to the articles labels.

Add sorting

Add orderableFields: ["title"] to publicApi, then more sorts to articleList:

plugins/newsroom/src/pages/article-list.ts
  sorts: {
    newest: { order: "desc", orderBy: "publishedAt" },
    oldest: { order: "asc", orderBy: "publishedAt" }, 
    title: { order: "asc", orderBy: "title" }, 
  },

Render a select next to the search field, using the same update function:

plugins/newsroom/src/pages/articles-page.tsx
import {
  NativeSelect,
  NativeSelectOption,
} from "@vitnode/core/components/ui/native-select";

<NativeSelect
  aria-label={t("sort_label")}
  onChange={event => {
    update({ sort: articleList.sorts.find(sort => sort === event.target.value) });
  }}
  value={search.sort ?? articleList.defaultSort}
>
  {articleList.sorts.map(sort => (
    <NativeSelectOption key={sort} value={sort}>
      {t(`sort.${sort}`)}
    </NativeSelectOption>
  ))}
</NativeSelect>;

Label each sort under articles.sort, such as "sort": { "newest": "Newest first", "oldest": "Oldest first", "title": "Title A–Z" }, and add "sort_label": "Sort articles". The list never writes the default sort to the URL, so /news?sort=newest and /news are the same page.

The News page with a Search articles field and a sort select set to Title A–Z, listing articles alphabetically

Keep list rows light

The list returns every field in fields, including each article's body. Name the fields a row needs with listFields; the single-record route still returns all of fields:

plugins/newsroom/src/content/article.ts
  publicApi: {
    path: "news",
    fields: ["id", "title", "slug", "body", "publishedAt"],
    listFields: ["id", "title", "slug", "publishedAt"], 
  },

Add a filter

A filter needs a field that publicApi.filterableFields lists, such as a relation, an enum or a container. Add a parser for its URL value in defineContentList({ filters }), and pass the value to the list route in fetch. The newsroom plugin's letters list filters by desk this way in plugins/newsroom/src/pages/letter-list.ts; List API lists the parsers.

How the list URL behaves

  • Normalized URLs. parseSearch drops unknown keys, invalid values, page=1 and the default sort. /news?sort=newest&page=1&utm_source=x and /news 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; Container pages 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 /news, 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.

Next, give every article its own page with Content page.