Blog System
The blog combines public articles from local MDX files, database publishing, and optional Notion and Sanity feeds. Database articles can be edited in /admin/posts; external sources remain read-only in this application.
MDX Files
Create content/blog/my-post.mdx with frontmatter:
---
title: "My First Post"
date: "2026-09-26"
summary: "A brief summary"
tags: ["Tutorial", "Next.js"]
draft: false
---
# My First Post
Your Markdown content goes here.Velite generates the article at build or development time. The filename determines the public URL /blog/my-post. Keep filenames to a single path segment. Set draft: true to hide the article from the feed and direct public reading. MDX slugs, including drafts, reserve their public address and cannot be claimed by database articles.
To add frontmatter fields, extend the posts schema in velite.config.ts, then map the generated field in the relevant source and presentation. The schema owns validation; a standalone TypeScript declaration does not change generated content.
Database Publishing
Open /admin/posts/editor to create an article. Save Draft keeps it unpublished; Publish makes a public-audience article readable. Existing articles are edited by stable ID at /admin/posts/editor?id=123; changing the slug changes the public URL while preserving article identity. Old public URLs are not aliases.
Commands use an article ID, expected version and request key. Concurrent edits are rejected instead of overwriting another editor. When a response is uncertain, retry the same command and request key. A new request key describes a new operation. Draft, archived, deleted, private and unlisted articles are excluded from public reading.
Server integrations use the content capabilities:
import { createPostPublisher, createPostEditorialReader } from "~/server/blog";
import { verifyAdminForAction } from "~/server/security/admin-auth";
import type { PostCommand } from "~/server/blog/publishing/contracts";
const authorize = async () => (await verifyAdminForAction()).authorized;
const editorial = createPostEditorialReader(authorize);
const selection = await editorial.read(123);
const catalog = await editorial.catalog({ search: "Tutorial", status: "draft", page: 1 });
async function publish(command: PostCommand) {
return createPostPublisher(authorize).execute(command);
}Each query and command checks trusted server authorization. Editorial reads distinguish ready results, invalid input, forbidden access and unavailable storage. A ready selection with a null value means the article is absent. The catalog includes all audiences, excludes deleted articles, and searches literal title text. Its lifecycle totals cover all non-deleted articles, independently of the current filters.
Route actions handle Next cache invalidation after confirmed writes. Application consumers should use these capabilities instead of inserting database rows or querying persistence receipts directly.
Notion Integration
Create a Notion integration, share its database with the integration, and configure NOTION_TOKEN and NOTION_DATABASE_ID in your local environment. The feed reads these properties:
| Property | Type | Meaning |
|---|---|---|
| Title | Title | Article title |
| Published | Date | A value is required for public inclusion |
| URL | URL | Required HTTP or HTTPS reading destination |
| Slug | Text | Optional source label |
| Summary | Text | Optional description |
| Tags | Multi-select | Optional tags |
Notion entries link to their external URL. This integration does not render or edit Notion article bodies. A response requiring another page is reported as unavailable; pagination is not currently implemented.
Sanity CMS
Configure SANITY_PROJECT_ID, SANITY_DATASET and, when required, SANITY_READ_TOKEN. The configured query reads post documents with title, slug, summary, publishedAt, tags and a required HTTP or HTTPS url. Draft and version documents are excluded. The feed links to the external destination; Sanity Studio owns editing and publishing.
Public Reading
These are server module operations, not HTTP API routes:
import { getBlogFeed, getPost } from "~/server/blog";
const feed = await getBlogFeed();
const tutorialPosts = feed.posts.filter((post) => post.tags?.includes("Tutorial"));
const article = await getPost("my-post");feed.unavailableSources identifies sources that failed. An empty feed with no failures is a valid empty result; present a partial or unavailable state when sources fail. Public detail returns null for an absent or unpublished article and throws on source failure. MDX ownership takes priority over a colliding database slug, including when the MDX article is a draft.
Database publication facts and aggregate feeds are read fresh. External feed snapshots may be cached for at most five minutes. Do not wrap the public feed or database eligibility in a long-lived cache: a confirmed withdrawal must be reflected by the next server read.
Source selection lives in src/server/blog/runtime.ts; source adapters implement the owned contracts in reading/contracts.ts. Rendering lives in src/components/blog, and public pages compose those components with the reading projections. Documentation pages under /docs use their own Nextra pipeline; they are not database articles or another blog source.