Kwisple
Browse the docs

Your site repository › Rendering

SEO, performance and security headers

The SDK's metadata, JSON-LD, sitemap and robots helpers, the Lighthouse guard, image and font defaults, and the security headers and frame-ancestors policy a production site needs.

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.

ConcernNext.jsAstro
Page metadatametadataFor(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.xmlcreateSitemap(client, options) in app/sitemap.tskwisple({ sitemap }) in astro.config.mjs
robots.txtcreateRobots(client, options) in app/robots.tskwisple({ robots }) (on by default)
ImagesimageProps() + next/image<Image>
Fontsbrand.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 siteUrl plus the entry's URL, so a custom domain wins over a deployment URL;
  • noindex on the SEO tab gives robots: noindex, nofollow;
  • the share image falls back to defaultImage; Open Graph type is article for the news section or entries with a post date, website otherwise.

SeoSite options

OptionMeaning
nameSite name: og:site_name, and the name in the title template.
siteUrlPublic base URL, from client.site(). Without it there is no canonical.
titleTemplateFor example "%s · Acme".
alternateshreflang → absolute URL of the same entry in each locale (include x-default).
defaultDescriptionUsed when the entry has no SEO description.
defaultImage{ url, width?, height?, alt? }, a 1200×630 share image.
ogLocaleOpen 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).

BuilderProducesNotes
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? })WebSitePublisher points at the organization.
breadcrumbJsonLd([{ name, url }, …])BreadcrumbListnull for fewer than two items, so the homepage gets none.
serviceJsonLd({ origin, url, name, description?, serviceType?, image?, areaServed? })ServiceProvider 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 priority for 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"), never 100vw for a half-width image.
  • Keep width and height (both helpers do) so nothing shifts.
  • AVIF and WebP: formats: ["image/avif", "image/webp"] in the Next starter's next.config.ts; formats={["avif", "webp"]} in Astro's <Image>.
  • A video hero gets its poster as a responsive image; the video loads after the load event.

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
OptionMeaning
--preset mobile|desktop|bothForm factors (default both).
--runs <n>Runs per URL and preset (default 3).
--min perf=95,a11y=100,bp=100,seo=100Thresholds (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:

DirectiveAllow
script-src and connect-srcThe platform origin of the statistics script and its collect endpoint (<Analytics> documents this), and the consent endpoint when the banner logs choices.
img-srcKWISPLE_ASSET_ORIGIN, plus data: for blur placeholders.
font-src, style-srcfonts.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.

Last updated 2026-10-30