Atlas recap

2026-09-05

Astro and SvelteKit SEO settings

A pure converter, explicit route data, and one head owner. The application keeps control of metadata and canonical URLs.

Scope PR 2373 · FIMO-2336 Base 6aad99fa → b41f39d6 Depth Normal Evidence tests · built HTML · browser navigation Partially verified

Open draft

PR 2373 · Apply Studio SEO settings

Closes the gap between SEO values saved in Studio and the HTML emitted by Fimo's Astro and SvelteKit starters.

Outcome

PR 2373 now gives Astro and SvelteKit a small, explicit way to turn Studio SEO settings into HTML. The application chooses the route, loads its content, and applies its own policy. Fimo converts the resulting values into tag attributes. The framework renders them.

This replaces the earlier draft's combined buildFimoHead helper. The public addition is toSeoHead in fimo/seo, used after the existing resolveSeoForPath. It follows the same ownership principle as the merged Next.js converters, with native tags as its output instead of Next metadata objects.

The PR remains draft, not released. Current proof includes 67 focused tests, three generated-framework integration tests and a SvelteKit browser navigation check. Existing applications still need to adopt the wiring; upgrading a dependency does not rewrite their SEO code.

The contract

Resolve → app policy → convert → render

The old helper combined route selection, defaults, overrides and HTML conversion. Separating those decisions makes custom routing and content possible without asking a utility to understand every application. There is no generic merge wrapper and no automatic pathname discovery.

Application inputs

Actual pathnameFramework route, locale and base path
Project configStudio defaults and route templates
Loaded contentData supplied by the page
↓

Pure operations

resolveSeoForPathResolve route templates and defaults
App policyChoose final fields and canonical URL
toSeoHeadConvert into native attributes
↓

Framework

Existing head ownerRender escaped tags once
Application composition example
import { resolveSeoForPath, toSeoHead } from 'fimo/seo';
import config from '../../.fimo/config.json';

const resolved = resolveSeoForPath(pathname, config, { article });
const seo = { ...resolved, title: String(article.title) };
const head = toSeoHead(seo, { canonicalUrl });
  • Both functions are pure and bundle-safe. Import paths for config depend on the application's actual file tree.

What Fimo supplies

Ten fields become metadata; the app still owns richer SEO

The converter accepts the existing FimoSEO type. It does not introduce a second settings schema. Most fields map directly to tags. The important exception is url, which is a site base, not the canonical URL of every page.

Text and sharing
title
title, og:title, twitter:title
description
description and social descriptions
image
og:image and twitter:image
siteName
og:site_name
locale
og:locale
twitterSite
twitter:site
Document policy
favicon
icon and apple-touch-icon links
robots
robots meta
themeColor
theme-color meta
url
Input for the app's URL policy only
canonicalUrl option
canonical link and og:url
Default converter output, abbreviated
{
  title: 'An article',
  meta: [
    { property: 'og:type', content: 'website' },
    { property: 'og:title', content: 'An article' },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: 'An article' },
  ],
  links: []
}
  • No canonical is inferred from seo.url. JSON-LD, alternate languages, article metadata and custom card types remain in the app's head code. The converter is optional if that code already handles Fimo's values.

Astro

Content pages resolve once, then pass values to Base

A layout knows the pathname, but not necessarily the article the page loaded. The starter now resolves project SEO after the content read and passes it into Base. Its route config uses {{project.title}} and {{project.summary}}. Base handles the native tags and retains title/description props for existing callers.

Actual starter pattern, excerpt
const seo = resolveSeoForPath(Astro.url.pathname, config, { project });
---
<Base lang={locale} seo={seo} title={seo?.title ?? String(project.title)}>
  <!-- Existing page content -->
</Base>
Base.astro, conversion and rendering excerpts
const head = toSeoHead({
  ...seo,
  ...(title !== undefined ? { title } : {}),
  ...(description !== undefined ? { description } : {}),
}, { canonicalUrl });
---
<title>{head.title ?? 'Fimo + Astro'}</title>
{head.meta.map((attributes) => <meta {...attributes} />)}
{head.links.map((attributes) => <link {...attributes} />)}
  • Base resolves a default when no seo prop is supplied. A supplied final object is not merged again with global settings. Its canonicalUrl prop also accepts null to suppress a canonical explicitly.
HTML asserted by the Astro integration
<title>Native Astro project</title>
<meta name="description" content="Localized project summary">
<meta property="og:title" content="Native Astro project">
<meta property="og:description" content="Localized project summary">
<meta name="twitter:title" content="Native Astro project">
  • These values come from the generated content fixture, not hardcoded metadata in the page layout.

SvelteKit

One head follows the final page data through navigation

A child title alone cannot keep a root Open Graph title in sync. The revised starter returns seoHead from its root server load. A content page can return a complete replacement under the same key. The root reads page.data, which includes the leaf's final data, and owns all the resulting tags.

Default-only pages inherit the root result. Content pages resolve their actual pathname and loaded entry. The app can return an empty converted head to clear Fimo tags, rather than merge defaults back in.

Content page load, composition example
const article = await loadArticle(event.params.slug);
const resolved = resolveSeoForPath(event.url.pathname, config, { article });
const seo = { ...resolved, title: String(article.title) };

return {
  article: serializeArticle(article),
  seoHead: toSeoHead(seo, { canonicalUrl: canonicalFor(article) }),
};
  • loadArticle, serializeArticle and canonicalFor stand for existing application code. Use the generated content module's serializer for tracked content at the Svelte load boundary. Do not create extra loaders solely to copy this example.
Root layout, implemented rendering pattern
import { page } from '$app/state';
const head = $derived(page.data.seoHead ?? { meta: [], links: [] });

<svelte:head>
  <title>{head.title ?? 'Fimo + SvelteKit'}</title>
  {#each head.meta as attributes}<meta {...attributes} />{/each}
  {#each head.links as attributes}<link {...attributes} />{/each}
</svelte:head>
  • Imports and the derived value belong inside the existing script block. Keep the provider and children rendering. Do not add a competing page head for the same tags.

Existing applications

Choose precedence before conversion, without taking over the app

An app's article title may override a Studio default; a private page may remove its share image. Those are explicit field choices. The converter never restores a removed field or silently changes the routing, caching or canonical policy.

A mature application may already use a head component or SEO library. Feed the resolved values into that component instead. There is no requirement to replace it with Fimo's renderer.

App-controlled removal, based on the tested article case
const seo = {
  ...resolved,
  robots: 'noindex',
  image: undefined,
  favicon: '',
};
const head = toSeoHead(seo, {
  canonicalUrl: 'https://sveltekit.example/stories/article/',
});
  • An explicit undefined removes a field. Use a conditional spread when an absent optional override should retain the default. The canonical is passed through unchanged, including its path and trailing slash.
Extending tags beyond FimoSEO, application example
const head = toSeoHead(seo, { canonicalUrl });
const meta = head.meta.filter(
  tag => !('property' in tag && tag.property === 'og:type')
);
meta.push({ property: 'og:type', content: 'article' });

// Render meta once, with the app's existing JSON-LD and alternate links.
  • Replace a matching tag instead of emitting duplicates. The converter's built-in website/card defaults are convenience, not a universal SEO policy.

Adoption and freshness

Skills teach one contract and point to the right framework recipe

The shared frameworks/seo-head reference names every field, removal semantics, canonical ownership and the single-head rule. Astro and SvelteKit references supply their own recipes. The main coding skill routes new content-page work there, and migration guidance tells agents to preserve the existing head owner.

These instructions make the pattern discoverable, but are not proof that every model will follow it. This revision has no independent coding-agent adoption run. The executable tests defend output and navigation contracts.

fimo-code/SKILL.md · discovery hook
Astro/SvelteKit SEO or a new content route → the framework reference,
then references/frameworks/seo-head.md
  • New startersThe SEO wiring is included. Content routes must pass the data their SEO templates reference.
  • Existing sitesUpdate Fimo, inspect the current head code, then adopt the values without duplicating tags. Package update alone is insufficient.
  • Config freshnessImported JSON is a build snapshot. Sync and rebuild/redeploy after Studio config changes.
  • Content freshnessSSR, prerendering and the app's cache policy determine when entry changes appear.
  • Custom routingThe app owns locale/domain/base-path selection. The route catalog does not replace its router.

Evidence

The current revision has output and navigation proof, not production acceptance

The implementation was tested on an isolated Linux Dev Remote box. Initial fixture runs needed the companion packages and publish-time route bundle built first. The later runs passed without substituting a released CLI. Astro's current starter resolved to 7.3.1; the content fixture uses 7.1.6. SvelteKit uses 2.70.2 with Svelte 5.56.8.

Verified67 focused tests

Converter 7, resolver 11, server 3, template contracts 22, skill references 24.

Verified3 generated-project integrations

Astro 2/2 and SvelteKit 1/1. Builds, typechecks and static/server HTML checks passed, including content metadata and app policy.

VerifiedSvelteKit browser navigation

Default → article → Back. A window marker proves no full-page reload. Exact titles, social tags, image removal, robots and canonical assertions passed. Desktop/mobile fixture screenshots inspected; no overflow.

VerifiedPackage and source checks

CLI and companion builds, CLI test typecheck, publish dry-run, lint, formatting, diff and pre-push i18n checks passed.

UnknownCI, deployment and production

Fresh CI was not waited on. This revision has no hosted-site deployment, production crawler acceptance or independent coding-agent adoption receipt. No Lighthouse comparison is claimed.

Read the implementation6 evidence files

Source peek

Open at head
Path copied