esen_seo 0.15.0
esen_seo: ^0.15.0 copied to clipboard
Real semantic HTML for Flutter Web — SEO widget extensions, meta tags, OpenGraph, JSON-LD and a bot-aware SSR server. No Puppeteer, pure Dart.
esen_seo #
Real semantic HTML for Flutter Web — no Puppeteer, no headless Chrome. Pure Dart, built from your widget tree.
A Flutter web app is not a document. However it renders — CanvasKit, WebAssembly or the DOM — what reaches the page is a widget tree, not headings, paragraphs and links, so a crawler finds no structure to read. esen_seo mirrors that widget tree as clean semantic HTML right in the DOM, manages meta tags, OpenGraph and Schema.org JSON-LD, and ships a shelf-based SSR server that hands bots the HTML straight in the page source.
// One change per widget, full SEO:
Text('Welcome').h1 // → <h1>Welcome</h1>
Text('We build Flutter apps.').p // → <p>We build Flutter apps.</p>
Image.network(url).seo(alt: 'Our team') // → <img src="..." alt="Our team"/>
This is an add-on for the app you already have, not a framework to
rebuild it in. It works in three steps: most widgets are mirrored
automatically, a .seo() call adds the meaning Flutter does not know,
and a handful of library widgets bridge the cases the mirror cannot
see at all — a closed dropdown, a virtualized list, an inactive tab, a
painted chart.
On iOS, Android and desktop nothing changes: every call is a no-op and your widgets render exactly as before. The library widgets build plain Flutter widgets there too — nothing is translated into native views. The HTML only exists on the web.
Features #
.seo()extensions forText,Image,Column,RowandGestureDetector— your widgets stay untouched, on every platform.- Typed tags with IDE autocomplete:
SeoTextTag.h1,SeoContainerTag.section, … — typos become compile errors. The most common tags have shorthands:.h1–.h6,.p,.li,.ul,.section,.article,.nav,.tr, … - Smart defaults: pages without any
.seo()calls still render — the first text becomes<h1>, following texts<p>, images<img>with theirsemanticLabelas alt text. The page never breaks: blocked or invalid tags (script,style, …) fall back to safe elements. - Custom translations:
.seoNodes()lets any widget declare its own HTML, and the SEO widget library translates painted content — aSeoBarChartmirrors as CSS bars plus a real<table>of its data. - Semantic rich text:
SeoRichTextbuilds native FlutterTextSpans and nested<strong>,<em>,<code>and safe<a>elements from one declarative span tree instead of flatteningText.richto plain text. - Meta tags, OpenGraph, Twitter Cards: one
EsenSeo.setMeta()call per page, with sensible fallbacks (og:title←title, …). - Schema.org JSON-LD for rich results: typed builders for
Article,Product(incl.AggregateRating),Review,Event,LocalBusiness,Organization,WebSite,BreadcrumbListandFAQPage, plus a generic escape hatch for every other type. - Bot-aware SSR server: a shelf middleware detects crawlers by
User-Agent and serves them a real HTML document — pure Dart, runs
with
dart run, no browser involved. - URL routing as a single source of truth: define your routes once
in a pure-Dart table — the app applies meta tags automatically on
navigation, while the server renders the declared route bodies for
bots. The same table generates
sitemap.xml(withlastmodand hreflang alternates),robots.txt, canonical URLs and real HTTP 404s. - Static prerendering: bake the route table into the web build as static HTML files — full SEO on Firebase Hosting, GitHub Pages or any CDN, no server needed.
- Visible shell (optional): let the prerendered HTML be the first frame — styled, readable content before the Flutter engine has loaded, with Flutter taking the screen over on its first frame.
- DOM-first routes (opt-in): let a pure route body remain the permanent
page without loading Flutter Web.
SeoTabs,SeoCarouseland boundedSeoCollectioninteractions run through transitions compiled from the same pure Dart source used by Flutter. A route may combine Tabs, Carousel and one Stepper family in one verified runtime bundle; Collection remains a standalone runtime under the same fixed JavaScript budget. A separate profile-bound navigation pilot can accelerate links between compatible document routes and reinitialize package-owned controls while retaining complete no-JavaScript pages. Its verified handoff profile can also move between static routes and a route-bound package Collection runtime. - AI crawlers & instant indexing:
llms.txtandllms-full.txtgenerated from the route table, and IndexNow pings so search engines pick up changes in minutes instead of days.
Quick start #
import 'package:esen_seo/esen_seo.dart';
void main() {
EsenSeo.init();
EsenSeo.setMeta(SeoMeta(
title: 'Yahya Esen — Flutter Developer in Munich',
description: 'Flutter apps for iOS, Android and the web — with real SEO.',
canonicalUrl: 'https://esen.software/',
openGraph: const OpenGraphMeta(image: 'https://esen.software/og.png'),
schemas: [
SeoSchema.organization(name: 'Esen Software', url: 'https://esen.software'),
],
));
runApp(const MyApp());
}
Column(
children: [
Text('Flutter apps that rank on Google').h1,
Text('Yahya Esen — freelance Flutter developer from Munich.').p,
Column(
children: [
Text('Web apps with real SEO').li,
Text('Mobile apps from the same codebase').li,
],
).ul,
GestureDetector(
onTap: () => context.go('/contact'),
child: Text('Contact us'),
).seo(href: '/contact'),
],
).section
For a multi-page app, register the route observer — it is what keeps
the mirror and the meta tags following navigation. Without it, a page
built purely from smart defaults keeps serving the previous page's
mirror (and title, and canonical) after a Navigator.push:
MaterialApp(
navigatorObservers: [
SeoRouteObserver(routes: seoRoutes, canonicalBase: siteBase),
],
// go_router: pass it to GoRouter(observers: [...]) instead.
);
(Widgets tagged with .seo() also refresh the mirror on navigation by
themselves — but the observer is the supported setup and the only one
that updates title and canonical too.)
On web, the semantic mirror is injected as #esen-seo-content next to
the Flutter canvas (invisible, aria-hidden, zero size); on mobile and
desktop every .seo() call is a no-op that returns the original widget.
Yes, that mirror is hidden by default — but it is not the old trick of
keeping a second, hand-maintained copy of the page in the markup. It is
generated from the same widget tree the user sees, so the live mirror
does not require a second authored content tree. With the visible shell
below it stops being hidden at all. One thing it is explicitly not: an
accessibility feature.
aria-hidden keeps screen readers out of it on purpose, because Flutter
publishes its own semantics tree and two of them would be read twice.
Accessibility stays a matter of Flutter's Semantics widgets.
Tags #
The semantic HTML elements work — structure, headings, text, lists, tables and media. The typed constants cover the common ones and autocomplete in the IDE; the rest go through the constructor:
Text('Quote').seo(SeoTextTag.blockquote);
Text('12 July').seo(SeoTextTag.time);
Column(children: [...]).seo(SeoContainerTag.article);
Text('Exotic').seo(SeoTextTag('bdo')); // less common tags
Tags are an allow list, so anything that could execute code, swallow the
document or collect input (script, style, iframe, form,
plaintext, svg, head-only tags, custom elements, invalid names) is
refused at render time and falls
back to span/div — in SeoMode.strict you get a debug warning.
This remains true for free SeoNode trees. The curated DOM-first action form
described below is an opaque package-owned control plan, not an expansion of
the generic tag allow list.
Attributes #
Every .seo() call accepts HTML attributes; images and links get the
important ones as typed parameters:
Text('12 July').seo(SeoTextTag.time, {'datetime': '2026-07-12'});
Column(children: [...]).seo(SeoContainerTag.section, {'id': 'pricing'});
Image.network(url).seo(alt: 'Team', width: 800, height: 400, lazy: true);
GestureDetector(...).seo(href: '/legal', rel: 'nofollow', hreflang: 'de');
Image dimensions let crawlers reserve layout space (Core Web Vitals:
CLS) and fall back to the widget's own width/height when set.
An attribute policy keeps the tree safe: event handlers (onclick, …)
and invalid names are dropped, while data-*, aria-*, id, lang,
cite, … pass through. URL attributes are held to an allow list —
relative URLs plus http, https, mailto, tel, sms and ftp.
Anything else is refused, so javascript: and friends cannot get
through even in a disguise nobody has thought of yet. That matters as
soon as link targets come from your users rather than from you.
Custom widgets & charts — translate the data, not the pixels #
Widgets that paint their content (charts, gauges, CustomPaint) are a
black box to the mirror: pixels carry no semantics. What is
translatable is the data they paint from. .seoNodes() lets any widget
declare its own HTML — the declared nodes replace the widget's subtree
in the mirror, and the usual tag/attribute policy applies:
MyRatingStars(score: 4.5).seoNodes([
SeoNode(tag: 'p', text: 'Rated 4.5 out of 5 stars'),
]);
The SEO widget library builds on this. Every library widget renders as normal Flutter widgets on every platform — and on the web its data appears in the mirror as readable HTML:
SeoBarChart(
title: 'Revenue per year',
motion: SeoMotionPreset.gentle,
data: [
SeoBarChartEntry('2024', 12),
SeoBarChartEntry('2025', 31),
SeoBarChartEntry('2026', 54),
],
)
// → <figure><figcaption>Revenue per year</figcaption>
// …CSS bars…
// <table><caption>…</caption>
// <tr><th>2024</th><td>12</td></tr>…</table></figure>
SeoBarChart— CSS bars plus a<table>of the values.SeoPieChart— a pure-CSS pie (conic-gradient, no images, no JS) plus a<table>with labels, values and shares.SeoRating— stars plus the exact score as plain text (★★★★☆ 4.5/5); pair withSeoSchema.product/SeoSchema.reviewfor rating stars in search results.SeoDataTable— specs, prices, comparisons as a real<table>with<caption>,<thead>and<tbody>.SeoFaq— an accordion whose answers are in the page source even while collapsed (<details>/<summary>, expandable without any JS).SeoBreadcrumbs— a trail as<nav><ol><li>with real links.SeoFigure— image plus caption as<figure>/<figcaption>, with the dimensions that keep the layout from jumping.SeoResponsiveImage— one fallback image and bounded width variants become a native Flutter image and semantic<picture>/srcsetmarkup. Intrinsic dimensions reserve the same aspect ratio on both sides; typed AVIF/WebP sources,sizes, loading and fetch-priority hints improve delivery without JavaScript.SeoTestimonial— a customer quote as<blockquote>with its attribution beside it, the way the HTML spec asks for.SeoRichText— inline importance, emphasis, code and links from one pure-Dart span model; Flutter and HTML keep the same text and structure.
SeoBarChart motion is deliberately opt-in. SeoMotionPreset.gentle uses
one pure timing model for the native Flutter growth/stagger and the browser
CSS animation. A DOM-first route selects SeoDomFirstFeature.motion to add
the fixed package stylesheet; a custom visible page can append the exported
seoMotionStylesheet itself. The effect adds no JavaScript, no focus stops and
no semantic changes, and it stops under prefers-reduced-motion or Flutter's
MediaQuery.disableAnimations. Without the preset, the widget and serialized
HTML retain their previous bytes.
SeoRichText(
spans: const [
SeoRichTextSpan.text('Read the '),
SeoRichTextSpan.link(href: '/docs', text: 'documentation'),
SeoRichTextSpan.text(' for '),
SeoRichTextSpan.strong(text: 'important details'),
SeoRichTextSpan.text('.'),
],
onLinkTap: (href) => Navigator.pushNamed(context, href),
)
// Flutter: native TextSpan tree on iOS, Android, desktop and web
// HTML: <p>Read the <a href="/docs">documentation</a> for
// <strong>important details</strong>.</p>
SeoRichTextSpan is intentionally semantic rather than a converter for
arbitrary TextStyle, gesture recognizers or WidgetSpan. Paint details do
not reliably identify a URL or the difference between importance and visual
boldness. Role-specific Flutter styles remain configurable on SeoRichText;
the HTML elements can be styled with ordinary CSS.
Responsive images use the fallback encoding for Flutter and expose alternate encodings to HTML. Every source must therefore depict the same image; arbitrary media queries and art direction are intentionally not part of this contract. For cross-platform network loading, use absolute HTTP(S) URLs. Relative URLs remain useful for web-only assets. Width-candidate URLs must percent-encode syntax separators such as whitespace, commas and quotes.
SeoResponsiveImage(
src: 'https://cdn.example.com/hero-1280.jpg',
alt: 'Product dashboard',
width: 1280,
height: 720,
candidates: const [
SeoResponsiveImageCandidate(
src: 'https://cdn.example.com/hero-480.jpg',
width: 480,
),
SeoResponsiveImageCandidate(
src: 'https://cdn.example.com/hero-800.jpg',
width: 800,
),
],
sources: const [
SeoResponsiveImageSource(
format: SeoResponsiveImageFormat.avif,
candidates: [
SeoResponsiveImageCandidate(
src: 'https://cdn.example.com/hero-480.avif',
width: 480,
),
SeoResponsiveImageCandidate(
src: 'https://cdn.example.com/hero-1280.avif',
width: 1280,
),
],
),
],
sizes: '(max-width: 48rem) 100vw, 48rem',
loading: SeoResponsiveImageLoading.eager,
fetchPriority: SeoResponsiveImageFetchPriority.high,
)
// Flutter: selects the smallest sufficient fallback candidate from the
// measured width and device-pixel ratio.
// HTML: <picture><source type="image/avif" srcset="...">
// <img src="..." srcset="..." sizes="..." width="1280" height="720">
Five of them close a different kind of hole: content Flutter never builds cannot be mirrored, because the mirror walks the widget tree.
SeoNavMenu— a dropdown's entries live in an overlay and do not exist while the menu is closed. This one keeps the whole tree as data, so every internal link is in the source:<nav><ul><li><a>, nested as deep as you declare it.SeoListView— the widest silent hole in Flutter Web:ListView.builderbuilds only what is on screen, so a 200-entry blog index mirrors maybe eight. Flutter still renders lazily here; the mirror gets all 200.SeoCarousel—PageView.buildervirtualizes off-screen pages. Flutter keeps that lazy native page view, while every slide reaches HTML as a complete section with its own heading.SeoTabs— aTabBarViewbuilds only the selected panel, so on a product page two thirds of the content are invisible. All panels are mirrored, each behind its own heading.SeoStepper— Flutter builds the active body and keeps visited bodies mounted, while every unvisited step already exists in HTML as an ordered<ol><li>flow with its own heading and complete content.
SeoFaq and SeoBreadcrumbs also hand you the matching structured
data, so the on-page content and the rich result come from one source:
SeoMeta(schemas: [
SeoFaq.schemaFor(entries),
if (SeoBreadcrumbs.schemaFor(trail, base: siteBase) case final crumbs?)
crumbs,
])
On non-web platforms all of them are no-ops that render the plain Flutter widget.
Meta tags & JSON-LD per page #
Call setMeta again on navigation; previously injected tags are
replaced:
EsenSeo.setMeta(SeoMeta(
title: 'Real SEO for Flutter Web — Blog',
description: 'How esen_seo mirrors your widget tree as semantic HTML.',
canonicalUrl: 'https://esen.software/blog/flutter-seo',
schemas: [
SeoSchema.article(
headline: 'Real SEO for Flutter Web',
author: 'Yahya Esen',
datePublished: DateTime.utc(2026, 7, 22),
),
SeoSchema.breadcrumbs([
(name: 'Home', url: 'https://esen.software/'),
(name: 'Blog', url: 'https://esen.software/blog'),
]),
],
));
For international pages, declare the language variants — rendered as
<link rel="alternate" hreflang="…"> tags:
SeoMeta(
title: 'Flutter Developer München',
canonicalUrl: 'https://esen.software/',
alternates: {
'de': 'https://esen.software/',
'en': 'https://esen.software/en/',
'x-default': 'https://esen.software/',
},
)
Anything beyond the built-in fields goes into extraMeta — plain
<meta name="…" content="…"> tags:
SeoMeta(
extraMeta: {
'google-site-verification': 'AbC123…',
'theme-color': '#0a0f1e',
},
)
SSR server for bots #
Crawlers that do not execute JavaScript (social link previews, many search and AI bots) never see client-injected HTML. The server half of esen_seo fixes that — bots get a complete HTML document in the page source, real users get your Flutter app:
import 'package:esen_seo/server.dart'; // pure Dart, no Flutter
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_static/shelf_static.dart';
Future<void> main() async {
final handler = const Pipeline()
.addMiddleware(seoBotMiddleware(resolve: (request) {
if (request.url.path == '') {
return SeoPage.fromNodes(
meta: SeoMeta(title: 'Yahya Esen — Flutter Developer in Munich'),
body: [SeoNode(tag: 'h1', text: 'Flutter apps that rank on Google')],
);
}
return null; // unknown route → serve the Flutter app
}))
.addHandler(
createStaticHandler('build/web', defaultDocument: 'index.html'),
);
await io.serve(handler, 'localhost', 8080);
}
SeoMeta, SeoSchema, SeoNode and HtmlRenderer are shared between
the Flutter side and the server, so the same nodes serialize identically
in both. Build pages from SeoNodes as above — they pass the tag and
attribute policy.
The SeoPage(bodyHtml: …) constructor writes its string into the
document verbatim and exists for HTML you wrote yourself; never assemble
it from content you do not control. See
example/bin/server.dart for a runnable setup.
URL routing — one table for app and server #
Define your routes once, in a pure-Dart file without Flutter imports:
// lib/seo_routes.dart — imported by main.dart AND bin/server.dart
import 'package:esen_seo/core.dart';
const siteBase = 'https://esen.software';
final seoRoutes = [
SeoRoute(
path: '/',
meta: (_) => SeoMeta(title: 'Yahya Esen — Flutter Developer in Munich'),
body: (_) => [SeoNode(tag: 'h1', text: 'Flutter apps that rank.')],
),
SeoRoute(
path: '/blog/:slug', // path parameters
meta: (params) => SeoMeta(title: 'Blog — ${params['slug']}'),
),
];
A content index can use the same closed collection state on both sides. The
route body receives pure component entries; the app presents the matching
SeoCollectionEntry values through the native Flutter widget:
final articleComponents = <SeoCollectionComponentEntry>[
(
title: 'Fast Flutter content',
searchText: 'Semantic HTML and performance',
categories: ['Flutter', 'SEO'],
sortKey: 20260301,
nodes: [
SeoNode(tag: 'h2', children: [
SeoNode(
tag: 'a',
text: 'Fast Flutter content',
attributes: {'href': '/blog/fast-flutter-content'},
),
]),
],
),
// At least one more complete item.
];
SeoRoute(
path: '/blog',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: const {SeoDomFirstFeature.collection},
meta: (_) => const SeoMeta(title: 'Blog'),
body: (_) => buildSeoCollectionNodes(
items: articleComponents,
interactionId: 'article-collection',
pageSize: 12,
synchronizeUrl: true,
),
);
SeoCollection supports search, one selected category, newest/oldest/title
sorting and pagination. Its source always contains every item in a useful
initial order; JavaScript only changes presentation after validating the whole
component. Empty or invalid ids, duplicate DOM ids, malformed metadata,
single-item collections, more than 2,000 items, more than 32 categories and
search corpora above 4,096 UTF-16 code units deliberately degrade to complete
static markup. The browser runtime is selected independently from tabs and
from enableInteractions. With synchronizeUrl: true, the DOM-first adapter
stores non-default state in esen.<interactionId>.q, .category, .sort and
.page query parameters. Search uses replaceState; category, sort and page
changes use pushState; Back and Forward restore the collection. The current
path, fragment and unrelated query parameters remain untouched, and the
canonical route URL is unchanged.
The live DOM mirror is derived directly from the widget tree, so it does
not require a second authored content tree. A server-rendered route
body, however, is separate unless the app and the route both derive
from a shared pure data model. Use auditSeoParity to catch drift between
independently authored trees.
In the app — meta tags update automatically on every navigation,
no setMeta boilerplate per page:
EsenSeo.init(cleanUrls: true); // path URLs (/demo) instead of /#/demo
MaterialApp(
navigatorObservers: [
SeoRouteObserver(routes: seoRoutes, canonicalBase: siteBase),
],
routes: {...},
);
Using go_router? Same observer, no extra package:
GoRouter(
observers: [
SeoRouteObserver(routes: seoRoutes, canonicalBase: siteBase),
],
routes: [...],
);
The observer matches URL-like route names directly and otherwise
follows the browser URL — so it works with go_router, beamer,
auto_route or any other Router-based package. (For go_router
ShellRoutes, add the observer to the shell's observers as well.)
On the server — the same table drives the bot responses:
seoBotMiddleware(routes: seoRoutes, siteBase: siteBase)
This automatically gives you:
- server-rendered pages for every route (with path parameters),
- canonical URLs derived from
siteBase+ route path, /sitemap.xml,/robots.txt,/llms.txtand/llms-full.txtgenerated from the table,- real HTTP 404s for unknown page paths — no SPA soft-404 problem.
The sitemap carries everything a route declares: set
SeoRoute(lastModified: …) and search engines see a <lastmod> date;
routes whose SeoMeta.alternates list language variants get
<xhtml:link rel="alternate" hreflang="…"> entries — Google's
recommended way to announce translations at scale.
Database-backed pages — one read, one page #
A /products/:slug page usually pulls its title, description, schema
and body from a single record. Building the metadata and the body
separately means two reads that can disagree — the page a user sees
drifting from the entry in your sitemap. SeoRoute.dynamic resolves
both from one read:
SeoRoute.dynamic(
path: '/products/:slug',
// Lists the concrete URLs for the sitemap, llms.txt and prerender.
enumeratePaths: () async =>
(await db.publishedSlugs()).map((s) => '/products/$s').toList(),
resolve: (request) async {
final product = await db.product(request.param('slug'));
if (product == null) return SeoDocument.notFound(); // real 404
if (product.movedTo != null) {
return SeoRedirect('/products/${product.movedTo}'); // real 301
}
return SeoDocument(
meta: SeoMeta(title: product.name, description: product.teaser),
// The head request (sitemap/llms) needs no body — skip it to keep
// enumeration cheap; never trim the meta.
body: request.detail == SeoDetail.head
? const []
: product.toSeoNodes(),
lastModified: product.updatedAt, // per-record <lastmod>
includeInSitemap: product.isPublished, // pull drafts from the index
);
},
)
seoBotMiddleware and prerenderSite resolve the table for you — the
classic SeoRoute(meta:, body:) form is unchanged and mixes freely with
dynamic routes in the same table. A SeoRedirect target is held to
stricter rules than a link: only http, https or a relative path,
only real redirect statuses (301, 302, 303, 307, 308), and never an
empty or fragment-only target. mailto: and tel: are fine in a link
and nonsense in a Location; an empty or #fragment target just
redirects to itself. Anything refused becomes a 404 rather than an
unsafe header.
A resolver redirect is served to human visitors as well as bots — sending Googlebot to the new URL while a user stays on the old one is cloaking. Error statuses are the deliberate exception: a 404 or 410 answers crawlers, while a human keeps the Flutter app and its own router decides what to show. Both are configurable:
seoBotMiddleware(
routes: seoRoutes,
siteBase: siteBase,
applyResolverRedirects: SeoRedirectScope.all, // default; .botsOnly, .off
infrastructureCacheTtl: Duration(minutes: 15), // default for dynamic tables
onResolveError: (path, error, stack) => log.warning('$path: $error'),
)
sitemap.xml, llms.txt and llms-full.txt are cached — forever for a
static table, 15 minutes for a dynamic one — and concurrent requests
share a single pass instead of each starting their own. Set
onResolveError when you use dynamic routes: a failing page is
dropped from the sitemap rather than taking the whole file down with it,
and without the callback that happens silently.
A resolver may also return response headers via SeoDocument.headers.
The names are an allow list — cache-control, expires, etag,
last-modified, age, x-robots-tag, link and content-language,
plus vary, which is merged with User-Agent rather than replacing
it on Flutter-delivered SSR responses. DOM-first responses do not vary by
User-Agent and preserve only the variants you declare. Everything else is
dropped, as is any name that is not a valid
HTTP token and any value outside printable ASCII. That is deliberately narrow: a page's content should not be
able to set a cookie, claim a content encoding, or decide your CORS and
CSP posture. For headers beyond that list, put your own shelf
middleware in the pipeline.
For URL hygiene, add the redirect middleware in front — duplicate content under several URLs splits ranking signals:
seoRedirectMiddleware(
canonicalHost: 'esen.software', // www.… → esen.software (301)
forceHttps: true,
trustProxy: true, // only behind your reverse proxy
stripTrailingSlashes: true, // /demo/ → /demo
redirects: {'/old-page': '/new-page'} // relaunch mappings
)
Audit — prove the site is correct before you ship it #
Most SEO mistakes are not broken code. They are a page marked noindex
that is still in the sitemap, a link to a route somebody renamed, a
translation cluster that points one way. The renderer cannot refuse any
of those — each one is a perfectly legal use of the API — so there is a
separate check for them:
// test/seo_audit_test.dart — runs in the CI you already have
test('the site has no SEO errors', () async {
assertSeoHealthy(
await auditSeoRoutes(routes: seoRoutes, siteBase: siteBase),
);
});
It reads the route table, not built HTML: the package already knows
every URL, title and node, so a broken internal link is simply an
href that matchSeoRoute cannot match. No crawler, no HTML parser,
and it runs before flutter build web has done any work.
esen_seo audit: 6 pages, 3 error(s), 2 warning(s), 1 info.
/blog/archive:
x route.shadowed unreachable: an earlier pattern already matches it,
so this route never runs (shadowed by /blog/:slug)
/geheim:
x robots.noindex-in-sitemap marked noindex but still listed in
sitemap.xml — two contradictory signals
/kontakt:
x link.broken links to a path that no route serves (/agb)
Among the things it catches, each verified against the real code: a
canonicalUrl the URL policy refuses — which leaves the page with no
canonical and suppresses the automatic one, so it ends up worse off
than if you had set nothing; a schema value JSON cannot encode, which
otherwise throws when the page renders rather than when you write it;
and hreflang clusters that are not reciprocal, which Google discards
without telling anyone.
assertSeoHealthy throws with the whole report in the message, and it
owns the comparison — a hand-written check against describe() is easy
to get wrong in a way that always passes, which is exactly what an
earlier version of this README recommended.
Severity is the contract: error is something measurably wrong,
warning is very likely wrong but a real site can look like that
(paginated pages share titles), and info never fails a build. A
resolver that throws becomes a finding rather than aborting the run —
but the report is then marked partial and the cross-page checks are
skipped, because "this title is unique" cannot be proven with a
page missing.
auditSeoRoutes(
routes: seoRoutes,
siteBase: siteBase,
policy: const SeoAuditPolicy(ignore: {SeoCheck.titleLength}),
);
Prefer running it from a script instead of a test? Same shape as the
prerenderer — a few lines in bin/seo_audit.dart that import your own
route table, then exit(report.passes() ? 0 : 1).
Parity — do bots and visitors see the same page? #
Everything above reads the route table, so on its own it can only confirm that the table agrees with itself. The check that matters most compares it against the widget tree a visitor actually sees:
import 'package:esen_seo/testing.dart';
testWidgets('bots and users see the same pages', (tester) async {
final report = await auditSeoParity(
routes: seoRoutes,
siteBase: siteBase,
paths: const ['/', '/docs'],
pump: (path) async {
await tester.pumpWidget(MyApp(initialRoute: path));
await tester.pumpAndSettle();
},
);
assertSeoHealthy(report);
});
The failure it exists for is mundane: somebody renames a headline in the widget and forgets the route body. From then on crawlers and visitors read different pages — and nothing else in the package can notice, because the two trees come from different code.
Text that reaches crawlers but never appears in the app is an
error; that is cloaking, whatever the intent. A differing <h1> is
an error too. A heading only the app shows is a warning, since an app
legitimately shows more than a crawler needs. Links are off by default:
navigation usually lives in the Flutter shell, so the route body will
never carry it. Coverage is reported honestly: a run that checked no
page at all is an error, because it proves nothing — while pages
your sample deliberately skipped are counted in the report as info,
the first few named, so the sample's blind spot is visible without
the build failing over a judgement call you made.
testing.dart is a separate import on purpose — it is test-time
scaffolding and has no business in a release build.
Static prerendering — SEO without any server #
No Dart server on your host? Bake the same route table directly into the web build:
// bin/prerender.dart
import 'package:esen_seo/server.dart';
import 'seo_routes.dart';
Future<void> main() async {
await prerenderSite(routes: seoRoutes, siteBase: siteBase);
}
flutter build web
dart run bin/prerender.dart
# → deploy build/web to Firebase Hosting, GitHub Pages, any CDN
Every route becomes a real <path>/index.html containing its title,
meta tags, JSON-LD and the semantic HTML body — visible in the page
source for everyone, no bot detection needed. The running app finds the
prerendered container by id and simply takes it over (hydration, no
duplicates). Deep links work on static hosts because the files actually
exist; sitemap.xml, robots.txt, llms.txt, llms-full.txt and a
404.html (served with a real 404 status by Firebase Hosting, GitHub
Pages & Co. — no SPA soft-404) are written too. For :param routes,
pass the concrete paths via additionalPaths.
Visible shell — the prerendered page as the first frame #
By default the semantic HTML is an invisible mirror next to the Flutter
canvas: crawlers read it, users never see it. With
SeoRenderMode.visibleShell the same HTML becomes the first frame
instead — a real, styled page the user can read while the Flutter engine
is still downloading:
await prerenderSite(
routes: seoRoutes,
siteBase: siteBase,
renderMode: SeoRenderMode.visibleShell,
stylesheet: seoDefaultStylesheet, // oder dein eigenes CSS
);
The prerendered container marks itself, so the two sides cannot drift
apart — but your app must call EsenSeo.init(), which is what
schedules the first mirror refresh and with it the handoff. Miss that
one call and the shell stays on top of your running app forever. There
is deliberately no timeout behind it: a shell that stays put is the
right answer when the engine never arrives, and from the outside the
package cannot tell that case from a forgotten init().
Once it runs, the moment Flutter has rendered its first frame the shell fades out over 150 ms and drops back to being the invisible mirror. While it is up the shell covers the viewport, so the Flutter engine's empty surface stays hidden during boot — the user sees content, then the finished app, and never the loading in between. If the engine never loads (slow network, JS error), the user simply keeps a readable page.
DOM-first routes — permanent HTML without Flutter Web #
A route whose entire body comes from the pure component layer can opt out of the Flutter browser runtime. Humans and crawlers then receive the same standalone semantic document; there is no canvas, takeover or hidden app. Navigation is ordinary multi-page navigation by default; compatible document routes may select the profile-bound pilot described below. Every indexable panel stays in the delivered HTML when JavaScript is unavailable.
final productTabs = <SeoTabComponentEntry>[
(
label: 'Overview',
nodes: [SeoNode(tag: 'p', text: 'Everything at a glance.')],
),
(
label: 'Details',
nodes: [SeoNode(tag: 'p', text: 'All technical details.')],
),
];
final seoRoutes = [
SeoRoute(
path: '/product',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: const {
SeoDomFirstFeature.tabs,
SeoDomFirstFeature.motion,
},
meta: (_) => const SeoMeta(title: 'Product'),
body: (_) => [
...buildSeoTabsNodes(
tabs: productTabs,
interactionId: 'product-tabs',
),
...buildSeoBarChartNodes(
data: const [
(label: '2025', value: 31.0),
(label: '2026', value: 54.0),
],
motion: SeoMotionPreset.gentle,
),
],
),
];
seoBotMiddleware serves that route before its User-Agent split, and
prerenderSite writes a standalone file without flutter_bootstrap.js or
main.dart.js. Its domFirstStylesheet input styles DOM-first pages
independently and defaults to seoDefaultStylesheet; stylesheet continues to
belong to Flutter's visible shell. The middleware uses the same
domFirstStylesheet default. domFirstNonce can supply a per-response CSP
nonce.
Tabs, Carousel and Stepper reserve their package-owned control geometry before
the first paint whenever an admitted runtime is present. The pure builders emit
only native-hidden, aria-hidden spans for that reservation, never focusable
controls. After the complete component contract validates, the adapter replaces
the reservation atomically. With JavaScript disabled, rejected or interrupted,
the reservation stays hidden and every panel, slide and step remains readable.
Custom CSS may restyle the controls, but should preserve the structural display
and sizing rules emitted after the route stylesheet.
The built-in executable slices use package-owned transitions. They do not
translate arbitrary Flutter State, Cubits or callbacks. SeoTabs,
SeoCarousel and SeoStepper share their pure transitions with separate
browser adapters; SeoCollection does the same with its prepared transition.
Each presentation owns its current state.
SeoDomFirstFeature.motion is separate:
it adds fixed CSS only, never a script, and responds exclusively to fixed
markers produced by the pure component builders. General forms,
application-authored controls and content effects remain separate, deliberately
unsupported capabilities. Client navigation is available only through the
closed document-route pilot below. The curated action form below is the only
remote-input exception. SeoCollection owns one
bounded local-search input: it submits nothing, performs no remote I/O and is
created only after the complete collection structure has been validated.
Profile-bound client navigation (pilot) #
Document-only DOM-first routes may opt into faster same-origin navigation. The
route table remains the source: seoBotMiddleware and prerenderSite derive a
bounded ordered manifest from it, so application data never names DOM targets
or executable code.
const documentFeatures = {
SeoDomFirstFeature.navigation,
SeoDomFirstFeature.prefetch,
SeoDomFirstFeature.themeToggle,
SeoDomFirstFeature.motion,
};
final seoRoutes = [
SeoRoute(
path: '/',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: documentFeatures,
meta: (_) => const SeoMeta(title: 'Home'),
body: (_) => homeNodes,
),
SeoRoute(
path: '/about',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: documentFeatures,
meta: (_) => const SeoMeta(title: 'About'),
body: (_) => aboutNodes,
),
];
The browser intercepts an ordinary link only when its final URL is HTTP(S),
same-origin, inside the configured siteBase path and matched by a route with
the exact same feature profile. It fetches a complete HTML document, enforces
strict response and DOM bounds, replaces only sanitized package-marked head
nodes and #esen-seo-content, then updates History, scroll and focus. Fetched
scripts are never inserted or executed. The theme toggle listens for the
package navigation event and binds the fresh marker after replacement.
Package-owned Tabs, Carousel and Stepper controls validate and initialize the
new document from its delivered initial state; a fragment inside a panel,
slide or step reveals that region before navigation focus and scroll. A
Stepper navigation profile may include theme toggle and CSS motion, but it is
mutually exclusive with Tabs and Carousel in the same profile to preserve the
fixed runtime budget with a stable reserve. CSS motion needs no
reinitialization.
SeoDomFirstFeature.prefetch is a separate opt-in. Pointer intent, keyboard
focus or a primary pointer press may start the same checked GET before link
activation. At most one fragment-free candidate is retained for up to ten
seconds; activating an anchor consumes it and applies that anchor's fragment.
The speculative response must pass the complete navigation URL, size,
manifest, head and content validator. Prefetch itself never changes the live
DOM, History, focus, scroll or busy state, and a failed candidate stays silent.
The later click simply uses the ordinary checked request and full-navigation
recovery.
The candidate is not retained when the response sends Cache-Control: no-store, no-cache or max-age=0, Pragma: no-cache, Vary: * or an
already expired Expires value. Positive max-age, Age and Expires
values can shorten the ten-second package ceiling. Save-Data, reported 2G
connections and hidden documents disable speculation without disabling
navigation. Because a GET may now occur before activation, route resolvers
must keep document GETs safe and free of application side effects.
Under the fixed runtime budget a prefetch profile may contain theme, motion and at most one of Tabs or Carousel. It cannot currently contain Stepper or the combined Tabs-and-Carousel runtime. Unsupported combinations fail when the route is constructed; existing navigation profiles continue to use their unchanged runtime.
SeoDomFirstFeature.runtimeHandoff is a separate, non-prefetching profile for
routes that differ only by the presence of the package-owned Collection
runtime:
const handoffFeatures = {
SeoDomFirstFeature.navigation,
SeoDomFirstFeature.runtimeHandoff,
SeoDomFirstFeature.themeToggle,
};
const collectionHandoffFeatures = {
...handoffFeatures,
SeoDomFirstFeature.collection,
};
Use handoffFeatures on the static routes in that navigation group and
collectionHandoffFeatures on each route whose body contains the Collection.
The route manifest binds each destination to either no loadable runtime or the
exact Collection runtime identity, UTF-8 byte length and SHA-256. A fetched
script remains inert text until Web Crypto verifies it against the trusted
initial manifest. It is then executed once with the nonce of the already
running package script, never a nonce supplied by the fetched document. A
missing browser capability, changed manifest, duplicate or malformed marker,
length or digest mismatch, stale response, blocked script or runtime that does
not reach its ready marker falls back to a full document request. Collection
state is rebuilt from the destination HTML and URL; it is not carried across
documents. Every route in this profile receives the same Collection structural
CSS, while only a Collection route carries its loadable runtime.
Production handoff expects HTTPS so Web Crypto is available. With a nonce-based
CSP, provide domFirstNonce as for the other package runtimes; otherwise an
unavailable verifier or blocked dynamic script deliberately uses full
navigation.
SeoDomFirstFeature.applicationRuntimeHandoff is the corresponding closed
pilot for one separately compiled application Collection runtime. Static and
Collection routes select the same feature profile; only the latter names the
typed runtime:
const articleCollectionRuntime =
SeoDomFirstApplicationRuntime.collection('article-collection');
const applicationHandoffFeatures = {
SeoDomFirstFeature.navigation,
SeoDomFirstFeature.applicationRuntimeHandoff,
SeoDomFirstFeature.themeToggle,
};
final seoRoutes = [
SeoRoute(
path: '/articles',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: applicationHandoffFeatures,
applicationRuntime: articleCollectionRuntime,
meta: (_) => const SeoMeta(title: 'Articles'),
body: (_) => articleCollectionNodes,
),
SeoRoute(
path: '/about',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: applicationHandoffFeatures,
meta: (_) => const SeoMeta(title: 'About'),
body: (_) => aboutNodes,
),
];
Pass the same SeoDomFirstRuntimeStore used for standalone application
runtimes to seoBotMiddleware or prerenderSite. Before any profile document
is emitted, the artifact's manifest, contract revision, typed identity, source
length, gzip size and digest are verified once. Manifest schema 3 then binds
that exact application source to its route. Fetched source stays inert until
Web Crypto verifies it; only then does the loader append its fixed package-owned
ready-marker epilogue and execute it with the current package runtime's CSP
nonce. Each destination rebuilds
Collection state from its own complete HTML and validated URL. One compatible
profile may reuse one runtime reference across any number of routes. A second
runtime id in that profile is rejected; bundles, prefetch and every other
application runtime kind must use ordinary document navigation.
That Collection-only form remains the schema-3 compatibility profile. To bind
the profile itself to a typed application artifact, set
applicationRuntimeHandoffProfile on every route in the group. Static routes
set only the profile; a route that executes the artifact sets the same
reference as applicationRuntime:
const pricingRuntime =
SeoDomFirstApplicationRuntime.configurator('pricing-configurator');
const pricingHandoffFeatures = {
SeoDomFirstFeature.navigation,
SeoDomFirstFeature.applicationRuntimeHandoff,
SeoDomFirstFeature.themeToggle,
};
final seoRoutes = [
SeoRoute(
path: '/overview',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: pricingHandoffFeatures,
applicationRuntimeHandoffProfile: pricingRuntime,
meta: (_) => const SeoMeta(title: 'Overview'),
body: (_) => overviewNodes,
),
SeoRoute(
path: '/pricing',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: pricingHandoffFeatures,
applicationRuntimeHandoffProfile: pricingRuntime,
applicationRuntime: pricingRuntime,
meta: (_) => const SeoMeta(title: 'Pricing'),
body: (_) => pricingConfiguratorNodes,
),
];
The explicit schema-4 profile admits standalone Collection, Configurator, Editorial Workflow, Approval Checklist, Tabs and Carousel artifacts. Its kind and validated id become part of the navigation profile, so another artifact cannot enter through an otherwise identical feature set. Every route receives the structural CSS for that typed profile, while only an active route embeds its verified source. The browser validates the complete route plan, contract revision, UTF-8 length and SHA-256 before replacement, then appends the package-owned readiness suffix for that exact kind. Each Configurator, Editorial Workflow, Approval Checklist, Tabs or Carousel destination starts from its delivered HTML and creates fresh state through its existing package adapter and apply boundary. Prefetch, bundles, package-owned interactive runtimes and other application runtime kinds remain separate profiles and use ordinary document navigation.
Outside the explicitly admitted handoff profiles, navigation cannot be
combined with Collection. Forms, other application runtimes and Stepper
Effects remain
incompatible with every navigation profile. Those links deliberately retain
native multi-page navigation until their state and reinitialization contracts
are explicit. Modified clicks, downloads, external targets, fragments on the
current page, malformed responses and profile changes likewise stay native or
fall back to a full document request. Without JavaScript every route remains a
complete, directly navigable HTML page. siteBase is required when any route
selects SeoDomFirstFeature.navigation, including subpath deployments such as
GitHub Pages.
Curated action forms #
SeoActionFormDefinition is one bounded source for a native Flutter form, a
non-interactive mirror summary and a real DOM-first POST form. It admits one to
eight fields from a closed set: text, email, multiline text and consent. The
same definition can also use a single-choice field with two to twelve fixed
options. The renderer still refuses arbitrary form, input, select,
textarea and button nodes.
// lib/contact_form.dart — pure Dart, shared by app, route and server
import 'package:esen_seo/form.dart';
const contactForm = SeoActionFormDefinition(
actionId: 'contact',
returnPath: '/contact/',
heading: 'Contact us',
description: 'We usually reply within one business day.',
submitLabel: 'Send message',
pendingLabel: 'Sending',
failureLabel: 'The message could not be sent.',
statusLabel: 'Submission status',
fields: [
SeoActionFormField(
name: 'email',
label: 'Email',
kind: SeoActionFormFieldKind.email,
required: true,
autocomplete: SeoActionFormAutocomplete.email,
),
SeoActionFormField(
name: 'message',
label: 'Message',
kind: SeoActionFormFieldKind.multiline,
required: true,
),
SeoActionFormField(
name: 'consent',
label: 'I agree to the privacy notice',
kind: SeoActionFormFieldKind.consent,
required: true,
),
],
);
Use the same definition in the route and Flutter presentation:
SeoRoute(
path: '/contact',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: const {SeoDomFirstFeature.actionForm},
meta: (_) => const SeoMeta(title: 'Contact'),
body: (_) => buildSeoActionFormNodes(contactForm),
);
// Import package:esen_seo/form_flutter.dart in Flutter code.
SeoActionForm(
definition: contactForm,
onSubmit: sendContactFromApp,
);
Register the fixed POST endpoint before the page middleware:
final handler = const Pipeline()
.addMiddleware(seoActionFormMiddleware(
publicOrigin: siteBase,
registrations: [
SeoActionFormRegistration(
definition: contactForm,
handler: (values) async {
await deliverMessage(
values.text('email'),
values.text('message'),
);
return const SeoActionFormResult.success('Message sent.');
},
),
],
))
.addMiddleware(seoBotMiddleware(routes: seoRoutes, siteBase: siteBase))
.addHandler(flutterAppHandler);
Without JavaScript the browser performs the same native POST and receives a
fixed noindex, no-store result document. With
SeoDomFirstFeature.actionForm, the package runtime submits once with fetch,
keeps stale responses out and writes only a bounded status message and errors
for declared fields. It never inserts HTML, retries automatically or moves
focus. The application or deployment still owns authentication, authorization,
rate limiting, abuse protection, durable idempotency and the actual side
effect. Exact-Origin checking is a browser CSRF boundary, not caller
authentication.
For a multi-step enquiry, partition the same flat form definition with
SeoActionFlowDefinition. Every field must occur exactly once and in the same
order as the form plan. A step may depend on one fixed option from an earlier
single-choice field in an unconditional step, and an optional package-owned
review stage can summarize only the active branch:
const projectFlow = SeoActionFlowDefinition(
form: projectForm,
steps: [
SeoActionFlowStep(
label: 'Project',
description: 'Choose what you want to build.',
fieldNames: ['name', 'service'],
),
SeoActionFlowStep(
label: 'Website',
description: 'Describe the website.',
fieldNames: ['website_goal'],
condition: SeoActionFlowCondition.choiceEquals(
fieldName: 'service',
value: 'website',
),
),
SeoActionFlowStep(
label: 'Shop',
description: 'Describe the catalog.',
fieldNames: ['shop_catalog'],
condition: SeoActionFlowCondition.choiceEquals(
fieldName: 'service',
value: 'shop',
),
),
SeoActionFlowStep(
label: 'Contact',
description: 'Where we can reply.',
fieldNames: ['email', 'consent'],
),
],
previousLabel: 'Previous',
nextLabel: 'Next',
progressLabel: 'Project enquiry progress',
review: SeoActionFlowReview(
label: 'Review',
description: 'Check the active project details.',
emptyValueLabel: 'Not provided',
consentAcceptedLabel: 'Confirmed',
consentDeclinedLabel: 'Not confirmed',
),
);
SeoRoute(
path: '/project',
delivery: SeoRouteDelivery.domFirst,
domFirstFeatures: const {SeoDomFirstFeature.actionFlow},
meta: (_) => const SeoMeta(title: 'Start a project'),
body: (_) => buildSeoActionFlowNodes(projectFlow),
);
// Import package:esen_seo/form_flutter.dart in Flutter code.
SeoActionFlow(definition: projectFlow, onSubmit: sendProjectEnquiry);
Without JavaScript all steps and the final submit control remain visible in one ordinary POST form. The optional runtime validates the complete package-owned structure before it adds active-branch navigation, current-step validation, progress semantics and the fixed review stage. Branches admit no predicates, negation, chained conditions, direct step jumps or application-selected DOM targets. Register the flow itself so Shelf applies the same active-field projection and never passes inactive branch values to the handler:
SeoActionFormRegistration.flow(
definition: projectFlow,
handler: sendProjectEnquiry,
)
Application-owned state #
A DOM-first route can instead execute a tabs, carousel, collection, stepper,
configurator, editorial workflow or approval checklist transition authored in
the application. A separately selected stepper variant may also emit one
closed focus effect. Write the logic as a state-free top-level Dart function
under lib/ and pass the same function to Flutter:
// lib/product_tabs_transition.dart — pure Dart, no Flutter import
import 'package:esen_seo/core.dart';
SeoTabsState transitionProductTabs(
SeoTabsState state,
SeoTabsAction action,
) {
// Application rule: next/previous stop at the ends instead of wrapping.
if (action is SeoTabsNext && state.index == state.count - 1) return state;
if (action is SeoTabsPrevious && state.index == 0) return state;
return transitionSeoTabs(state, action);
}
SeoTabs(
transition: transitionProductTabs,
tabs: flutterProductTabs,
);
SeoCarousel and SeoStepper accept the equivalent SeoCarouselTransition
and SeoStepperTransition. For example, an application can wrap previous and
next at the ends while keeping direct, first and last selection unchanged:
SeoStepperState transitionProductStepper(
SeoStepperState state,
SeoStepperAction action,
) {
final current = initialSeoStepperState(
count: state.count,
index: state.index,
);
if (current.count == 0) return current;
final last = current.count - 1;
if (action is SeoStepperNext && current.index == last) {
return SeoStepperState(index: 0, count: current.count);
}
if (action is SeoStepperPrevious && current.index == 0) {
return SeoStepperState(index: last, count: current.count);
}
return transitionSeoStepper(current, action);
}
SeoStepper(
transition: transitionProductStepper,
steps: flutterProductSteps,
);
To focus the active panel after an accepted action, return the next state and the closed effect together. Invalid state or effect output rejects the complete result; initialization and availability probes never execute effects:
SeoStepperEffectResult transitionProductStepperEffects(
SeoStepperState state,
SeoStepperAction action,
SeoStepperEffectContext context,
) {
if (context.interactionId != 'product-stepper') {
return SeoStepperEffectResult(state: state);
}
final next = transitionProductStepper(state, action);
return SeoStepperEffectResult(
state: next,
effect: next == state ? null : const SeoStepperFocusActivePanel(),
);
}
SeoStepper.withEffects(
interactionId: 'product-stepper',
effectTransition: transitionProductStepperEffects,
steps: flutterProductSteps,
);
The effect carries no selector, element id, callback or executable value. Flutter and the browser apply accepted state first and then focus only the package-owned active step panel. Browser arrow-key navigation keeps focus on the active step control so repeated arrows remain usable.
SeoCollectionTransition additionally receives the prepared, read-only
records and its closed category/page configuration. The package validates the
returned state before Flutter or the browser applies it. This example makes
page navigation wrap while search, filtering and sorting keep their built-in
semantics:
SeoCollectionState transitionArticles(
SeoCollectionState state,
SeoCollectionAction action, {
required List<SeoCollectionRecord> records,
required int categoryCount,
required int pageSize,
}) {
final snapshot = selectSeoCollection(
records: records,
categoryCount: categoryCount,
pageSize: pageSize,
state: state,
);
final current = snapshot.state;
if (snapshot.pageCount > 1 &&
action is SeoCollectionPreviousPage &&
current.page == 0) {
return SeoCollectionState(
query: current.query,
categoryIndex: current.categoryIndex,
sort: current.sort,
page: snapshot.pageCount - 1,
);
}
if (snapshot.pageCount > 1 &&
action is SeoCollectionNextPage &&
current.page == snapshot.pageCount - 1) {
return SeoCollectionState(
query: current.query,
categoryIndex: current.categoryIndex,
sort: current.sort,
);
}
return transitionSeoCollection(
current,
action,
records: records,
categoryCount: categoryCount,
pageSize: pageSize,
);
}
SeoCollection(
transition: transitionArticles,
synchronizeUrl: true,
items: flutterArticles,
);
With synchronizeUrl: true, the compiled application runtime restores initial,
Back and Forward state through one atomic SeoCollectionRestoreState action.
The URL codec bounds the candidate first; the same application transition can
then accept or normalize the complete snapshot before that candidate can alter
visible state or replace the canonical URL. Delegate unhandled restore actions to
transitionSeoCollection as above. Search still replaces the current History
entry, while category, sort and page actions push only changed URLs.
The approval checklist is a closed multi-flag slice rather than a generic DOM
mutation API. Import package:esen_seo/checklist.dart for its pure state,
projection and semantic builder, and
package:esen_seo/checklist_flutter.dart for the native widget. Each accepted
action changes exactly one of at most 32 flags. Flutter renders native checkbox
controls; the DOM-first adapter creates only package-owned checkbox controls
and writes validated projection text into fixed status slots. Application
values cannot select elements, attributes, classes or focus targets.
SeoApprovalChecklistState transitionReleaseChecklist(
SeoApprovalChecklistState state,
SeoApprovalChecklistAction action,
) => transitionSeoApprovalChecklist(state, action);
SeoApprovalChecklistView projectReleaseChecklist(
SeoApprovalChecklistState state,
) {
final complete = seoApprovalChecklistCheckedCount(state);
return SeoApprovalChecklistView(
statusText: complete == state.checked.length ? 'Ready' : 'Open',
summaryText: '$complete of ${state.checked.length} complete',
announcementText: '$complete checklist items complete',
);
}
Build the permanent HTML from buildSeoApprovalChecklistNodes and use the
same transition and projection with SeoApprovalChecklist on Flutter. Initial
status, summary and progress must match the delivered document exactly;
rejected or missing JavaScript leaves every checklist item readable.
Compile only the selected transition and its package-owned adapter. Tabs is the default kind for backward compatibility; select every other kind explicitly:
dart run esen_seo:esen_seo_runtime \
--id product-tabs \
--library package:my_app/product_tabs_transition.dart \
--symbol transitionProductTabs
dart run esen_seo:esen_seo_runtime \
--kind carousel \
--id product-carousel \
--library package:my_app/product_carousel_transition.dart \
--symbol transitionProductCarousel
dart run esen_seo:esen_seo_runtime \
--kind collection \
--id article-collection \
--library package:my_app/article_collection_transition.dart \
--symbol transitionArticles
dart run esen_seo:esen_seo_runtime \
--kind stepper \
--id product-stepper \
--library package:my_app/product_stepper_transition.dart \
--symbol transitionProductStepper
dart run esen_seo:esen_seo_runtime \
--kind stepper-effects \
--id product-stepper-effects \
--library package:my_app/product_stepper_transition.dart \
--symbol transitionProductStepperEffects \
--interaction-ids product-stepper
dart run esen_seo:esen_seo_runtime \
--kind approval-checklist \
--id release-checklist \
--library package:my_app/release_checklist.dart \
--symbol transitionReleaseChecklist \
--projection-symbol projectReleaseChecklist \
--interaction-ids release-checklist-control
The stepper-effects symbol is one stateless dispatcher. Its package-owned
context identifies the Stepper, so a switch can serve several admitted ids
without returning closures or retaining state. The build-time validated
--interaction-ids list limits enhancement before the first DOM mutation;
unlisted steppers remain complete static HTML.
For a route that uses several application transitions, describe two or three bundle-capable adapter families in one bounded JSON file. Bundles admit Tabs, Carousel and either Stepper or Stepper Effects. Member order in the file does not affect the generated entrypoint or manifest:
{
"schemaVersion": 1,
"id": "product-page",
"entries": [
{
"kind": "tabs",
"library": "package:my_app/product_tabs_transition.dart",
"symbol": "transitionProductTabs"
},
{
"kind": "carousel",
"library": "package:my_app/product_carousel_transition.dart",
"symbol": "transitionProductCarousel"
}
]
}
dart run esen_seo:esen_seo_runtime \
--bundle runtime_bundle.json
For an application with several runtime routes, keep their complete artifact set in one authoritative plan instead of maintaining separate build commands:
{
"schemaVersion": 1,
"runtimes": [
{
"kind": "collection",
"id": "articles",
"library": "package:my_app/article_collection_transition.dart",
"symbol": "transitionArticles"
},
{
"kind": "bundle",
"id": "product-page",
"entries": [
{
"kind": "tabs",
"library": "package:my_app/product_tabs_transition.dart",
"symbol": "transitionProductTabs"
},
{
"kind": "carousel",
"library": "package:my_app/product_carousel_transition.dart",
"symbol": "transitionProductCarousel"
}
]
}
]
}
dart run esen_seo:esen_seo_runtime --plan esen_seo_runtimes.json
The plan accepts all standalone kinds and inline bundle entries using the same
fields as their individual commands. Configurator, editorial-workflow and
approval-checklist entries use projectionSymbol and interactionIds;
stepper-effects uses interactionIds. The plan is limited to 64 KiB and 64
runtime artifacts, rejects unknown fields and duplicate artifact identities,
and validates every entry before starting the first compiler process.
Compilation happens in a fresh sibling staging directory. Only a complete,
exact set of .js and .json pairs replaces the dedicated output directory,
so a later import, compiler or budget failure leaves its prior contents
unchanged. A successful replacement removes runtimes no longer present in the
plan. The output must be below build/ and cannot be build/ itself or a
symbolic link. CI can execute the same compilers and compare both bytes and the
complete file-name set without changing the output:
dart run esen_seo:esen_seo_runtime \
--plan esen_seo_runtimes.json \
--check
stepper and stepper-effects are the same ownership family and cannot both
appear in one bundle. Collection, configurator, editorial workflow and approval
checklist use standalone application runtimes. Measured Collection combinations
exceeded the unchanged 25 KiB artifact ceiling; the approval checklist itself
uses 23.5 KiB gzip and therefore also keeps its measured standalone boundary.
Bundle configuration rejects these kinds before compilation.
Unknown fields and kinds, duplicate families, invalid symbols, files outside
the application root and configurations above 32 KiB are also rejected before
compilation. Every admitted member independently passes the same complete
pure-Dart graph and held-state checks as a single runtime. The complete output
must remain inside the 25 KiB gzip and 512 KiB raw limits.
The command parses the complete application import/export/part graph before
compilation. It rejects Flutter, IO, browser libraries, third-party packages,
conditional and deferred imports, path escapes and invalid identifiers. It
also rejects non-const top-level or static fields, so the transition cannot
hold current state between calls. It then runs
dart compile js -O2 --csp --no-source-maps --fatal-warnings and writes a
collision-free artifact stem plus .js and a SHA-256 .json manifest below
build/esen_seo/runtimes/. Existing unambiguous single runtimes retain their
established names. A plain Stepper id beginning with effects- uses a +
separator to remain distinct from Stepper Effects, and bundle names include
their canonical member set. Compiler output above 512 KiB raw or the fixed
25 KiB gzip budget, script-tokenizer hazards and string-to-code constructors
are refused.
The manifest also binds the package runtime contract revision. This revision
is independent of the file schema and compiler version: it changes only when
newly emitted component markup is no longer compatible with an already
compiled adapter. Rebuild application runtimes after upgrading esen_seo.
Legacy or otherwise mismatched revisions are named and rejected before their
JavaScript is read, so an old artifact cannot silently disable controls on a
newly rendered page.
This is a capability and held-state boundary, not a formal proof that arbitrary Dart is referentially transparent. Keep environment reads and side effects out of the transition, and test identical action sequences on the pure, Flutter and compiled-browser paths.
Build the artifact with the same pinned Dart SDK that serves or prerenders the site. CI can compile the transition again and compare the complete output, manifest and compiler version without modifying the artifact:
dart run esen_seo:esen_seo_runtime \
--id product-tabs \
--library package:my_app/product_tabs_transition.dart \
--symbol transitionProductTabs \
--check
Select the typed identity on the route, never a JavaScript string:
SeoRoute(
path: '/product',
delivery: SeoRouteDelivery.domFirst,
applicationRuntime:
const SeoDomFirstApplicationRuntime.tabs('product-tabs'),
meta: (_) => const SeoMeta(title: 'Product'),
body: (_) => buildSeoTabsNodes(
tabs: productTabNodes,
interactionId: 'product-tabs-control',
),
);
Select all bundle members explicitly on the route. The member set is checked against the current bundle manifest before the script can be delivered:
SeoRoute(
path: '/product',
delivery: SeoRouteDelivery.domFirst,
applicationRuntime: SeoDomFirstApplicationRuntime.bundle(
'product-page',
members: const {
SeoDomFirstApplicationRuntimeKind.tabs,
SeoDomFirstApplicationRuntimeKind.carousel,
},
),
meta: (_) => const SeoMeta(title: 'Product'),
body: (_) => [
...buildSeoTabsNodes(
tabs: productTabNodes,
interactionId: 'product-tabs-control',
),
...buildSeoCarouselNodes(
slides: productCarouselNodes,
interactionId: 'product-carousel-control',
),
],
);
For a carousel route use
SeoDomFirstApplicationRuntime.carousel('product-carousel') together with
buildSeoCarouselNodes. For a collection route use
SeoDomFirstApplicationRuntime.collection('article-collection') with
buildSeoCollectionNodes. For a stepper route use
SeoDomFirstApplicationRuntime.stepper('product-stepper') together with
buildSeoStepperNodes. Select
SeoDomFirstApplicationRuntime.stepperEffects('product-stepper-effects') for
the closed effect variant. For a checklist route use
SeoDomFirstApplicationRuntime.approvalChecklist('release-checklist') with
buildSeoApprovalChecklistNodes. Runtime kind is part of the artifact
filename, so different transition families with the same logical id cannot
overwrite each other.
Finally give the server or prerenderer the build-owned directory:
final runtimes = SeoDirectoryRuntimeStore('build/esen_seo/runtimes');
seoBotMiddleware(
routes: seoRoutes,
siteBase: siteBase,
domFirstRuntimeStore: runtimes,
);
await prerenderSite(
routes: seoRoutes,
siteBase: siteBase,
domFirstRuntimeStore: runtimes,
);
On first load through a SeoDirectoryRuntimeStore, the store checks manifest
schema, runtime contract revision, kind, logical id, bundle members where
applicable, SHA-256, byte sizes and the expected Dart compiler version, then
caches the verified artifact for that store's lifetime. Missing, stale,
foreign or inconsistent artifacts fail by name instead of falling back to
package logic or Flutter. Treat the build directory as trusted deployment
input: the hash detects a mismatched script and manifest, but cannot
authenticate them against an actor who can replace both. A route may select
either the corresponding package feature or one matching application runtime
member, never both. Cubit or another Flutter state manager may dispatch the
same pure transition on the Flutter side, but it is not compiled and is not a
dependency of esen_seo.
For a hybrid site that serves Flutter and DOM-first routes from the same origin, disable Flutter's root-scoped application-shell cache:
flutter build web --release --pwa-strategy=none
Alternatively, own a custom service worker whose navigation and asset scope excludes every DOM-first route. An existing root-scoped offline-first worker can otherwise intercept those navigations or fetch Flutter artifacts in the background even though the DOM-first document itself references none.
On a DOM-first route a resolver result is final because no Flutter app exists
there as a fallback. Therefore every SeoRedirect and every error document is
served to humans and crawlers even when applyResolverRedirects is
SeoRedirectScope.botsOnly or .off. auditSeoParity excludes these routes:
the route body is the presentation, not a second tree to compare with Flutter.
Progressive interactions #
Visible HTML can opt into package-owned progressive enhancement. SeoTabs,
SeoNavMenu, SeoCarousel, SeoStepper and SeoCollection are currently
supported: Flutter keeps its native stateful widgets on iOS, Android and in the
running web app, while the visible semantic page gains accessible controls
from a small vanilla JavaScript runtime.
SeoTabs(
interactionId: 'product-tabs', // stable DOM id: enables enhancement
interactionLabel: 'Product information',
tabs: productTabs,
);
SeoNavMenu(
interactionId: 'primary-nav',
label: 'Primary navigation',
items: navigationItems,
);
SeoCarousel(
interactionId: 'product-carousel',
interactionLabel: 'Product gallery',
slides: productSlides,
);
SeoStepper(
interactionId: 'checkout-steps',
interactionLabel: 'Checkout',
steps: checkoutSteps,
);
await prerenderSite(
routes: seoRoutes,
siteBase: siteBase,
renderMode: SeoRenderMode.visibleShell,
stylesheet: seoDefaultStylesheet,
enableInteractions: true,
interactionNonce: cspNonce, // optional
);
The source contains every tab panel, carousel slide and step body as ordinary
sections or ordered list items with headings. JavaScript creates controls only
after validating that structure, uses textContent for labels, and skips the
invisible inert mirror after Flutter takes over. With JavaScript disabled,
nothing disappears and every link, slide, panel and step remains readable.
Navigation remains a native list of links rather than becoming an ARIA
application menu: only branches receive disclosure buttons, and linked parents
keep a separate navigation target. Carousels do not autoplay. Steppers do not
translate validation, completion rules, form state or callbacks. This is an
explicit component contract, not a compiler that attempts to translate
arbitrary Dart callbacks or application state into JavaScript.
For a standalone semantic page with no Flutter bootstrap, use
SeoPage.visibleFromNodes(...); it applies the same default stylesheet and
interaction runtime at the trusted document boundary. interactionNonce is
placed on the generated style and script tags; the visible shell's existing
inline style attribute still needs to be allowed separately by a strict CSP.
Styling is yours to control. class and style pass through .seo()
like any other attribute, so the shell can carry your own CSS:
Text('Willkommen').seo(SeoTextTag.h1, {'class': 'hero-title'});
Column(children: [...]).seo(SeoContainerTag.section, {'class': 'card'});
The CSS is inlined into the <head> of every prerendered file — an
external stylesheet would cost a round trip and give away exactly the
head start the shell is for. seoDefaultStylesheet is a ~1 KB
classless baseline scoped to the container. Give any custom CSS an
opaque background — otherwise Flutter's still-empty surface shows
through while it boots.
The theme bridge — the shell in your app's design #
Hand-written shell CSS drifts: you change the app theme, the CSS keeps
last month's colors. The theme bridge generates the stylesheet from
your ThemeData instead — colors, the Material type scale, weights
and the font family — and guards it against drift in the CI you
already have:
// lib/theme.dart — the ONE theme source, used by the app AND the test
ThemeData buildLightTheme() => ThemeData(colorSchemeSeed: Colors.teal);
ThemeData buildDarkTheme() =>
ThemeData(colorSchemeSeed: Colors.teal, brightness: Brightness.dark);
// test/seo_theme_css_test.dart — verifies on every run, regenerates on
// --dart-define=esenSeoUpdate=true
test('the shell stylesheet matches the app theme', () {
checkOrUpdateSeoThemeCss( // from package:esen_seo/testing.dart
seoStylesheetFromTheme(buildLightTheme(), darkTheme: buildDarkTheme()),
);
});
// bin/prerender.dart — pure Dart, imports the generated constant
prerenderSite(routes: seoRoutes, siteBase: siteBase,
renderMode: SeoRenderMode.visibleShell, stylesheet: seoThemeCss);
The generated lib/seo_theme.g.dart is a plain committed constant —
the same shared-file pattern as your route table, and the reason this
works at all: ThemeData needs Flutter, prerenderSite runs without
it, and a string is the one thing both sides can hold. Change the theme
without regenerating and the guard test fails with the exact command to
run. The result replaces seoDefaultStylesheet — pass one or the
other, never both.
One assumption to know about: the generated CSS is a function of the Flutter version (Material color roles and type values shift between releases), so dev and CI should run the same pinned Flutter — which disciplined teams do anyway. After an SDK upgrade, regenerate; the guard's error message tells both toolchains apart from a real theme change.
Your dark theme rides along as a prefers-color-scheme block (only
the tokens that differ). An app that forces themeMode passes
mode: SeoThemeMode.dark (or .light) — that flag lives on
MaterialApp, not on ThemeData, so the bridge cannot read it.
For a manual light/dark control, generate the stylesheet with
enableManualTheme: true, place a controlled SeoThemeToggle in the Flutter
UI and select SeoDomFirstFeature.themeToggle on the corresponding DOM-first
routes. The permanent HTML follows the operating-system preference until the
visitor chooses explicitly; the closed light/dark value is then restored
before first paint and reused across pages. Without JavaScript the control
stays absent and the complete document still follows the system theme. Use
exactly one toggle per DOM-first document; ambiguous duplicate controls stay
inert. Manual selection is intended for SeoThemeMode.system, since a forced
mode deliberately emits only its selected palette.
Set compactOnSmallScreens: true to keep the full label on larger layouts and
show only the sun or moon symbol at widths up to 600 pixels. Flutter and the
DOM-first presentation use the same breakpoint; the tooltip and accessible
name remain complete in both layouts.
Every value is validated against an allow list before it becomes CSS; what fails validation is dropped and the shell degrades to the default look rather than breaking.
Deliberate deviations, so nothing surprises you: headings follow your
Material scale, which means h2/h3 render a step larger than the
default stylesheet and keep the theme's weight — Material 3 headings
are regular, not bold. h4–h6 get rules for the first time.
Paragraphs read as bodyLarge (16 px, the browser baseline) rather
than Flutter's 14 px default text; pass bodyRole: SeoBodyRole.bodyMedium for 1:1 parity. Your bundled font is named
first in a system-font fallback chain, but the browser has not loaded
its file — if you want the real face during boot, add your own
@font-face pointing at the font asset the web build ships anyway
(assets/fonts/…), with font-display: swap, and append it to the
generated CSS. What the bridge does not mirror: elevation, shapes,
ink effects — the shell is a document, not a widget tree.
Honest limits: this is a handoff, not React-style hydration —
Flutter renders to canvas, so it can never adopt the DOM. The shell
will resemble your app, not match it pixel for pixel (we know the
semantic tree, not the widget geometry). Before the engine is up, real
<a href> links work but generic buttons and forms do not. The curated action
form is available only on a permanent DOM-first route. And the mode only
applies to prerendered pages — flutter run has no prerendered HTML to
show, and EsenSeo.init() must run in the app so the handoff happens.
AI crawlers & instant indexing #
llms.txt is a markdown manifest of your site for
AI assistants. Be clear-eyed about it: it is a young proposal, adoption
is uneven, and Google has said it does not use it for Search — treat it
as a bet, not a traffic channel. What makes it worth having anyway is
that it costs you nothing: esen_seo generates it from the route table
you already maintain (served by the middleware, written by
prerenderSite, or standalone):
seoLlmsTxt(routes: seoRoutes, siteBase: siteBase)
// # Esen Software
// > Flutter apps with real SEO.
//
// ## Pages
//
// - [Home](https://esen.software/): Flutter apps with real SEO.
// - [Docs](https://esen.software/docs): How esen_seo works.
llms-full.txt goes one step further: the complete page content —
your routes' server-side bodies converted to markdown — in one file,
so an AI assistant reads the whole site in a single request
(seoLlmsFullTxt(...), served and written automatically as well).
And instead of waiting for the next crawl, push changed pages actively via IndexNow (Bing, Seznam, Naver, Yandex — Google still crawls via sitemap):
// after a deploy or content update:
await submitIndexNow(
siteBase: siteBase,
key: 'a1b2c3d4e5f6a7b8', // self-chosen, 8–128 hex chars
paths: ['/', '/blog/neuer-post'],
);
The protocol requires the key to be readable at
https://your-site/<key>.txt — seoBotMiddleware(indexNowKey: …)
serves it and prerenderSite(indexNowKey: …) writes it, so there is no
extra hosting setup.
Trade-off: prerendered pages are a build-time snapshot — for frequently changing content use the SSR server above instead.
Modes #
| Mode | Behaviour |
|---|---|
SeoMode.safe |
Default. Renders everything, smart defaults fill gaps. |
SeoMode.strict |
Like safe, plus debug warnings for widgets without .seo() and for blocked tags. |
EsenSeo.init(mode: SeoMode.strict);
How it compares #
| Package | Approach | Add-on for your existing app | HTML in page source | No headless Chrome | Runtime SSR for dynamic content |
|---|---|---|---|---|---|
| esen_seo | Widget mirror + pure-Dart SSR and prerendering | ✅ | ✅ | ✅ | ✅ |
| sfwf | SSR/prerendering via Puppeteer | ✅ | ✅ | ❌ | ✅ |
| hydraline_flutter | Semantic widgets + build-time SSG | ✅ | ✅ | ✅ | ❌ (build-time only) |
| flenx / Jaspr | Dart web framework — you build the site in its components | ❌ (separate site) | ✅ | ✅ | ✅ |
| seo, flutter_seo, seo_renderer | Client-side HTML mirror, no server part | ✅ | ❌ (JS required) | ✅ | ❌ |
| meta_seo | Head meta tags only, no body HTML | ✅ | ❌ (head only) | ✅ | ❌ |
In short: esen_seo is — to our knowledge — the only add-on for your existing Flutter app that covers both paths without a browser on the server: baked static HTML for CDN hosting and a runtime pure-Dart SSR server for dynamic, database-driven pages — plus typed JSON-LD builders, hreflang and 301-redirect middleware.
Performance #
The hot paths are allocation-conscious and continuously benchmarked. Ballpark numbers from an Apple-silicon laptop (Dart VM):
| Hot path | Cost |
|---|---|
| Rendering a ~2,800-node widget mirror to HTML | ~1 ms |
BotDetector.isBot per request (precompiled matcher) |
~0.3 µs |
| Route-table lookup across 21 routes | ~1.5 µs |
Reproduce them from a repository checkout:
dart run benchmark/hot_paths.dart.
An honest word on Core Web Vitals #
esen_seo solves Flutter Web's crawling and indexing problem:
crawlers get real semantic HTML, meta tags, structured data and clean
URLs. What it cannot do is make the Flutter engine smaller — Google
also measures real-user loading performance (Core Web Vitals via
CrUX), and a Flutter web app ships a multi-megabyte engine. To get the
most out of it: build with --wasm, use deferred loading for big
routes, mark below-the-fold images with lazy: true, and use the
prerenderer or SSR server so the first response already carries
content. SEO ranking is content × technique × performance — esen_seo
covers the first two and helps with the third.
Status #
Young package under active development, covered by more than 980 unit and widget tests — the pipeline (extensions, smart defaults, meta/OpenGraph, JSON-LD, routing, bot middleware, prerendering), the widget library, and a set of tests that feed hostile input through every path to HTML.
What the renderer guarantees, and what it does not #
Everything the package emits passes a tag and attribute policy in the
renderer itself, so a page assembled from untrusted content (a CMS, say)
cannot turn into executable markup on any of the three paths — the
Flutter mirror, the SSR middleware or the prerenderer. No <script>, no
event handler, no javascript: URL, no positioning or stacking that
would lift an element out of the mirror. That holds no matter where the
SeoNode came from.
It does not mean untrusted content is visually harmless. In
SeoRenderMode.visibleShell the prerendered HTML is the page the user
sees while Flutter boots, and content shown to a user can mislead them.
A property allow list cannot prevent that, and a longer one would not
help: an empty <a> sized width:100vw;height:100vh paints nothing and
still takes the click, using two properties every document needs. A
later sibling with margin-top:-100vh lies over an earlier one, so the
real headline stays visible while its clicks go somewhere else. Plain
visible text linking somewhere unexpected works just as well and needs
no CSS at all.
Read the first paragraph precisely, then: the policy keeps content inside the mirror's container. It does not police what that content does to itself once it is there.
Note also what the property list governs: inline styles only. A
class value names a rule in your stylesheet, so if that stylesheet
has a rule with position: fixed, untrusted content can reach it by
name and the inline allow list never sees it. seoDefaultStylesheet is
classless and offers nothing to target, but your own CSS may.
So the boundary is: the package makes content non-executable; it does
not make it honest. In the default seoOnly mode this is moot — the
mirror is clipped to zero size, pointer-events:none and inert, so
nothing inside it can be seen or clicked either way. If you enable the
visible shell and your route bodies come from a source you do not
control, review that content the way you would review any user-generated
content before displaying it. That is an application decision; the
renderer cannot make it for you.
Issues and feedback are welcome on GitHub.
License & contributing #
esen_seo is licensed under the Apache License 2.0 — free for any use, commercial or not, with an explicit patent grant. The software is provided "AS IS", without warranties or conditions of any kind and without liability (sections 7 and 8 of the license).
Contributions are welcome — please read CONTRIBUTING.md first; pull requests are accepted under our CLA.