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.tshandlers, content inloadfunctions, 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:
| Example | Stack |
|---|---|
nextjs-shadcn | Next.js 16, React 19, Tailwind v4 |
nextjs-mui | Next.js 16, React 19, MUI v9 |
sveltekit-tailwind | SvelteKit 2, Svelte 5, Tailwind v4 |
sveltekit-shadcn | SvelteKit 2, Svelte 5, Tailwind v4 |
react-ssr | React 19, Vite 8, Express 5 |
react-spa | React 19, Vite 8, React Router 8 |
The admin is the same React application in each of them, the SvelteKit apps included.
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>
)
}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, 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-nextis 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
RequestandResponse, 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.