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.

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.

  1. 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 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 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.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 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 in serverExternalPackages, and rewrite /robots.txt and /sitemap.xml to the API, which builds both from the published content.

  3. Render

    A page is a server component. It reads the published version of a document, so a saved draft never changes what visitors see. requirePublished answers 404 for a page with no published version, and <SeoJsonLd> renders the page's Schema.org graph.

    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)
    })

    @krantho/ui-react/server also provides CMSTextServer and CMSMarkdownServer for a single field or a named ## section, and resolveSeoContent() for generateMetadata. 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.

  4. 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 whose generateStaticParams returns nothing, so the build never reads the database.

    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
    • 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 start in a container or on a VM), the same setting works as it is. Several instances share one page cache through the site's database with cache.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.