StacksSvelteKit
Krantho for SvelteKit
Load functions read published content in-process, and a CDN in front can keep each page until an editor changes it.
Krantho runs inside your SvelteKit app. The API and the admin are two +server.ts endpoints; your load functions read published content from the same database, in the same process. The admin is the same React application every stack serves, delivered as static files, so your app stays Svelte.
Install
One bundle,
@krantho/sveltekit, pulls in the SvelteKit adapter, the Svelte components, the admin, the local media store and the SEO package. Also add the packages your files import by name, including@krantho/coreforrenderContent. 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 endpoints connect krantho to the app: the API at
/api/*and the admin at/admin. A module insrc/libopens the content loader over the database the API uses.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 endpoint and the loader both take the collections, so whichever a fresh deployment serves first opens the database and imports the markdown content into it.
sitePreview: 'signed-url'lets an editor open a draft on the site through a short-lived signed link.Render
A
loadfunction reads the published version of a document, renders its body withrenderContent, and composes its JSON-LD graph. A saved draft never changes what visitors see.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) })Render the HTML with your own components, or with
@krantho/ui-svelte, which has Svelte 5 components for fields, markdown, images and JSON-LD. Live mode and Open on site in the admin send the editor to the page with a signed token, valid for ten minutes and for that page only; the draft renders withCache-Control: private, no-store, so no cache ever keeps it.Deploy
A SvelteKit site builds with
@sveltejs/adapter-nodeinto a Node server for a container or a VM, or with@sveltejs/adapter-vercelon Vercel. The code is the same on every host.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 a CDN — Amazon CloudFront, Google Cloud CDN or Vercel's — 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. 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 a CDN — Amazon CloudFront, Google Cloud CDN or Vercel's — set