You're reading docs for Nextly Alpha. APIs may change between releases.

Plugins

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-seo

Its 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

FieldTypeNotes
metaTitletextCapped at 60 characters, which is roughly where search engines truncate a title
metaDescriptiontextareaCapped at 160 characters, for the same reason
ogImageuploadRelates to media. The social preview image
canonicaltextA per-page canonical URL
noindexcheckboxDefaults 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

OptionTypeDefaultWhat it does
collectionsstring[]requiredThe collections that get the seo group
fieldsFieldConfig[]the five aboveThe fields placed inside the group
baseUrlstringthe request originAbsolute origin for <loc> in the sitemap
basePathstring, 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
sitemapboolean or { collections }trueControls 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:

  1. Set baseUrl, so the document is cacheable at all.
  2. Cache the route that generates the document, at /admin/api/plugins/@nextlyhq/plugin-seo/sitemap.xml. Caching only /sitemap.xml works 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.
  3. Pass a skip to 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 isWhat the sitemap does
Same origin (/about or https://example.com/about)Replaces the generated <loc>
A different originDrops the entry entirely
A non-http scheme, or carrying credentialsIgnored, generated URL kept
UnparseableIgnored, 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