Skip to main content

Sitemap & robots.txt

EverShop automatically generates a sitemap and serves it at /sitemap.xml, so search engines can discover every public page of your store. It also serves a default robots.txt that points crawlers at the sitemap. Both are on by default — there is nothing to configure to get a working sitemap.

What it does

  • Enumerates every public storefront URL — products, categories, CMS pages, landing pages, blog content, and the homepage — and writes them into a sitemap index (/sitemap.xml) plus one child file per content type (/sitemap-products.xml, /sitemap-categories.xml, …).
  • Regenerates on a schedule (every 30 minutes by default) with a cron job, but only does the work when something actually changed.
  • Serves the files as static files once generated, so requests cost nothing extra.
  • Emits hreflang alternate links when your store has more than one language enabled.
  • Serves a sensible default robots.txt (with an absolute Sitemap: line) when you don't provide your own.

How it works

The sitemap files are written to your project's public/ folder. EverShop serves everything in public/ as static files, so once public/sitemap.xml exists a request for /sitemap.xml is served directly from disk — no application code runs.

Three things keep it up to date:

MechanismWhenWhat it does
Static servingEvery request, when the file existsStreams the file from public/. The common case.
Cron jobEvery 30 minutesRebuilds the files if the content changed (or the last build is older than maxAge).
On-demandA request for a missing sitemap fileGenerates the set on the spot, then serves it. Covers the first request after startup.

The "did anything change" check is a cheap fingerprint over each content type — the count of included URLs, their newest updated_at, and a hash of the URLs themselves — plus the set of enabled languages. Hashing the URLs means a change like assigning a product to a category (which changes its URL) is detected even though the product's updated_at isn't affected. If the fingerprint is unchanged and the files are fresh, regeneration is skipped.

What is included

Each content type contributes the URLs that are actually reachable on the storefront:

ContentIncluded whenLast modified
ProductsEnabled and visibleProduct updated_at
CategoriesEnabledCategory updated_at
CMS pagesPublishedPage updated_at
Landing pagesEnabled and within the publish windowLanding page updated_at
Blog postsPublishedPost updated_at
Blog categoriesPublishedCategory updated_at
Blog tagsAllTag created_at
Static routesListed in sitemap.staticPaths (default: the homepage)

The URL of each entry is its friendly URL — the same path a visitor sees, including nested category paths for products (for example /women/shoes/awesome-shoes).

note

Google uses the <lastmod> value but ignores <changefreq> and <priority>. EverShop still emits changefreq/priority because other search engines use them; you can tune or ignore them via configuration.

Multi-language stores

EverShop serves each enabled language on a shared URL prefix — the default language unprefixed (/shoes) and each additional language prefixed (/de/shoes, /fr/shoes). When more than one language is enabled, the sitemap lists every language variant of each URL and connects them with hreflang alternate links (including a self-reference and x-default), which is what Google expects for localized pages:

<url>
<loc>https://example.com/shoes</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/shoes"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/shoes"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/shoes"/>
</url>

A single-language store pays no overhead — the sitemap contains just the canonical URLs, with no hreflang markup. The enabled languages come from your store's language settings; there is nothing sitemap-specific to configure.

Configuration

Everything lives under the sitemap key in your configuration file (config/default.json or an environment-specific file). All keys are optional — these are the defaults:

{
"sitemap": {
"enabled": true,
"schedule": "*/30 * * * *",
"maxUrlsPerFile": 50000,
"maxAge": 86400000,
"hreflang": true,
"staticPaths": ["/"],
"changefreq": {
"product": "daily",
"category": "daily",
"cmsPage": "weekly",
"landingPage": "weekly",
"static": "daily"
},
"priority": {
"product": 0.7,
"category": 0.8,
"cmsPage": 0.5,
"landingPage": 0.6,
"static": 1.0
},
"robots": {
"enabled": true,
"disallow": ["/admin/", "/checkout", "/account", "/cart", "/api/"]
}
}
}
KeyDescription
enabledTurn the whole feature on or off.
scheduleCron expression for the regeneration job.
maxUrlsPerFileSplit a content type into multiple child files once it exceeds this many URLs (the sitemaps.org limit is 50,000).
maxAgeForce a full regeneration when the last build is older than this many milliseconds, regardless of the change check.
hreflangEmit hreflang alternates on multi-language stores.
staticPathsFixed storefront paths to include (for example ["/", "/blog"]).
changefreq / priorityPer-content-type hints (used by some search engines; ignored by Google).
robotsWhether to serve a dynamic robots.txt, and which paths to disallow.

The store's base URL (used for the absolute <loc> values and the robots.txt Sitemap: line) comes from shop.homeUrl, or the EVERSHOP_HOME_URL environment variable. Set it so the sitemap contains fully-qualified production URLs.

robots.txt

If your store does not already have a robots.txt, EverShop serves a dynamic default that disallows non-indexable areas and advertises the sitemap with an absolute URL:

User-agent: *
Disallow: /admin/
Disallow: /checkout
Disallow: /account
Disallow: /cart
Disallow: /api/

Sitemap: https://example.com/sitemap.xml

A physical robots.txt always wins. Your theme may ship one in its public/ folder — if so, that file is served and the dynamic default (and the robotsTxt setting below) do not apply. To use the dynamic version, remove the theme's robots.txt; to keep a static one, add your own Sitemap: line to it.

When no physical file exists, you can override the dynamic default entirely with a robotsTxt setting (raw text), saved through the settings API.

Adding your own URLs (extensions)

The sitemap collects URLs from collectors. Core registers collectors for products, categories, CMS pages, landing pages, static routes, and blog (posts, categories, tags) — and an extension registers its own the exact same way, from its bootstrap file. Anything you register participates in everything below automatically: multi-language hreflang, 50,000-per-file chunking, the change-detection skip, cron + on-demand regeneration, and serving at /sitemap-<name>.xml with an entry in the index. No other wiring.

There are two ways, depending on whether your entity already has url_rewrite rows.

If your entity has url_rewrite rows

This is the common case (it's how products, categories, and blog work). Use createEntityCollector — you only describe the table, filter, and hints:

import {
registerSitemapCollector,
createEntityCollector
} from '@evershop/evershop/base/services/sitemap';

export default function () {
registerSitemapCollector(
createEntityCollector({
name: 'brands', // served at /sitemap-brands.xml
table: 'brand',
entityType: 'brand', // the entity_type your url_rewrite rows use
where: 'e.status = 1', // optional SQL filter over the entity alias `e` (default: all rows)
updatedAtColumn: 'updated_at', // optional; use e.g. 'created_at' if there's no updated_at
changefreq: 'weekly',
priority: 0.6
})
);
}

It joins url_rewrite, reads each row's request_path for the URL and your timestamp column for <lastmod>, and implements the change fingerprint for you (including the path hash).

If your entity has custom URLs

Return one entry per public URL. Each entry's path is a canonical, root-relative path — do not prepend your domain or a locale prefix; EverShop turns it into the absolute, localized <loc>:

import { registerSitemapCollector } from '@evershop/evershop/base/services/sitemap';
import { pool } from '@evershop/evershop/lib/postgres';
import { select } from '@evershop/evershop/lib/postgres/query';

export default function () {
registerSitemapCollector({
name: 'authors',
async collect() {
const rows = await select('slug', 'updated_at')
.from('author')
.where('active', '=', true)
.execute(pool);
return rows.map((row) => ({
path: `/author/${row.slug}`, // → https://yourstore.com/author/<slug>
lastmod: new Date(row.updated_at).toISOString(),
changefreq: 'weekly',
priority: 0.5
}));
}
});
}

Rules and the optional fingerprint

  • Register from bootstrap. The registry locks after bootstrap; calling registerSitemapCollector from a request or middleware throws.
  • name is the child file name (sitemap-<name>.xml) and appears in the index — keep it a lowercase, URL-safe slug.
  • collect() returns canonical root-relative paths — the pipeline handles the base URL, locale prefixing, and hreflang.

To join the "skip when unchanged" optimization, implement getFingerprint() — the count of included URLs, their newest modification timestamp, and a hash of the paths (so a URL change is detected even when timestamps don't move):

registerSitemapCollector({
name: 'authors',
async collect() {
/* ... */
},
async getFingerprint() {
return { count: 42, maxUpdatedAt: '2026-07-05T10:00:00.000Z', pathsHash: 'abc123' };
}
});

createEntityCollector does this for you; a hand-written collector that omits getFingerprint() still works — it just forces a full regeneration on every run.

Regenerating on demand

You can trigger a regeneration programmatically — for example from an admin action:

import { generateSitemap } from '@evershop/evershop/base/services/sitemap';

await generateSitemap({ force: true });

Without force, generation is skipped when nothing changed.



Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.