Contents

Grav IndexNow Plugin: automatically submit pages to search engines

โšก In short

Grav has no IndexNow plugin in its official catalog. This homemade plugin fills the gap by automatically submitting modified URLs to api.indexnow.org on every page save โ€” whether through the Grav admin or the MCP plugin โ€” no manual intervention, no cron job, no external dependency.

Source code available on GitHub:

๐Ÿง  Why

IndexNow is an open protocol that instantly notifies compatible search engines (Bing, Yandex) when a page is created or modified. Without it, search engines wait for their next crawler pass โ€” which can take hours or days.

Several reasons motivated this development:

  • No native plugin: unlike WordPress or Shopify, Grav has no IndexNow plugin in its catalog
  • Cloudflare Crawler Hints already active but limited to cache invalidations โ€” not content modifications
  • Full control: explicitly submit all 3 URL variants (/route, /fr/route, /en/route) on every save
  • Traceability: every submission is logged in grav.log with the returned HTTP code

๐Ÿ”ง What was done

๐Ÿ“ Plugin structure

A minimal Grav plugin consists of 3 files:

/var/www/grav/user/plugins/indexnow/
โ”œโ”€โ”€ indexnow.php          โ† main logic
โ”œโ”€โ”€ blueprints.yaml       โ† admin field definitions
โ””โ”€โ”€ indexnow.yaml         โ† default values

๐Ÿ” Finding the right admin hook

The first obstacle was identifying the Grav event triggered when saving a page in the admin. The onPageSave hook โ€” intuitive but nonexistent โ€” doesn’t work. Searching the source code revealed the right candidate:

grep -rh "fireEvent(" /var/www/grav/system/src/ | sort -u
# โ†’ onAdminAfterSave emitted by AdminController.php after each successful save
Hook testedResult
onPageSaveโŒ Does not exist in Grav admin
onAdminSaveโš ๏ธ Emitted before validation
onAdminAfterSaveโœ… Emitted after each successful save

โš™๏ธ Plugin code

public static function getSubscribedEvents(): array
{
    return [
        'onAdminAfterSave' => ['onAdminAfterSave', 0],
        'onMcpAfterSave'   => ['onMcpAfterSave', 0],
    ];
}

๐Ÿ› Bug 1: $this->config() vs $this->grav['config']

The $this->config() method in a Grav plugin returns an array, not a Config object. Calling ->get() on it causes a fatal error:

// โŒ Wrong โ€” returns an array, not a Config object
$config = $this->config();
$key    = $config->get('plugins.indexnow.key');

// โœ… Correct
$config = $this->grav['config'];
$key    = $config->get('plugins.indexnow.key');

๐Ÿ› Bug 2: MCP plugin bypasses the admin hook

After initial testing, a problem appeared: pages created or modified via the MCP plugin (create_page, update_page) did not trigger onAdminAfterSave. This hook is only emitted by PageObject.php in the admin context โ€” programmatic calls via MCP bypass it entirely.

Diagnosis:

# No fireEvent in the MCP plugin
grep -r "fireEvent" /var/www/grav/user/plugins/mcp-server/mcp-server.php
# โ†’ (no results)

# onAdminAfterSave conditioned on isAdminSite()
grep -r "onAdminAfterSave" /var/www/grav/system/src/Grav/Common/Flex/Types/Pages/PageObject.php
# โ†’ if ($this->isAdminSite()) { $grav->fireEvent('onAdminAfterSave', ...) }

Solution โ€” custom onMcpAfterSave event:

Rather than reusing onAdminAfterSave (which requires a PageInterface object not always available after an MCP write), a custom event was added directly in the MCP plugin after each successful operation:

// In mcp-server.php โ€” helper added after toolCreatePage and toolUpdatePage
private function notifyPageSaved(string $route): void
{
    $grav = \Grav\Common\Grav::instance();
    $grav->fireEvent('onMcpAfterSave', new \RocketTheme\Toolbox\Event\Event([
        'route' => $route,
    ]));
}

The IndexNow plugin listens to this event and extracts the route directly from the payload:

public function onMcpAfterSave(Event $event): void
{
    $route = $event['route'] ?? null;
    if (!$route) return;
    if (in_array($route, self::EXCLUDED_ROUTES, true)) return;
    if (str_starts_with($route, '/tag')) return;

    $this->submitToIndexNow($route);
}

โœ… Final result โ€” two contexts covered

ContextHookStatus
Save via Grav adminonAdminAfterSaveโœ… Active
create_page / update_page via MCPonMcpAfterSaveโœ… Active

๐Ÿ“‹ IndexNow key file

The key file must be publicly accessible at the site root:

curl -I https://arleo.eu/bf6faf5563914fe7bb18d429976b182d.txt
# โ†’ HTTP/2 200

โœ… Verification in logs

tail -f /var/www/grav/logs/grav.log | grep -i indexnow
# [IndexNow] โœ… Submission OK (HTTP 200):
# https://arleo.eu/fr/grav-plugin-indexnow,
# https://arleo.eu/en/grav-plugin-indexnow,
# https://arleo.eu/grav-plugin-indexnow

๐Ÿ Conclusion

The plugin now covers both modification contexts: the Grav admin via onAdminAfterSave and the MCP plugin via the custom onMcpAfterSave event. Every save triggers an immediate submission of all 3 URL variants to IndexNow, with full traceability in grav.log. Bing indexing time drops from several days to a few minutes.

To go further:

  • ๐Ÿ’ก Add a bulk submission on plugin startup to index all existing pages in a single request
  • ๐Ÿ’ก Extend submission to other IndexNow-compatible engines (Naver, Seznam) via their dedicated endpoints