Every site should score Lighthouse Performance 95 or more on mobile, and 100 for Accessibility, Best Practices and SEO, and keep that score without anyone checking by hand. The SDK and the starters set most of it up; this page is the reference for what they do and what you add for a production site.
| Concern | Next.js | Astro |
|---|---|---|
| Page metadata | metadataFor(entry, site) in generateMetadata | <Seo entry site /> in <head> |
| JSON-LD | <JsonLd data /> + builders from @kwisple/sdk/next | <JsonLd data /> from @kwisple/sdk/astro/components + builders from @kwisple/sdk |
sitemap.xml | createSitemap(client, options) in app/sitemap.ts | kwisple({ sitemap }) in astro.config.mjs |
robots.txt | createRobots(client, options) in app/robots.ts | kwisple({ robots }) (on by default) |
| Images | imageProps() + next/image | <Image> |
| Fonts | brand.fontPreloads / brand.fontsHref in the layout | <BrandHead brand /> |
Page metadata
resolveSeo(entry, site) (in @kwisple/sdk) turns an entry's SEO tab into plain values. metadataFor() maps them onto Next's Metadata; Astro's <Seo> renders them with seoHeadTags(). The rules live in one place:
- a custom SEO title is used as is (no template); otherwise the entry title goes through
titleTemplate; - the description falls back to
defaultDescription, because every page needs one; - the canonical is the SEO tab's canonical, else
siteUrlplus the entry's URL, so a custom domain wins over a deployment URL; noindexon the SEO tab givesrobots: noindex, nofollow;- the share image falls back to
defaultImage; Open Graph type isarticlefor thenewssection or entries with a post date,websiteotherwise.
SeoSite options
| Option | Meaning |
|---|---|
name | Site name: og:site_name, and the name in the title template. |
siteUrl | Public base URL, from client.site(). Without it there is no canonical. |
titleTemplate | For example "%s · Acme". |
alternates | hreflang → absolute URL of the same entry in each locale (include x-default). |
defaultDescription | Used when the entry has no SEO description. |
defaultImage | { url, width?, height?, alt? }, a 1200×630 share image. |
ogLocale | Open Graph locale such as nl_BE; default the entry's locale. |
Next.js
// src/app/[[...path]]/page.tsx (excerpt)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const api = await cms();
const [{ entry }, site] = await Promise.all([api.entryByPath(toPath((await params).path)), client.site()]);
if (!entry) return {};
return metadataFor(entry, {
name: site.name,
siteUrl: site.siteUrl,
defaultDescription: typeof entry.intro === "string" ? entry.intro.slice(0, 160) : undefined,
titleTemplate: `%s · ${site.name}`,
});
}Pass titleTemplate here: a root-level optional catch-all does not inherit the layout's title.template. With a template (or a custom title) metadataFor returns title: { absolute }. The layout sets metadataBase from the site URL. Code routes such as /nieuws set their own title, description and alternates.canonical.
Astro
---
import { Seo } from "@kwisple/sdk/astro/components";
---
<Seo entry={entry} site={{ name: site.name, siteUrl: site.siteUrl, titleTemplate: `%s · ${site.name}` }} />The starter's Base.astro renders <Seo> for entries and a plain <title>, description, canonical and optional noindex for code routes (title, description, canonicalPath, noindex props). Until the site has its own address it uses Astro.url.origin.
RANSON.N shows the other option: a site-specific seoMetadata() in src/lib/seo.ts with its own default description and share image. That is fine as long as every page still gets a title, a description and a canonical.
Structured data (JSON-LD)
Builders return plain objects; <JsonLd data={…} /> renders them as <script type="application/ld+json"> with < escaped, so content can never close the tag. data may be an object, an array or null (renders nothing). Outside React or Astro use jsonLdScript(data).
| Builder | Produces | Notes |
|---|---|---|
organizationJsonLd({ type?, origin, name, description?, logo?, image?, telephone?, email?, vatID?, address?, openingHours?, areaServed?, sameAs?, priceRange? }) | Organization or a subtype | @id is <origin>/#organization. Use a LocalBusiness subtype (Bakery, Electrician, Restaurant) for a shop. address.country defaults to BE. |
webSiteJsonLd({ origin, name, locale? }) | WebSite | Publisher points at the organization. |
breadcrumbJsonLd([{ name, url }, …]) | BreadcrumbList | null for fewer than two items, so the homepage gets none. |
serviceJsonLd({ origin, url, name, description?, serviceType?, image?, areaServed? }) | Service | Provider points at the organization. |
schemaDay(n) | "Monday" … "Sunday" | 1 is Monday. For openingHours: [{ days, opens, closes }]. |
The starters render organizationJsonLd and webSiteJsonLd in the layout and a breadcrumb in the resolver for every page except the homepage. The Next FAQ block adds a FAQPage, the news views a NewsArticle. RANSON.N's <StructuredData> emits an Electrician with opening hours built from a global, plus WebSite, a breadcrumb and a Service on service pages. Fill the business details with real data: they appear on every page.
Sitemap
collectSitemap(client, { sections, extra?, fallbackOrigin? }) lists every live, indexable entry with a URL in the given sections, per locale, with lastModified (the entry's updatedAt) and hreflang alternates when an entry exists in more than one locale. Entries with noindex are left out. extra adds code routes such as /nieuws. A site that is not live yet, and any staging deployment, gets an empty sitemap.
// Next.js: src/app/sitemap.ts
import { createSitemap } from "@kwisple/sdk/next";
import { client } from "@/lib/cms";
export default createSitemap(client, {
sections: ["home", "pages", "news"],
extra: ["/nieuws"],
fallbackOrigin: "http://localhost:4102",
});// Astro: astro.config.mjs
kwisple({ sitemap: { sections: ["home", "pages", "news"], extra: ["/nieuws", "/contact"] } })The Astro integration serves /sitemap.xml (sitemapXml() with xhtml:link alternates, s-maxage=3600) only when sitemap.sections is set; sitemap: false turns it off. Add every section with URLs and every code route, or they will not be indexed as well.
Robots
robotsRules(client, { disallow?, fallbackOrigin?, preview? }) allows everything except /api/ and /__preview (plus your disallow paths) and points at <siteUrl>/sitemap.xml. It disallows everything while the site is not live, on a staging deployment, and when preview is true. Next's createRobots(client, { disallow?, fallbackOrigin? }) sets preview when VERCEL_ENV is preview. Astro serves /robots.txt by default (robots: { disallow } to add paths, robots: false to switch it off).
So until you set the site Online zetten (set online) in the dashboard, search engines are kept out, and the sitemap is empty.
Canonical, hreflang and Open Graph
The canonical and the sitemap both come from the site's Site-URL (Website → Instellingen → Ontwikkeling, card Site) as client.site() returns it. Set it to the final address (https://acme.be) before going live and before a production build that prerenders pages. When a site has more than one locale, build alternates for metadataFor/<Seo> from the entry in each locale; the sitemap adds its own alternates.
Open Graph and Twitter tags use the same title, description, canonical and image. og:image:width, height and alt are added when known. A share image of 1200×630 is the safe size.
Performance defaults
Caching
The catch-all route caches pages until a publish purges them (Next: generateStaticParams() returning [] plus dynamicParams = true; Astro: the in-memory content cache). Do not call headers() or cookies() in the resolver or the root layout, or every page renders per request again. 404s are logged from the browser for the same reason (Rendering content).
Images
- Pass
priorityfor the first hero or cover image only (the LCP element). Everything else stays lazy. - Give every image a real
sizes("(min-width: 768px) 50vw, 100vw"), never100vwfor a half-width image. - Keep
widthandheight(both helpers do) so nothing shifts. - AVIF and WebP:
formats: ["image/avif", "image/webp"]in the Next starter'snext.config.ts;formats={["avif", "webp"]}in Astro's<Image>. - A video hero gets its poster as a responsive image; the video loads after the
loadevent.
See Rendering content for the props.
Fonts
client.brand() gives fontPreloads (self-hosted woff2 files worth preloading) and fontsHref (a Google Fonts stylesheet, or null when the fonts are self-hosted and css holds the @font-face rules). The Next layout and Astro's <BrandHead> render both. For the best score use self-hosted woff2 with font-display: swap and preload at most the two or three faces used above the fold; do not load the Google Fonts stylesheet in production.
JavaScript
Server components by default. The Astro starter ships no JavaScript except the cookie banner (only when the site needs consent), the statistics script and the live preview while previewing. Load animation libraries with await import() inside the function that needs them, and third-party embeds (maps, video) on click or when they scroll into view. Layout that JavaScript computes must run before the first paint (an inline script right after the markup), or the element stays hidden until it has run.
Static files
Files in public/ have no content hash, so Next serves them with max-age=0. Add headers() rules: RANSON.N gives /fonts/:file* public, max-age=31536000, immutable, /assets/:path* public, max-age=2592000, stale-while-revalidate=86400 and the favicons a day.
The Lighthouse guard
The platform repository has pnpm lighthouse (tools/lighthouse/run.mjs). It audits URLs of a server that is already running (it builds nothing) with a pinned Lighthouse version, mobile and desktop, three runs each; the run with the median performance score counts. It exits with 1 when a category is below its threshold.
cd ../acme-site && pnpm build && pnpm start # the site's production server
pnpm lighthouse http://localhost:4102/ http://localhost:4102/nieuws
pnpm lighthouse --preset mobile --runs 5 --min perf=90 http://localhost:4102/
pnpm lighthouse --config tools/lighthouse/urls.example.json --out .lighthouse| Option | Meaning |
|---|---|
--preset mobile|desktop|both | Form factors (default both). |
--runs <n> | Runs per URL and preset (default 3). |
--min perf=95,a11y=100,bp=100,seo=100 | Thresholds (these are the defaults). |
--config <file.json> | { "min": {…}, "urls": ["…" or { "url", "min" }] }; a URL may override thresholds, for example a page that is noindex by design. |
--out <dir> | Reports per run plus summary.md and summary.json (default the OS temp folder). |
--chrome <path> | Chrome binary; else CHROME_PATH, a local Chrome or Playwright's Chromium. |
Local scores guard against regressions; they are not the real-world number. Run it against the same kind of build each time.
Security headers
The starters set poweredByHeader: false and no other headers. Add them in next.config.ts for a production Next.js site; RANSON.N's headers() is the reference:
// next.config.ts (excerpt, from RANSON.N)
async headers() {
const dashboard = process.env.KWISPLE_DASHBOARD_ORIGIN?.replace(/\/$/, "");
return [
{
source: "/:path*",
headers: [
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Content-Security-Policy", value: `frame-ancestors 'self'${dashboard ? ` ${dashboard}` : ""}` },
],
},
];
}KWISPLE_DASHBOARD_ORIGIN is read when the config loads, so it must be set at build time as well as at runtime. On the platform's own server, Caddy also adds Strict-Transport-Security, X-Content-Type-Options and Referrer-Policy to any response that lacks them, and X-Robots-Tag: noindex, nofollow on the staging host (Hosting and deploys).
Framing: preview and apps
Two dashboard features frame your site: the live preview, and apps (custom modules opened in the client's dashboard, Apps overview). Both need frame-ancestors to include the dashboard's origin. When an agency works on its own white-label dashboard host, add that host as well. Do not send X-Frame-Options: DENY or SAMEORIGIN: browsers that do not know frame-ancestors follow it and block both features. App pages must also not rely on cookies, which are third-party inside a frame.
A fuller Content Security Policy
If you go beyond frame-ancestors, allow what the platform serves:
| Directive | Allow |
|---|---|
script-src and connect-src | The platform origin of the statistics script and its collect endpoint (<Analytics> documents this), and the consent endpoint when the banner logs choices. |
img-src | KWISPLE_ASSET_ORIGIN, plus data: for blur placeholders. |
font-src, style-src | fonts.gstatic.com and fonts.googleapis.com only while the brand uses Google Fonts. |
frame-ancestors | 'self' and the dashboard origin(s). |
Check the policy with the preview open in the dashboard and with an app open before you ship it.