StacksExpress / Vite
Krantho for Express and Vite
The API and the admin mount as middleware, and your server-side render reads published content from the same database.
Krantho runs inside a Node server you already have: Express, or a Vite app that renders on the server. The krantho API and admin mount as middleware; your render function reads published content from the same database, in the same process, and serves the page through one helper that knows which responses a CDN may keep.
Install
One bundle,
@krantho/node, pulls in the Node adapter, the admin, the local media store and the SEO package. It includes no UI library on purpose: pick@krantho/ui-react,@krantho/ui-svelte,@krantho/ui-muior@krantho/ui-tailwind, or render with your own components. Also add the packages 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
The API and the admin are handlers over Web
RequestandResponse.toWebRequestturns the Node request into a Web request, andwriteResponseHeaderscopies the response headers back, oneSet-Cookieline per cookie. Mount them without a path: the handler routes on the full URL.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 })) })In a Vite dev server, the
@krantho/server-node/viteplugin serves the API and the admin for you.Render
A module opens the content loader over the database the API uses. Your render function reads the published version of a document, so a saved draft never changes what visitors see.
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 }) }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) })Serve every page through
renderCachedPage, with the full URL fromreq.originalUrl. Return every header the page sets — a cookie above all — from the render instead of setting it onres: the helper must see a cookie to keep that page off a CDN. WithsitePreview: 'signed-url'on the API, Live mode and Open on site in the admin send the editor to the page with a signed token, and the draft renders withCache-Control: private, no-store.Deploy
An Express server runs as a plain Node process in a container or on a VM. The code is the same for every host; the environment picks how pages are cached.
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- Behind Amazon CloudFront or Google Cloud CDN, set
cache.pagestocdnand name the provider. Each page carries CDN cache headers and tags for every read it made, and every live change purges exactly those tags. Avercelprovider exists as well, for a server that runs on Vercel. See Deploy behind a CDN. - Without a CDN, keep the default
cache.pages: 'none'. Every request renders the page from reads the content loader keeps forcontent.cache.ttlMs(five minutes by default), so a publish shows within that time. - Previews are signed with
KRANTHO_JWT_SECRET. Set it on every deployment that previews drafts; without it, a preview link renders the published page.
- Behind Amazon CloudFront or Google Cloud CDN, set