SEO
An SEO field group on the collections you name, and a sitemap of published content served over plain HTTP.
Alpha (
0.x): pin your versions.The stability boundary is
@nextlyhq/plugin-sdk(see API stability). This package's own exports,seoPlugin, its option types and the sitemap generators below, are not covered by that guarantee and are alpha. Pin your versions and read the release notes before upgrading.
The SEO plugin adds one field group to the collections you name, and serves a sitemap of their published entries.
It is framework-agnostic and has no next dependency, so it works the same in an
integrated site, a headless project and an admin-only install.
It does not render <meta> tags. Turning the stored fields into Next.js Metadata is
core work, done by buildMetadata from nextly/runtime. The split is deliberate: the
plugin owns the data, and delivery belongs to whatever is serving the pages. See
Routing and SEO for that half.
Installation
npm install @nextlyhq/plugin-seoIts peer dependencies are nextly and @nextlyhq/plugin-sdk, and while Nextly is in
alpha the manifest pins nextly at the matching lockstep version: every publishable
package ships on one train, so install the plugin and the core at the same version.
The plugin also declares a compatibility range of >=0.0.2-alpha.55 for the runtime
check. That is a floor, not an invitation: it records the oldest core that exported
everything the plugin imported at the time, and the import graph has moved since, so a
deliberately older pairing can be refused by npm or fail to resolve at module load.
Basic setup
import { defineConfig } from "nextly";
import { seoPlugin } from "@nextlyhq/plugin-seo";
export default defineConfig({
plugins: [seoPlugin({ collections: ["pages", "posts"] })],
});collections is the whole of the required configuration. Collections you do not name are
untouched.
What lands on an entry
Every field goes inside a single group named seo, so entries always expose their SEO data
in one predictable place:
entry.seo?.metaTitle;
entry.seo?.metaDescription;
entry.seo?.ogImage;
entry.seo?.canonical;
entry.seo?.noindex;Read the group defensively. Adding the plugin to a collection that already has rows
adds a nullable column, and those existing rows carry null until they are next saved. A
bulk insert can omit it too, because that path does not apply nested defaults. So
entry.seo is optional even though every field inside it is. Core's own
MetadataEntry type declares it as seo?: SeoMetaInput | null for this reason.
The default fields
| Field | Type | Notes |
|---|---|---|
metaTitle | text | Capped at 60 characters, which is roughly where search engines truncate a title |
metaDescription | textarea | Capped at 160 characters, for the same reason |
ogImage | upload | Relates to media. The social preview image |
canonical | text | A per-page canonical URL |
noindex | checkbox | Defaults to false. Drops the entry from this plugin's sitemap. The robots directive that actually tells a crawler comes from buildMetadata, which is core |
Ticking noindex does not on its own keep a page out of search results. The plugin
does two things with it, and one of them is nothing: the entry leaves this sitemap, and
that is all. A crawler that reaches the page by any other route still indexes it, because
the robots meta tag is emitted only if your app calls buildMetadata and renders what
it returns. An inbound link from another site, one of your own pages still linking to it,
and a URL already in an index are each enough.
That is the same split as everywhere else on this page: the plugin stores, core renders.
If you rely on noindex, make sure the pages for that collection actually render metadata
from buildMetadata.
All five are optional. canonical and noindex ship by default rather than being left for
you to add, because a canonical URL and a search opt-out are baseline controls rather than
advanced ones.
Custom fields
Replace the whole set when a project needs a different shape:
import { defineConfig } from "nextly";
import { text } from "@nextlyhq/plugin-sdk";
import { seoPlugin } from "@nextlyhq/plugin-seo";
export default defineConfig({
plugins: [
seoPlugin({
collections: ["posts"],
fields: [
text({ name: "metaTitle", label: "Meta Title", maxLength: 60 }),
text({ name: "focusKeyword", label: "Focus Keyword" }),
],
}),
],
});The field factories come from @nextlyhq/plugin-sdk, which is the stable
plugin-authoring surface and the type fields is declared against.
fields replaces the defaults rather than adding to them, so list everything you want.
Overrides stay nested under seo: the example above reads at entry.seo.focusKeyword.
Configuration
| Option | Type | Default | What it does |
|---|---|---|---|
collections | string[] | required | The collections that get the seo group |
fields | FieldConfig[] | the five above | The fields placed inside the group |
baseUrl | string | the request origin | Absolute origin for <loc> in the sitemap |
basePath | string, or a function, or absent | /<collection> | Where each collection's route is mounted |
urlFor | (entry, collection) => string | null | undefined | /<collection>/<slug> | Builds a whole entry path. Returning null or undefined drops that entry |
sitemap | boolean or { collections } | true | Controls the public sitemap route |
The sitemap
The plugin mounts one public route. Plugin routes are namespaced under
/plugins/<plugin-name> relative to wherever the dynamic handler is mounted, and the
scaffolded app mounts it at src/app/admin/api/[[...params]]/route.ts, so in a default
project the sitemap is served at:
GET /admin/api/plugins/@nextlyhq/plugin-seo/sitemap.xml/api/plugins/... returns 404 unless the project has deliberately added a second
catch-all there. Check where your own createDynamicHandlers route file sits if the URL
above does not resolve.
It lists entries from the configured collections, generated per request. There is no
next dependency involved, which is what lets a headless consumer with no
app/sitemap.ts read it.
"Published" means the built-in lifecycle, and only that. The status: published
filter is applied to a collection that declares status: true, read from the collection
itself. A collection without that lifecycle has no unpublished state as far as the
platform is concerned, so every row is listed, including rows you consider drafts via
an ordinary field you happen to have named status. Filtering on a same-named custom
field would be the plugin guessing at your semantics. If you keep drafts that way, exclude
that collection from the sitemap.
One document, and the protocol bounds it. The builder stops once it has emitted
50,000 URLs, or sooner if the serialized document would pass 50 MB, because a
single <urlset> past either is invalid. It stops rather than failing, so a site above
those limits gets a sitemap that is quietly short with no error to say so.
Both limits are on the whole document, not per collection. Collections are walked in the order you configure them, so a first collection large enough to reach the cap means the later ones contribute nothing at all and never say so.
The cap counts URLs emitted, not rows read, and the route is not rate limited.
Those two facts are worse together than apart. Every exclusion is a skip that keeps
paging: an entry with seo.noindex, an unusable slug, an off-origin canonical, or a
location over 2,048 characters is dropped without counting toward the 50,000.
The builder still stops the moment it holds 50,000 acceptable URLs, so a large collection that mostly yields valid entries is not read in full. The expensive case is a configured set that cannot reach the cap: if it produces fewer than 50,000 URLs and stays under 50 MB, then every row of every collection is read on every request, however many rows that is.
Nextly's rate limiter skips paths beginning /admin/api/ through defaultSkipAdminApi,
which is exactly where the scaffold mounts plugin routes, so on a default install this
public endpoint is exempt out of the box. A handler mounted anywhere else goes through the
ordinary read limiter, so this is about the scaffolded layout rather than about the
plugin. With no baseUrl set the response is no-store, so nothing in front absorbs the
repeats either.
Three things that each help, in the order they are worth doing:
- Set
baseUrl, so the document is cacheable at all. - Cache the route that generates the document, at
/admin/api/plugins/@nextlyhq/plugin-seo/sitemap.xml. Caching only/sitemap.xmlworks when that address rewrites or proxies. Behind a redirect the crawler follows on to the plugin route, so it is that route's cache, not the one in front of/sitemap.xml, that decides whether the origin rebuilds. - Pass a
skipto the rate limiter that does not exempt this path.
These exports cannot shard, so do not reach for them from generateSitemaps().
SitemapOptions takes collections, baseUrl, urlFor, basePath, pageSize and
maxBytes. There is no offset, cursor or predicate, and buildSitemapUrls always starts
at the first page and returns at the global cap, so calling it once per shard yields the
same first 50,000 URLs every time.
Until a shard-aware option exists, a corpus past the cap needs a sitemap index built from your own queries, splitting the work by something you control: one document per collection, per locale, or per date range, each within the cap.
The sitemap is public and reads as the system. It enumerates every published entry's
URL in the collections it covers, bypassing per-collection read access, because that is
what a sitemap is for. Any collection whose entries should not be publicly enumerable
(owner-scoped, role-gated, or internal) must be kept out of it. Narrow the list with
sitemap: { collections: [...] }, or turn the route off with sitemap: false.
A collection can carry SEO fields without appearing in the sitemap. The two lists are separate for exactly this reason.
Getting the URLs right
The one part of an entry's URL the plugin cannot work out is where the route is mounted: that is decided by where the route file sits in your app directory, which is invisible from here. Everything after the prefix is derived from the route's own slug handling.
So declare the mount, and let the rest be derived:
import { defineConfig } from "nextly";
import { seoPlugin } from "@nextlyhq/plugin-seo";
export default defineConfig({
plugins: [
seoPlugin({
collections: ["pages", "posts"],
baseUrl: "https://example.com",
// Pages render at /about, not /pages/about. Posts are mounted at /blog.
basePath: collection => (collection === "pages" ? "" : "/blog"),
}),
],
});Returning null from basePath excludes that whole collection from the sitemap.
urlFor has the same escape one level down: it receives the entry and its collection, and
returning null or undefined drops that single entry, which is how an entry with no
stable public URL stays out.
urlFor builds the whole path instead, and takes precedence over basePath:
import { defineConfig } from "nextly";
import { seoPlugin } from "@nextlyhq/plugin-seo";
export default defineConfig({
plugins: [
seoPlugin({
collections: ["posts"],
baseUrl: "https://example.com",
urlFor: entry => `/blog/${String(entry.slug)}`,
}),
],
});Prefer basePath where only the prefix differs. A hand-built path has to reproduce
correctly what the route already answers for nested, encoded and unservable slugs, and
basePath keeps that half derived.
seo.canonical has the last word, after urlFor has run. A stored canonical is
resolved against the site origin, and then:
| The canonical is | What the sitemap does |
|---|---|
Same origin (/about or https://example.com/about) | Replaces the generated <loc> |
| A different origin | Drops the entry entirely |
| A non-http scheme, or carrying credentials | Ignored, generated URL kept |
| Unparseable | Ignored, generated URL kept |
So an entry can vanish from the sitemap, or appear at a path your urlFor never returned,
because of a value an editor typed into a field. That is the intended meaning of a
canonical, and it is worth knowing before debugging the callback.
Two things basePath does not do. It does not claim the mount's own root is served: an
entry with an empty slug is skipped whatever the prefix says, because whether that root
routes depends on the route file rather than on the prefix. List a homepage with urlFor.
And it is ignored entirely when urlFor is supplied, which already owns the whole path.
baseUrl, and why it affects caching
baseUrl must be an absolute http(s) origin: no path, query, fragment or credentials. An
invalid value throws when the plugin is constructed rather than when the route is first
called, so a typo fails at boot.
Leaving it out is correct for a single-origin deployment: the route derives the origin from the incoming request. Behind a proxy that rewrites the host it is wrong, and the URLs point at the internal host.
Because a request-derived origin comes from a spoofable Host header, a sitemap built that
way is served with cache-control: no-store, so an intermediary cannot cache one host's
document and serve it for another. Configure baseUrl if you want a cacheable sitemap.
Advertising it to crawlers
The mounted path is not where a crawler looks, so expose it at /sitemap.xml and name
that in robots.txt.
A rewrite and a redirect both work for the crawler, but they cache in different
places. A rewrite or proxy is served under /sitemap.xml, so a cache in front of that
address holds the answer. A redirect sends the crawler on to the plugin route, so it is
that route's own cache that decides whether the origin rebuilds. Either is fine; what is
not fine is caching one address and generating at the other.
Core ships nextlySitemap and nextlyRobots for a Next.js app; see
Routing and SEO.
Building a sitemap yourself
The generator is exported, so a project that wants its own route can use the same code
rather than a second implementation: generateSitemap, buildSitemapUrls,
serializeSitemap, defaultUrlForEntry and escapeXml, with the SitemapUrl,
SitemapOptions, SitemapServices and UrlForEntry types.
Turning the fields into tags
The plugin stores; core renders. buildMetadata reads the seo group and returns a Next.js
Metadata object, and Open Graph, robots and hreflang come from there rather than from the
plugin.
Populate ogImage, or the social image is silently dropped. It is an
upload relation, so at depth: 0 (the default for createPublicContentRoute)
it is an unresolved id rather than an object. buildMetadata only emits an
image when seo.ogImage is a populated record carrying a url, so the page
gets a title and a description, no image, and no error. Fetch the entry at a
depth that resolves the relation, or pass fallback.image.
The buildMetadata section of Routing and SEO
covers it, including what happens when a field is empty.
Next steps
- Routing and SEO for rendering the tags and serving the sitemap
- Plugins for the other plugins that ship
- API stability for what is semver-protected