Krantho is in active development and not yet published to npm. Read what that means today.
esc

Search the documentation and product pages. Type “theme” to switch themes.

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.

  1. 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/core for renderContent. 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 zod

    The 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.

  2. Wire

    Two endpoints connect krantho to the app: the API at /api/* and the admin at /admin. A module in src/lib opens the content loader over the database the API uses.

    app/api/[...path]/route.ts
    import { 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.ts
    import { 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.ts
    import 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.ts
    import { 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.ts
    import { 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.ts
    import { 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.

  3. Render

    A load function reads the published version of a document, renders its body with renderContent, and composes its JSON-LD graph. A saved draft never changes what visitors see.

    app/(site)/features/[[...slug]]/page.tsx
    import { 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.ts
    import { 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 with Cache-Control: private, no-store, so no cache ever keeps it.

  4. Deploy

    A SvelteKit site builds with @sveltejs/adapter-node into a Node server for a container or a VM, or with @sveltejs/adapter-vercel on Vercel. The code is the same on every host.

    krantho.config.ts
    import { 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.ts
    import 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=E2EXAMPLE
    server.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.pages to cdn and 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 for content.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.