Documentation

Install the Blog Plugin

Install @vitnode/blog, enable its API and TanStack Start integration, publish your first article in AdminCP and read it on the /blog page.

@vitnode/blog is the Content Engine reference plugin. It brings articles, categories, editorial screens, revisions, a public /blog index, article pages and search indexing, without making your host app impersonate a blog plugin.

Install the package

Install @vitnode/blog
bun add @vitnode/blog@canary

Enable the API plugin

For a Single App, edit apps/web/src/vitnode.api.config.ts; a split deployment uses the same change in apps/api/src/vitnode.api.config.ts.

apps/web/src/vitnode.api.config.ts
import { blogApiPlugin } from '@vitnode/blog/config.api'

export const vitNodeApiConfig = buildApiConfig({
  plugins: [
    blogApiPlugin(), 
  ],
})

Enable the web plugin

One line, and it brings the blog's pages, its AdminCP screens and its translations with it - your build reads the factory and writes the loaders into src/package-messages.gen.ts for you:

apps/web/src/vitnode.config.ts
import { blogPlugin } from '@vitnode/blog/config'

export const vitNodeConfig = buildConfig({
  plugins: [
    blogPlugin(), 
  ],
})

Migrate, run, and publish

Prepare and start the blog
bun run db:migrate
bun dev

In AdminCP, create a category at Blog → Categories, then publish an article at Blog → Articles. A category's Friendly URL is filled in from its name in each language when you leave it empty, like an article's. The plugin owns the editorial work so the host can stay boringly reliable.

Read the blog at /blog

Installing the plugin adds /blog: every published article, newest first, 12 to a page. Drafts, articles scheduled for later and hidden articles never appear.

  • The first page opens with the six newest articles as image tiles: one large story, two beside it and three below. Each tile shows the article's categories, title, authors and date on top of its cover.
  • The other articles follow as cards with the cover, up to three categories, the title, the authors with their avatars and the date. The whole card opens the article at /blog/article/:slug.
  • A sidebar lists every category that has articles, with how many each holds. Each one opens the category's own page at /blog/category/:slug, with its articles as cards. On phones the list turns into a row of chips.
  • Moving between pages animates with view transitions: an article's cover and title grow into the article page, and the category highlight slides to the category you picked. Visitors who prefer reduced motion get no animation.
The /blog page with a large story and five smaller image tiles, then article cards beside a category list with article counts

Every view has its own address, so a refresh, a shared link and the back button all show the same view:

URLWhat it shows
/blogEvery article, with the image tiles on the first page
/blog?page=2A later page of every article
/blog/category/guidesOne category's articles, using its friendly URL
/blog/category/guides?page=2A later page of that category
/blog/article/building-a-pluginOne article

Category and article slugs are per language: a Polish reader gets each category's Polish friendly URL, and a category link in another language redirects to it. Older links still work: /blog/:slug and /blog?category=3 redirect permanently to the new addresses. Any other query parameter is ignored. There is no search box on /blog; readers find articles through the site search, which indexes every published article.

The page follows the reader's language. In Polish it lists the Polish translation of each article, or the English one where none is published yet, and category names fall back the same way. "No articles yet" means nothing is published; "Nothing in this category yet" means the picked category has no articles in that language, and offers See all articles.

The first page of /blog and of every category is indexed by search engines, with a canonical link and hreflang alternates that use each language's own slug. Later pages get noindex, follow, so crawlers still reach every article. Add /blog to your app's /sitemap.xml next to the article entries; the sitemap route lists articles, not the index.

Read an article

An article page puts everything a reader asks first at the top, centered: a link back to the articles, the categories, the title, the authors and a line with the publish date, when it was last edited and how long it takes to read. The "Edited" note only shows up when the article changed more than an hour after it went live, so a quick fix right after publishing stays quiet.

The cover and the text share one centered column. When the article has headings, an On this page list sits in the right margin and follows you as you scroll, highlighting every section you are looking at. On phones it folds into a box above the cover. Headings get their own link anchors, so you can share /blog/article/building-a-plugin#why-plugins.

Categories and article counts in your own pages

The sidebar and the category tags come from a small public route that returns each category's id, name and friendly URL in the requested language, its friendly URL in every language, its colour and article count, plus the total for All articles:

curl "http://localhost:3000/api/@vitnode/blog/categories?locale=pl"
{
  "items": [
    {
      "articleCount": 4,
      "color": "hsl(215, 81%, 52%)",
      "id": 1,
      "name": "Poradniki",
      "slug": "poradniki",
      "slugs": { "en": "guides", "pl": "poradniki" }
    }
  ],
  "total": 4
}

A count only includes articles a reader can open in that language, the same ones /blog lists. Names stay cached until a category changes, and counts until an article is published, edited, hidden or deleted. To put a blog list on another page with your own layout, see Add a list to another page.

Article authors

Every article carries its authors under authorId, in the order the article lists them, on both the list and the article route. The blog opts the field into publicApi.people, so each author is a small public profile, never a raw user row:

curl "http://localhost:3000/api/@vitnode/blog/content/blog?locale=en&first=1"
{
  "edges": [
    {
      "authorId": [
        {
          "avatarColor": "a8295c",
          "avatarUrl": null,
          "id": 1,
          "name": "Ada",
          "nameCode": "ada",
          "role": { "color": null, "prefix": null }
        }
      ],
      "id": 4,
      "title": "Building a plugin"
    }
  ]
}

The response is trimmed here. A deleted account simply drops out of the list.

Write and translate articles

The article editor reads top to bottom like the published article: the cover image and its alt text, then the title, slug and content. The checks before publishing live in the SEO sheet, so the text gets the whole width.

Check the article before you publish

Press SEO in the header to open the Ready to publish sheet. The badge on the button counts what is still left, so you know when it needs a look. The sheet checks what a published article usually needs:

CheckPasses when
Title fits search resultsEvery started language has a title of 60 characters or less
Cover imageA cover image is set
URLThe language you edit has a friendly URL
Cover alt text in every languageEvery started language describes the cover
Translations completeEvery language has a title, friendly URL and content
Translations up to dateNo translation is older than the default language

The title and alt text fields show a live counter against the recommended length: 60 and 125 characters. They are recommendations, not limits, so you can still save a longer title.

Each row is green when it is done and says what is missing when it is not, and the bar on top counts how many are ready. You can fix most rows right there: press Edit under the cover to jump to the alt text, or Open a language that is missing a translation.

Publish or unpublish

The header shows whether the article is a Draft or Published (hover it to see when it went live). To change that, open the arrow next to Save changes and pick Publish or Unpublish. It asks for confirmation first, because readers see the change immediately. Hide and Unhide live in the same menu.

Hide or duplicate an article

The More actions menu of an article in the list has Hide and Duplicate. Hiding takes a published article off the site, search and the sitemap without unpublishing it, and Unhide brings it back with the same URL and date. It needs the Hide and unhide articles permission. Duplicating makes a draft copy with every translation, such as "Hello world (Copy)". See Hide content and Duplicate content.

Translate side by side

Pick a language from Translate. Each translated field then shows the default language on the left, read-only, and the language you translate into on the right. Your shared fields, such as categories and authors, stay in one place.

A translation is marked outdated when the default language was saved after it. Generate from title builds the friendly URL from the translated title.

Use AI helpers

With AI configured, the editor adds:

  • Translate on each field, and Translate missing fields for a whole language. Content keeps its formatting.
  • AI review in the header, which opens a sheet with suggestions about clarity, completeness, structure and tone. When a suggestion is about the wording, Show fix shows the current text next to a rewrite, and Apply puts the rewrite into the article. Dismiss hides a suggestion you do not agree with. The review never blocks publishing.
When the AI buttons appear

The buttons appear only when the API has at least one model in ai.models. The routes behind them require the can_edit permission on articles. See AI to configure a model.

AI never saves for you. Read the suggestion, then press Save changes. While others are editing the article, a translated body goes into the shared editor as one change, so everyone sees it and you can undo it.

Write together

Several people can work on one article at once. The header shows who else is in it, and a colored outline with a name tag shows who is editing which field. The title, slug and other plain fields are edited by one person at a time; the article body is shared, with everyone's cursor visible. Your changes are autosaved to a draft until someone presses Save changes. See Live editing and autosave for how it works and what it needs on your server.

Upgrade an existing blog

Articles no longer have an Excerpt field. The migration drops the column with every excerpt in it, so copy any you want to keep before you run it. Search results and the meta description now use the start of the article content. The article content is now stored as rich text JSON instead of HTML; the migration keeps the words of existing articles as one paragraph, so re-check their formatting.

Articles moved from /blog/:slug to /blog/article/:slug, and categories got a localized Friendly URL. The migration gives every existing category one made from its name and id, such as guides-3; rename it in AdminCP if you want a shorter one. Old article links redirect, but search results store their URL, so rebuild the blog.post collection in AdminCP → Debug Panel → Search afterwards. If you map the article route in routePaths, rename the key to '/blog/article/:slug'.

Run your migrations after you upgrade @vitnode/blog:

Run the blog migrations
bun run db:migrate

Deliver it from a plugin

Build the public article page in a plugin too, then use the Content Engine delivery guide for loaders and SEO. That keeps the data model and its public URL together.