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.

Features

Your framework

You keep the framework and components you know, and krantho fits around them.

Problem

A CMS that favors one framework asks you to follow it, or to write the glue yourself. Teams that build in more than one framework end up with a different content setup for each.

Approach

The krantho core knows nothing about React or Svelte. A thin adapter per framework turns its requests and responses into the krantho handler's, and wires the API, the admin and the content loader:

  • Next.js — @krantho/server-next, for the App Router: an API route, an admin route, and a loader wrapped in Next's data cache.
  • SvelteKit — @krantho/server-sveltekit: +server.ts handlers, content in load functions, and a hook for CDN caching.
  • Express and Vite — @krantho/server-node: a handler for any Node server, a Vite plugin for development, and a standalone server.

The UI packages are parallel alternatives, not layers. @krantho/ui-react has server components for text, markdown sections and SEO; @krantho/ui-svelte has Svelte 5 components; @krantho/ui-mui and @krantho/ui-tailwind style React output with MUI or Tailwind. Rendered HTML carries semantic class names, so plain CSS works as well.

Each framework has a bundle package — @krantho/next, @krantho/sveltekit or @krantho/node — that pulls in its adapter, the admin, the local media store and SEO. The Next.js and SvelteKit bundles also bring @krantho/ui-react or @krantho/ui-svelte; with Express you pick the UI package yourself.

Proof

The krantho repository holds example apps that render the same site from the same content:

ExampleStack
nextjs-shadcnNext.js 16, React 19, Tailwind v4
nextjs-muiNext.js 16, React 19, MUI v9
sveltekit-tailwindSvelteKit 2, Svelte 5, Tailwind v4
sveltekit-shadcnSvelteKit 2, Svelte 5, Tailwind v4
react-ssrReact 19, Vite 8, Express 5
react-spaReact 19, Vite 8, React Router 8

The admin is the same React application in each of them, the SvelteKit apps included.

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

Limits

  • @krantho/server-next is built for the Next.js App Router.
  • Astro, Nuxt, Remix and other frameworks have no adapter. The Node handler works with any server that speaks Web Request and Response, but you write the glue.
  • The scaffolder, create-krantho, sets up Next.js and SvelteKit projects. Express projects are set up by hand. A scaffolded project does not add the admin route yet, so you add it yourself.