How to add virtual pages to kirby's index

Is there a way to add virtual pages to $site->index() somehow?

Or what is your strategy to have your virtual pages in what Kirby believes that are “all pages from the site”? That’ld make e.g. search work “sitewide”.



Hey, Bart! You can add pages via the pages extension in a plugin. I think the last time I tested this didn’t become part of the index, though. Haven’t checked in a while.

Hey Sonja,

I have created a plugin that fetches content from an API, caches that JSON and then uses this as “store” to create virtual pages from (incl. a route to make it work etc etc).

Everything works when using the route, but otherwise Kirby is unaware of this (virtual) content. This has as consequence that I have to cater in every possible situation where we need all content (e.g. in site search) for the management of these virtual pages. I’ld like to “add” these to the site index somehow so this will be automagically ok everywhere. But I don’t know how to do it?

This seems like a must for having sites where part of the pages is coming in from another source as virtual pages? Adding them to the index is the missing link in my setup… Or how do you work with/around this?

Are these virtual pages defined as children of a parent, like in the examples in the guide? Or just as routes?

I quickly added the code from the reference you’ve supplied to my plugin.

From my quick tests, the helpers such as page("new-page") return a page, also routing works automagically. Which is awesome, but other things don’t work:

var_dump(page("new-page")); // works
var_dump($site->find("new-page")); // works
dump($site->index()); // doesn't work; no "new-page" in the resulted set
var_dump($site->search("Hello")); // doesn't work; no hits

Is this expected?

From my tests in the past, these virtual pages added via the pages extension don’t work like normal pages nor do virtual pages from a route.

As far as I remember, though, the pages created as virtual children of a parent as described in the guide examples, e.g., should work like real pages (not 100% sure though).

Is there another way to “register pages to kirby” than via the pages -extension in a plugin? In my case I want to add pages that will be fetched from an api, but I want (need) to cache them.

When I add 'pages' => myHelperClass::getPagesArray() to the plugin I can’t access the plugin’s cache since it doesn’t exist yet (I think)? When I dump the cache when it’s initialized like this in the plugin it returns a “nullCache”. This is my (simplified) plugin:

Kirby::plugin('bvdputte/myplugin', [
    'options' => [
        'cache.vrData' => true
    'pages' => myHelperClass::getPagesArray()

Ideally I’ld like to run the “register pages” stuff in a system.loadplugins.after hook, but I don’t know how. Can you point me in the right direction?

FYI: When I run myHelperClass::getPagesArray() in e.g. a controller; it returns the virtual pages in an array as expected, and caches the returned request from the api in the specified plugin’s cache at site/cache/mywebsite.test/bvdputte/myplugin/cache/vrData.

As I mentioned above, there are three ways to register virtual pages that I know of

  • in a route (does not become part of the system)
  • in a page model as virtual children (they should actually be treated as real pages)
  • using the Pages extension (they don’t seem to be a proper part of the index, either)

More I cannot say…

With this approach; are they then used in e.g. $site->search()?

That’s at least what I would expect, but without testing, I can’t tell for sure. I’d regard it as a bug if that doesn’t work (seems to work in the Panel search, though).

Just getting back here for future reference; reusing the children()-method in a page model of the parent seems to work just fine. Routing works, and so does e.g. $site->search().

Thanks for the help Sonja!

Digging into virtual pages, I found your discussion about how virtual pages are indexed, and I agree that they should be added in the $pages object, depending on how they are ‘injected’.
Routes shouldn’t be included, but using the Pages extension mechanism, they should be indexed. Having to override a parent page’s children() method sounds like a hack to me, and doesn’t allow including them in the root.

I’d like to add a virtual page via the Pages extension, into the pages() object, in the site root (site()->pages()). My final intention is that my virtual page appears automagically in my menus, but that’s not the point here.
The children() method/hack works, but then I have to give it a non-virtual sub page that exists in the content folder. A’m I missing something here ? Is this behaviour normal ? Is there a way to define the position / uri in the pages hierarchy ?

/* -- file : kirby/src/Cms/Pages.php
 * The `$pages` object refers to a
 * collection of pages. The pages in this
 * collection can have the same or different
 * parents, they can actually exist as
 * subfolders in the content folder or be
 * virtual pages created from a database,
 * an Excel sheet, any API or any other
 * source.

That suggests that virtual pages should be indexed. And the reference also states : With the Pages extension you can register pages from a plugin that do not physically exist in the file system., but that’s subject to how you interpret registering.

1 Like

Apologies to revive this old post, but it came the closest to my problem and I might have found a solution or add a missing link.

My situation is that I created a plugin to handle multiple API calls to different endpoints{/:id}{/:id}

For Kirby I setup a content folder:


And my goal is to reuse the API endpoints and integrate them in kirby and be able to query all those virtual kirby pages like native pages.

my plugin index.php looks like this: (Work in Progress!)

	'EntriesPage' => 'src/EntriesPage.php',
	'EntryPage' => 'src/EntryPage.php',
], __DIR__);

Kirby::plugin('mo/core', [
	'routes' => function($kirby) {
		return [
				'pattern' => [
					'(:any)/locations' // needs more patterns?!
				'language' => '*',
				'action' => function ($language, $parent) {
					$page = [
						'slug' => 'locations',
						'template' => 'locations',
						'num' => 0,
						'model' => 'entries',
						'parent' => page($parent),
						'children' => [],
						'content' => [
							'title' => t('Standorte', 'Standorte'),

					$page = new EntriesPage($page);
					return $page;


	'pageModels' => [
		'entries' => 'EntriesPage',
		'entry' => 'EntryPage',

The EntriesPage Model creates the children()

class EntriesPage extends EntryPage
	public function children() : Pages

		// Query the `record` collection, could also be `geojson`
		$results = $this->_query('records/'. $this->slug());

		foreach(A::get($results,'records') as $item) {
			$pages[] = [
				'slug' => $item['id'],
				'num' => 0,
				'parent' => $this,
				'model' => 'entry',
				'template' => 'location',
				'content' => array_merge($item,[
					'title' => t($item['name_en'], $item['name'])
		return Pages::factory($pages, $this);


And EntryPage Model has a method to query the API.

So this works fine if I visit, but if I want to show a list of locations as teaser on the parent parent page and try to access the location children with
page('customerX/locations'), the children aren’t registered with kirby, as @bvdputte described it initially.

@texnixe is correct when she says, VP are getting registered in the guide “Virtual Pages from a Database”.

In the Database example it comes down to creating a file in the content folder.
So when I added a locations/entries.txt to each customer:


then customerX/locations wasn’t a virtual file anymore, and it showed up in the index with all the children. But I don’t want to add a bunch of ghost folders/files to my content, just to trick Kirby.

It comes down to Pages::inventory() that collects all files (children, images, templates) and sets the $page->children() somewhere in \HasChildren->index() when you attempt to find a page(’…’)

So my solution is to overwrite inventory in the site model like this:


class SitePage extends Page
	public function inventory(): array

		if ($this->inventory !== null) {
			return $this->inventory;

		$kirby = $this->kirby();

		$this->inventory = Dir::inventory(
		// register my virtual Parent Page
		$this->inventory['children'][] = [
			'dirname' => "locations",
			'model' => 'entries',
			'num' => 0,
			'root' => "M:\WEB\project\content\customerX/locations", //?
			'slug' => "locations",

		return $this->inventory;


And now everything works as expected.

I still have to make it more universal for my case and experiment with the virtual inventory, but this did the trick to register a virtual page with child pages.

Hopefully all of this makes sense, and maybe it helps someone find a solution.
@bvdputte recommended his plugin to me GitHub - bvdputte/kirby-vpkit: Virtual pages helper for multilingual Kirby 3 that deals with virtual pages. It’s a different approach, and worth to explore.

Any insight into how or why inventory() plays such an important role is appreciated. I just came to this conclusion by a lot trial and error and stepping through the code with Xdebug.

1 Like