Migrating from Perch Runway to Kirby

Kirby logo

I finally took the time to migrate my Perch Runway website to Kirby. You are reading these lines on a page served by the latest release of Kirby CMS.

I’d been wanting to make the switch ever since attending a workshop by Bastian Allgeier at New Adventures 2020.

Then I discovered Kirby

Kirby's philosophy immediately appealed to me. I’ve always preferred treating the file system as the foundation of a website rather than hiding everything away in a database (and depending on it). Long before discovering Kirby, several bespoke content management systems I’d designed for clients followed a similar principle. Kirby simply felt like the natural expression of that idea.

Only a year earlier, I had migrated this weblog from Movable Type to Perch Runway. At the time, Perch Runway was an excellent fit for the site. It was lightweight, flexible and let me build exactly what I wanted without getting in the way. I happily recommended it over WordPress whenever it made sense.

It quickly became my CMS of choice. I started recommending it to clients, migrated several existing websites and built new ones with it. My own website, however, proved to be a much bigger undertaking.

Over the years I had built a fairly sophisticated content model in Perch Runway, complete with custom collections, bespoke blocks and years of accumulated content. Migrating all of that wasn’t simply a matter of exporting a database and importing it somewhere else.

By 2022, I had a working Kirby version of the site running locally, with matching collections and blocks. The foundations were there, but client work kept taking priority and the project quietly sat on the shelf for four years. Don’t ask.

Switching for real

When I finally picked it up again, I wrote a collection of export scripts and helper functions to transform the existing content stored in Perch’s database into Kirby content files. Along the way I refreshed—or completely rewrote—many of the templates and snippets, which had become rather dated since my first experiments with Kirby. The framework itself had evolved considerably, and I wanted the codebase to evolve with it.

I also took the opportunity to simplify a number of existing components, rethink parts of the content model, improve responsive image handling and make it easier to integrate external sources such as Flickr or Raindrop.io. Some ideas I’d wanted to implement for years suddenly became much easier once the migration was underway. I learned a bunch about how Kirby works, and incidentally about the new features of the PHP language.

The migration itself was almost the easy part. The challenge was preserving more than twenty-five years of content (with its own legacy issues), keeping every URL intact, maintaining publication dates and metadata, and ensuring that the public-facing site changed as little as possible while the foundations underneath were completely rebuilt.

Outcome

Perhaps the biggest benefit wasn’t changing CMS at all. It was having an excuse to revisit code I’d written years ago, delete a surprising amount of it, and take advantage of everything Kirby has included since I first experimented with it in 2022.

If you’re facing a similar migration from Perch Runway, I’ve published the export scripts and helper functions on GitHub. They’re tailored to my own content model rather than being a generic migration tool, but they should provide a useful starting point—and perhaps save you a few hours of work.

The migration

Several people have asked how I performed the migration.

The export scripts and helper functions aren’t intended to be a universal Perch-to-Kirby migration tool. They reflect the structure of my own website and the decisions I made along the way.

Nevertheless, they may provide a useful starting point if you’re facing a similar migration. I’ve published the scripts on GitHub: perch-runway-to-kirby.

Just bear in mind they will probably be useless to you as-is, but feel free to adapt them to your own project. All what follows is based on the fact that I had a Kirby version of my site running before the migration.

1. Blog entries

I concentrated on migrating the blog entries first.

The process wasn’t particularly complicated, but it was very specific to my site. Along the way I had to solve a number of small problems:

  • converting Perch blog posts into Kirby pages
  • translating custom blocks to Kirby blocks
  • preserving source image and references
  • rewriting internal links
  • keeping publication dates and slugs unchanged
  • exporting metadata
  • copying all assets over to Kirby
  • validating the imported content before switching production

2. New blog URL format

I changed the URL scheme to match Kirby's flat organisation for my blog entries. I switched from using,

/logs/2026/06/lost-in-thoughts/
to
/logs/lost-in-thoughts/

maintaining the old format as a 301 redirect.

This was taken care of in my .htaccess file.

# ------------------------------------------------------------------
# Old blog URLs → new ones
# ------------------------------------------------------------------
RewriteRule ^logs/[0-9]{4}/[0-9]{2}/([^/]+)/?$ /logs/$1/ [R=301,L]

3. Duplicate names

As a consequence, I had a couple of duplicates: two posts with the same title that co-existed happily in the file system because of the prefixed date but generated an error:

Example of duplicate post URLs
Perch Kirby
/logs/2015/03/sweet-sixteen 201503121526_sweet-sixteen
/logs/2017/03/sweet-sixteen 201703031052_sweet-sixteen

Changing the second URL to 201703031052_sweet-sixteen-today fixed that.

4. Maintaining the trailing slash

My website uses trailing slashes in its URLs since its inception. I was worried that switching to non-trailing slashes might create issues like,

  • unnecessary redirects
  • temporary SEO instability
  • duplicate URL risk during migration
  • broken historical backlinks if redirects fail
  • analytics fragmentation
  • cache inconsistencies

I struggled with the decision of keeping them or not, and finally I kept them. Because Kirby’s native url() usually returns no trailing slash, I fixed calls to $page->url() in my snippets to add a trailing slash, to make sure everything matched the previous state, especially the canonicals.

This was taken care of in my .htaccess file.

# ------------------------------------------------------------------
# Canonical trailing slash URLs
# ------------------------------------------------------------------
RewriteCond %{REQUEST_URI} !^/bin(/|$) [NC]
RewriteCond %{REQUEST_URI} !^/panel(/|$) [NC]
RewriteCond %{REQUEST_URI} !^/api(/|$) [NC]
RewriteCond %{REQUEST_URI} !^/media(/|$) [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !\.[a-z0-9]{1,10}$ [NC]
RewriteCond %{REQUEST_URI} !/$
RewriteRule ^(.+)$ /$1/ [R=301,L]

5. Single pages

This was easy. Once the blog entries sorted, I recreated the singe pages one by one in Kirby, which gave me the opportunity to revisit them.

6. Perch assets cleanup

Perch stores all its assets in perch/resources folder. The entries post first migration organised them in folders, but the posts preceeding it referred to assets all over the place. I had fixed most of the paths, but took the opportunity to go through all,

  • inline images (images in the body field)
  • legacy logs assets
  • inline images CSS classes
  • links to Perch resources

and clean them up, replacing them with KirbyText (image: …) tags whenever possible. My migration script took care of most inline images, but not links to images (like those opening in a lightbox).

7. RSS feed and sitemap

Kirby routing is powerful. It takes care of all my listings (by category, tag, year, photo, etc.) as well as my RSS feeds and sitemap.

I wrote a couple of routes to offer 2 RSS feeds: short, and full content (just because I could).

return [
  "routes" => [
    [
      "pattern" => "rss",
      "action" => function () {
        $articles = collection("logs")
          ->listed()
          ->sortBy("published", "desc")
          ->limit(20);

        $content = snippet(
          "rss",
          [
            "articles" => $articles,
          ],
          true
        );

        return new Kirby\Cms\Response($content, "application/rss+xml");
      },
    ],
    [
      "pattern" => "rss/full",
      "action" => function () {
        $articles = collection("logs")
          ->listed()
          ->sortBy("published", "desc")
          ->limit(20);

        $content = snippet(
          "rss-full",
          [
            "articles" => $articles,
          ],
          true
        );

        return new Kirby\Cms\Response($content, "application/rss+xml");
      },
    ],
    [
      "pattern" => "sitemap.xml",
      "action" => function () {
        $pages = site()
          ->pages()
          ->listed()
          ->index();
        $ignore = kirby()->option("sitemap.ignore", ["error"]);
        $content = snippet("sitemap", compact("pages", "ignore"), true);

        return new Kirby\Cms\Response($content, "application/xml");
      },
    ],
    [
      "pattern" => "sitemap",
      "action" => function () {
        return go("sitemap.xml", 301);
      },
    ],
    …
// rest of config //

8. Migrate Raindrop.io bookmarks

I publish a grid of my latest public Raindrop.io bookmarks on the homepage. Previously, I used a Perch collection to store them, which enabled search too, but I decided to store them as structured data as each bookmark didn't require its own public URL.

A PHP script checks for new bookmarks once a day, and writes them to a JSON file, archiving the previous last 5 copies.

[
  {
    "id": 1234567890
    "href": "https://example.com/article",
    "description": "Title or short description",
    "extended": "Markdown notes",
    "excerpt": "Quoted excerpt",
    "meta": "example.com",
    "hash": "abc123",
    "time": "2026-05-05",
    "shared": true,
    "toread": false,
    "tags": ["web", "css", "kirby"],
    "cover": "https://example.com/image.jpg",
    "domain": "example.com",
    "collectionId": 0987654321
  }
]

A Kirby Collection provides the items to display to the appropriate snippet.

<?php

use Kirby\Cms\Collection;

return function () {
    $path = kirby()->root('site') . '/data/raindrops.json';

    if (!file_exists($path)) {
        return new Collection([]);
    }

    $data = json_decode(F::read($path), true);
    $items = $data['items'] ?? [];

    if (empty($items)) {
        return new Collection([]);
    }

    usort($items, fn ($a, $b) =>
        strtotime($b['time'] ?? '') <=> strtotime($a['time'] ?? '')
    );

    return new Collection($items);
};

9. Migrate travel entries

As for the past travel snippets, they were also stored in a Perch Collection, but with far less entries. I migrated them in a similar fashion than the blog entries: a PHP script ran through the Perch Collection, and created individual pages for each trip, moving the assets at the same time.

The novelty is that each trip has its own page and permalink, giving me the possibility to enrich these pages with additional content.

Still missing

There are still a few things missing: webmentions and comments weren't migrated. I still have to figure out the best path forward for that. Search is still sketchy, and needs to be ironed out. There's a search box at the bottom of the logs page, but it's far from stellar.

It feels good to be at last powered by Kirby. I love tinkering and trying new things, and I've learned so much in the process, it's really satisfying. Not having to deal with a database is a blessing in itself. The flat file and elegance of the CMS enabled me to find (and fix) corrupted markup in my legacy content by means of search and replace with regular expressions. So powerful.

Migrating to Kirby

If you're moving from another platform or CMS, this guide will help you understand the essentials, give you pointers to relevant documentation and resources, and smooth the process of setting up your new Kirby site to make the transition as seamless as possible.

Recent Logs — or the next 10 entries

Well, they might not be all that recent. You'll find the older entries in the archives.

More entries »

Athens and Paros  — Greece / 2026

A couple of days in Athens to explore Pangrati and Kypseli, followed by two weeks downtime in Paros.