StacksNext.js
Krantho for Next.js
Server components read published content in-process, and each page stays cached until an editor changes what it shows.
Krantho runs inside your Next.js App Router project. The API and the admin are two route handlers; your pages are server components that read published content from the same database, in the same process. Your routes, components and styling stay yours.
Install
One bundle,
@krantho/next, pulls in the Next.js adapter, the React server components, the admin, the local media store and the SEO package. pnpm lets your code import only the packages you list yourself, so also add the ones your files import by name. SQLite is the default store and needs no database server.# For the first release: the packages are not on npm yet (see Project status) pnpm add @krantho/next @krantho/server-next @krantho/ui-react @krantho/config @krantho/schema zod# For the first release: the packages are not on npm yet (see Project status) # @krantho/core provides renderContent for load functions pnpm add @krantho/sveltekit @krantho/server-sveltekit @krantho/core @krantho/config @krantho/schema zod# For the first release: the packages are not on npm yet (see Project status) # @krantho/node bundles no UI library: add ui-react, ui-svelte, ui-mui or ui-tailwind. # @krantho/core provides renderContent for the render function pnpm add @krantho/node @krantho/server-node @krantho/ui-react @krantho/core @krantho/config @krantho/schema zodThe packages are not on npm yet; this is the install the first release will use. Project status explains how to run krantho from source today.
Wire
Two route handlers connect krantho to the app: the API at
/api/*and the admin at/admin. A small module,lib/krantho.ts, points server components at the database the API opens.app/api/[...path]/route.tsimport { createApiRouteHandlers } from '@krantho/server-next' import config from '../../../krantho.config' import { collections } from '../../../krantho.collections' // The content API at /api/*: content, media, SEO, auth and the sitemap export const { GET, POST, PUT, DELETE, PATCH } = createApiRouteHandlers({ config, collections, }) // The admin at /admin, in app/admin/[[...slug]]/route.ts: // // import { createAdminRouteHandler } from '@krantho/server-next' // export const { GET } = createAdminRouteHandler()src/routes/api/[...path]/+server.tsimport { createApiHandlers } from '@krantho/server-sveltekit' import config from '../../../../krantho.config' import { collections } from '../../../../krantho.collections' // The content API at /api/*: content, media, SEO, auth and the sitemap export const { GET, POST, PUT, DELETE, PATCH } = createApiHandlers({ config, collections, // Editors preview drafts through a short-lived signed link sitePreview: 'signed-url', }) // The admin at /admin, in src/routes/admin/[...path]/+server.ts: // // import { createAdminHandler } from '@krantho/server-sveltekit' // export const { GET } = createAdminHandler()server.tsimport express from 'express' import { createAdminServer, createHandler, toWebRequest, writeResponseHeaders } from '@krantho/server-node' import config from './krantho.config' import { collections } from './krantho.collections' const api = createHandler({ config, collections, sitePreview: 'signed-url' }) const admin = createAdminServer(config) const app = express() async function send(res: express.Response, response: Response): Promise<void> { res.status(response.status) writeResponseHeaders(res, response) res.end(Buffer.from(await response.arrayBuffer())) } const owns = (prefix: string, path: string) => path === prefix || path.startsWith(`${prefix}/`) // Mounted without a path: the handler routes on the full URL app.use(async (req, res, next) => { if (owns('/api', req.path)) return send(res, await api.handle(await toWebRequest(req))) if (!owns('/admin', req.path)) return next() const url = new URL(`http://localhost${req.originalUrl}`) return send(res, admin({ url, cookie: req.headers.cookie })) })lib/krantho.tsimport { notFound } from 'next/navigation' import { createContentLoader } from '@krantho/server-next' import { getCMSContent, initServerLoader, NetworkError } from '@krantho/ui-react/server' import type { CMSContent } from '@krantho/ui-react/server' import config from '../krantho.config' import { collections } from '../krantho.collections' // Server components read the database the API route opens, in-process initServerLoader(createContentLoader({ config, collections })) /** A page's published content; 404 when it has no published version */ export async function requirePublished(collection: string, slug: string): Promise<CMSContent> { try { return await getCMSContent(collection, slug) } catch (error) { if (error instanceof NetworkError && error.status === 404) notFound() throw error } }src/lib/cms.tsimport { renderContent } from '@krantho/core' import { createContentLoader } from '@krantho/server-sveltekit' import config from '../../krantho.config' import { collections } from '../../krantho.collections' // Pages read the published version from the database the API route opens const loader = createContentLoader({ config, collections }) /** A published document with its rendered body; null when it has none */ export async function loadContent(collection: string, slug: string) { const doc = await loader.getPublished({ collection, slug }) if (!doc) return null const { html, sections } = await renderContent(doc.fields, doc.body) return { fields: doc.fields, html, sections } } /** A page's JSON-LD graph, composed from its published version */ export function loadJsonLd(collection: string, slug: string) { return loader.jsonLd({ collection, slug }) }src/lib/cms.tsimport { renderContent } from '@krantho/core' import { createContentLoader } from '@krantho/server-node' import config from '../../krantho.config' import { collections } from '../../krantho.collections' // Pages read the published version from the database the API opens const loader = createContentLoader({ config, collections }) /** A published document with its rendered body; null when it has none */ export async function loadContent(collection: string, slug: string) { const doc = await loader.getPublished({ collection, slug }) if (!doc) return null const { html, sections } = await renderContent(doc.fields, doc.body) return { fields: doc.fields, html, sections } } /** A page's JSON-LD graph, composed from its published version */ export function loadJsonLd(collection: string, slug: string) { return loader.jsonLd({ collection, slug }) }The API route and the loader both take the collections, so whichever a fresh deployment serves first opens the database and imports the markdown content into it. In
next.config.ts, list krantho's server packages inserverExternalPackages, and rewrite/robots.txtand/sitemap.xmlto the API, which builds both from the published content.Render
A page is a server component. It reads the published version of a document, so a saved draft never changes what visitors see.
requirePublishedanswers 404 for a page with no published version, and<SeoJsonLd>renders the page's Schema.org graph.app/(site)/features/[[...slug]]/page.tsximport { notFound } from 'next/navigation' import { SeoJsonLd } from '@krantho/ui-react/server' import { requirePublished } from '../../../../lib/krantho' import { FeatureList } from './list' // /features and /features/<slug>. Rendered on its first request, then cached // until a change in /admin invalidates it (cache.pages: 'next-isr'). // The build reads no content. export const revalidate = false export function generateStaticParams(): { slug?: string[] }[] { return [] } interface Props { params: Promise<{ slug?: string[] }> } export default async function FeaturesPage({ params }: Props) { const { slug } = await params if (!slug) return <FeatureList /> const [name] = slug if (slug.length !== 1 || !name) notFound() const feature = await requirePublished('features', name) return ( <article> <SeoJsonLd collection="features" slug={name} /> <h1>{String(feature.fields['title'] ?? '')}</h1> <p>{String(feature.fields['benefit'] ?? '')}</p> <div dangerouslySetInnerHTML={{ __html: feature.html }} /> </article> ) }src/routes/features/[slug]/+page.server.tsimport { error } from '@sveltejs/kit' import { loadContent, loadJsonLd } from '$lib/cms' // /features/<slug>. Reads happen inside the page render, so each one records // its cache tags: behind a CDN, a change in /admin purges the pages that showed it. export async function load({ params }: { params: { slug: string } }) { const feature = await loadContent('features', params.slug) if (!feature) error(404, 'Page not found') const jsonLd = await loadJsonLd('features', params.slug) return { fields: feature.fields, html: feature.html, jsonLd } } // +page.svelte renders it: // // <h1>{data.fields.title}</h1> // <p>{data.fields.benefit}</p> // {@html data.html}server.ts// server.ts, continued: `app` and `config` come from the API wiring import { renderCachedPage } from '@krantho/server-node' import { loadContent } from './src/lib/cms' // A feature page. Reads happen inside the render, so each one records its cache tags async function renderFeature(slug: string): Promise<{ status: number; html: string }> { const feature = await loadContent('features', slug) if (!feature) return { status: 404, html: '<h1>Page not found</h1>' } return { status: 200, html: `<article>${feature.html}</article>` } } // After the API and admin middleware. Pass req.originalUrl, which keeps the // mount path and the query string a signed draft preview carries. app.get('/features/:slug', async (req, res) => { const request = { url: req.originalUrl, method: req.method } const page = await renderCachedPage(request, config, () => renderFeature(req.params.slug)) for (const [name, value] of Object.entries(page.headers)) res.setHeader(name, value) res.status(page.status).type('html').send(page.html) })@krantho/ui-react/serveralso providesCMSTextServerandCMSMarkdownServerfor a single field or a named##section, andresolveSeoContent()forgenerateMetadata. Live mode and Open on site in the admin open the page in Next.js draft mode, so an editor sees the draft on the page itself while visitors keep the published version.Deploy
With
cache.pages: 'next-isr', each page renders on its first request and is served from Next's cache until a change in the admin invalidates what it shows. The content routes are dynamic segments whosegenerateStaticParamsreturns nothing, so the build never reads the database.krantho.config.tsimport { defineKranthoConfig } from '@krantho/config' export default defineKranthoConfig({ site: { domain: 'krantho.com', name: 'Krantho', language: 'en', baseUrl: 'https://krantho.com', }, cache: { // Each page renders on its first request and stays cached until a change // in /admin invalidates it. Content routes need the ISR shape: // generateStaticParams returns [] and revalidate is false. pages: 'next-isr', // Several `next start` instances: keep Next's cache in the site's database, // with the cache handler from @krantho/server-next set in next.config.ts // shared: 'database', }, })src/hooks.server.tsimport type { Handle } from '@sveltejs/kit' import { createPageCacheHandle } from '@krantho/server-sveltekit' import config from '../krantho.config' // Pages carry CDN cache headers and cache tags; a change purges them. // Keep this the outermost handle, first in sequence(). export const handle: Handle = createPageCacheHandle({ config }) // The CDN is chosen by the environment, with no code change: // KRANTHO_CACHE_PAGES=cdn // KRANTHO_CACHE_CDN_PROVIDER=cloudfront # or gcp, or vercel // KRANTHO_CACHE_CDN_CLOUDFRONT_DISTRIBUTION_ID=E2EXAMPLEserver.ts// server.ts, continued: `app` and `config` come from the API wiring import { renderCachedPage } from '@krantho/server-node' // Every page goes through renderCachedPage. render() is your SSR entry, // returning { status, html, headers? }; the helper records the cache tags of // the reads inside it and sets the CDN headers. A change purges those tags. app.use(async (req, res) => { const request = { url: req.originalUrl, method: req.method } const page = await renderCachedPage(request, config, () => render(req.originalUrl)) for (const [name, value] of Object.entries(page.headers)) res.setHeader(name, value) res.status(page.status).type('html').send(page.html) }) // The CDN is chosen by the environment, with no code change: // KRANTHO_CACHE_PAGES=cdn // KRANTHO_CACHE_CDN_PROVIDER=gcp # or cloudfront, or vercel // KRANTHO_CACHE_CDN_GCP_PROJECT=my-project // KRANTHO_CACHE_CDN_GCP_URL_MAP=my-url-map- On Vercel, cached pages are served from Vercel's CDN, and Next's own invalidation reaches it: there is no purge to set up. Content lives in Postgres and uploads in an object store. See Deploy to Vercel.
- On your own servers (
next startin a container or on a VM), the same setting works as it is. Several instances share one page cache through the site's database withcache.shared: 'database'. - Behind Amazon CloudFront or Google Cloud CDN, name the CDN in the environment. Every live change then refreshes Next's cache and purges
/*at the CDN. See Deploy behind a CDN.