# Need help understanding/configuring complex routing (or redirect?) for multilanguage urls

**URL:** <https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597>\
**Category:** Questions\
**Tags:** v4\
**Created:** [October 2, 2024, 10:36am UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597 "2024-10-02T10:36:42Z")\
**Posts on this page:** 10\
**Page:** 1

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 2, 2024, 10:36am UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/1 "2024-10-02T10:36:42Z")

</div>

Hi !  
I have built several Kirby websites in the past months and it’s been a breeze 👍.  
Now the main thing I have really struggled with has been routing. Seems like I can’t wrap my head around it.

I’m trying to change a few URLs in the structure of my website.  
Context: it’s v4 website, using multilanguage configuration for english (en) and french (fr). I have two _collections_: products, and blog posts, alongside normal pages. I have the Retour plugin installed.

The first question I guess is : is routing the good thing to use or should I just redirects in Retour? I feel like routing is a more maintainable way of doing it. Also I’d love to understand routing. My default language is configured to not show the /fr/ in the url.

**Currently I have:** in FR:

- `example.com/produits/` =\> it’s the shop landing page
- `example.com/produits/nom-du-produit` =\> an individual product
- `example.com/articles/` =\> the blog page with the list of latest publications
- `example.com/articles/un-titre-article-de-blog` =\> an individual article

Those urls are translated as in EN;

- `example.com/en/products/`
- `example.com/en/products/product-name`
- `example.com/en/news/`
- `example.com/en/news/a-blog-post-title`

**Here is what I’m trying to do:**

**Parent pages and their translation stay the same**

- `example.com/produits/` stays the same, plural
- `example.com/articles/` same

**And then I want to change:**  
Product is still a child of /produits/ but the url is singular. Similar in english:

- `example.com/produits/nom-du-produit` =\> `example.com/produit/nom-du-produit`
- `example.com/en/products/product-name` =\> `example.com/en/product/product-name`

Blog posts are still children of /articles/ and /en/news but site in the root url:

- `example.com/articles/un-article-de-blog` =\> `example.com/un-article-de-blog`
- `example.com/en/news/a-blog-post-title` =\> `example.com/en/a-blog-post-title`

For those who wonder why I’d do that, it’s an old website with years of history and we’d like to reproduce the url structure from the previous website (wordpress). If that’s the only way/best way I can concede to putting back the blog posts in the root folder but i’d rather not.

Thanks in advance a lot for any guidance about the errors I could have made…

Here is what I tried but I get 404s.  
For example for a singular product I get 404 on /vin/product-name and on /vins/product-name  
I tried to use the docs, the examples and even code llm but to no effect. I get 404s all around in a way or another or with only one of the cases working.

```php
// /site/config/config.php
'routes' => [
        [
            // Route to change the French product URL from plural to singular
            'pattern' => 'produits/(:any)', // Match any product under /produits/
            'language' => 'fr', // Apply only for the French version
            'action' => function ($productSlug) {
                // Redirect to the singular /produit/ slug
                $productPage = page('produits/' . $productSlug);
                if ($productPage) {
                    return $productPage;
                }
                return false; // Return false to allow Kirby to show a 404 error
            }
        ],
        [
            // Route to change the English product URL from plural to singular
            'pattern' => 'en/products/(:any)',
            'language' => 'en', // Apply only for the English version
            'action' => function ($productSlug) {
                $productPage = page('en/products/' . $productSlug);
                if ($productPage) {
                    return $productPage;
                }
                return false;
            }
        ],
        // Route to remove the parent "articles" slug from French blog posts
            [
              'pattern' => '(:any)',
              'language' => 'fr',
              'action' => function($slug) {
                  // First, try to find the page at the root level (e.g., /un-article-de-blog)
                  $page = page($slug);
                  
                  // If not found at the root, try to find it under the "articles" parent page
                  if(!$page) {
                      $page = page('articles/' . $slug);
                  }
  
                  // If still not found, show the error page
                  if (!$page) {
                      $page = site()->errorPage();
                  }
  
                  // Visit the resolved page (either root, articles, or error page)
                  return site()->visit($page);
              }
          ],
          // Route to handle redirects for old French URLs (e.g., /articles/un-article-de-blog => /un-article-de-blog)
          [
              'pattern' => 'articles/(:any)',
              'language' => 'fr',
              'action' => function($slug) {
                  // Redirect to the new root-level URL
                  go('/' . $slug);
              }
          ],
          
          // Route to remove the parent "news" slug from English blog posts
          [
              'pattern' => 'en/(:any)',
              'language' => 'en',
              'action' => function($slug) {
                  // First, try to find the page at the root level (e.g., /en/a-blog-post-title)
                  $page = page('en/' . $slug);
                  
                  // If not found at the root, try to find it under the "en/news" parent page
                  if(!$page) {
                      $page = page('en/news/' . $slug);
                  }
  
                  // If still not found, show the error page
                  if (!$page) {
                      $page = site()->errorPage();
                  }
  
                  // Visit the resolved page (either root, en/news, or error page)
                  return site()->visit($page);
              }
          ],
          // Route to handle redirects for old English URLs (e.g., /en/news/a-blog-post-title => /en/a-blog-post-title)
          [
              'pattern' => 'en/news/(:any)',
              'language' => 'en',
              'action' => function($slug) {
                  // Redirect to the new root-level URL for English articles
                  go('/en/' . $slug);
              }
          ],
    ]

```

Here is my fr.php config

```php
// /site/languages/fr.php
return [
    'code' => 'fr',
    'default' => true,
    'direction' => 'ltr',
    'locale' => [
        'LC_ALL' => 'fr_FR'
    ],
    'name' => 'Français',
    'url' => '/'
];

```

---

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 2, 2024, 10:00pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/2 "2024-10-02T22:00:18Z")

</div>

Maybe I’m trying to do too many things at once.  
Starting over and reading [this](https://forum.getkirby.com/t/overwrite-url-in-page-model/12974/4) and other threads I have solved the articles problem using these routes and a model:

```php
// site/config/config.php
    'routes' => [
      [
        'pattern' => '(:any)',
        'language'=> 'fr',
        'action' => function($language, $uid) {
            $page = page($uid);
            if(!$page) $page = page('articles/' . $uid);
            if(!$page) $page = site()->errorPage();
            return site()->visit($page);
        }
      ],
      [
        'pattern' => 'articles/(:any)',
        'language' => 'fr',
        'action' => function($language, $uid) {
            go($uid);
        }
      ],

      // Route for English articles (news)
      [
        'pattern' => '(:any)', // Match any UID for English
        'language' => 'en',
        'action' => function($language, $uid) {
            $page = page($uid); // Check for a direct page match
            if (!$page) $page = page('news/' . $uid); // Check for English articles
            if (!$page) $page = site()->errorPage(); // Fallback to the error page if not found
            return site()->visit($page); // Visit the found page
        }
      ],
      [
        'pattern' => 'news/(:any)',
        'language' => 'en',
        'action' => function($language, $uid) {
            go($uid);
        }
      ],
    ]

```

```php
// site/models/article.php
use Kirby\Cms\Page;

class ArticlePage extends Page {
    
    public function url($options = null): string {
        
        $languageCode = $this->kirby()->language() ? $this->kirby()->language()->code() : '';
        
// because my website is configured not to have the /fr/ for the default language
        if ( $languageCode == 'fr') {
            return url( $this->slug( ) );

        } else {
            return url( $languageCode . '/' . $this->slug( ) );

        }

    }

}

```

Now I still could not manage to route the /products/ urls and have another bug, the language switcher link populated with this code does not work anymore on articles/ or /news/ pages: the link sends to the current page.

```auto
<a class="languageswitcher" 
      href="<?php e($page->translation($language->code())->exists(), $page->url($language->code()), page('error')->url($language->code())) ?>">

```

---

<div class="post-metadata">

**Author:** ![lukasbestle](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/lukasbestle/32/11437_2.png) [@lukasbestle](https://forum.getkirby.com/u/lukasbestle)\
**Post date:** [October 3, 2024, 8:03pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/3 "2024-10-03T20:03:17Z")

</div>

> [@JuG](#):
>
> Now I still could not manage to route the /products/ urls

What is your current code state for this? What is the result you get and what is the result you want?

> [@JuG](#):
>
> the language switcher link populated with this code does not work anymore on articles/ or /news/ pages: the link sends to the current page.

Since you use the `$page` variable, the links always point to the current page. Isn’t this what you intended?

---

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 18, 2024, 3:42pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/5 "2024-10-18T15:42:15Z")

</div>

Hi! thanks for answering. Sorry for the delay I was under a huge backlog of work.

Here is the current state @lukasbestle

Routes for products/produits and produit/product:

```php
'routes' => [

      /**
       * REDIRECT /product URL to /products page
       */
      [
        'pattern' => 'produit',
        'language'=> 'fr',
        'action' => function () {
          go('produits');
        }
      ],
      // same for english
      [
        'pattern' => 'product',
        'language'=> 'en',
        'action' => function () {
          go('products');
        }
      ],

      /**
       * ROUTE PRODUCT PAGES to SINGULAR URL /products/ to /product/
       */
      [
        'pattern' => 'produits/(:any)', // Match URLs like /products/slug
        'language'=> 'fr',
        'action' => function ($language, $slug) {
            // Find the page under /products/ with the given slug
            $productPage = page('produits/' . $slug);
            if ($productPage) {
                // Redirect to the new URL: /product/slug
                return go('produit/' . $slug);
            }
            return go('error');
        }
    ],
    [
        'pattern' => 'produit/(:any)', // Match URLs like /product/slug
        'language'=> 'fr',
        'action' => function ($language, $slug) {
            // Find the product page based on the new pattern
            return page('produits/' . $slug) ?? go('error');
        }
      ],
      // same for english language
      [
        'pattern' => 'products/(:any)',
        'language'=> 'en',
        'action' => function ($language, $slug) {
            $productPage = page('products/' . $slug);
            if ($productPage) {
                return go('en/product/' . $slug);
            }
            return go('error');
        }
    ],
    [
        'pattern' => 'product/(:any)',
        'language'=> 'en',
        'action' => function ($language, $slug) {
            return page('products/' . $slug) ?? go('error');
        }
      ],

```

and route for articles/news

```php
      /**
       * REDIRECT /articles/blog-post to /blog-post
       */
      [
        'pattern' => '(:any)',
        'language'=> 'fr',
        'action' => function($language, $uid) {
            $page = page($uid);
            if(!$page) $page = page('articles/' . $uid);
            if(!$page) $page = site()->errorPage();
            return site()->visit($page);
        }
      ],
      [
        'pattern' => 'articles/(:any)',
        'language' => 'fr',
        'action' => function($language, $uid) {
            go($uid);
        }
      ],
      // Same for English articles /en/news/blog-post
      [
        'pattern' => '(:any)', // Match any UID for English
        'language' => 'en',
        'action' => function($language, $uid) {
            $page = page($uid); // Check for a direct page match
            if (!$page) $page = page('news/' . $uid); // Check for English articles
            if (!$page) $page = site()->errorPage(); // Fallback to the error page if not found
            return site()->visit($page); // Visit the found page
        }
      ],
      [
        'pattern' => 'news/(:any)',
        'language' => 'en',
        'action' => function($language, $uid) {
            go($uid);
        }
      ],
    ]

```

And the article.php model page

```auto
<?php

// site/models/article.php
use Kirby\Cms\Page;

class ArticlePage extends Page {
    public function url($options = null): string {
        $languageCode = $this->kirby()->language() ? $this->kirby()->language()->code() : '';
        // because my website is configured not to have the /fr/ for the default language
        if ( $languageCode == 'fr') {
            return url( $this->slug( ) );
        } else {
            return url( $languageCode . '/' . $this->slug( ) );
        }
    }
}

```

There is obviously something wrong somewhere but I still could not figure it out.  
Seems like I need the article.php model to make it work but it is buggy for still the same reason as stated before.

What I meant when I was saying that the link points to the current page I meant the exact current page, not the translation of the current page.  
There is something I can’t manage to wrap my head around in there.  
Reading [the code for $page](https://github.com/getkirby/kirby/blob/4.4.1/src/Cms/Page.php#L1260) didn’t really solve it for me.

When on an english news article, which url is, thanks to the routes `example.com/en/blog-post-url`, the language switcher points to this same url instead of `example.com/post-de-blog-url`.

Maybe I should simply get rid of it, but then I don’t see how to properly setup the Routing. Without the page model, when I’m on a french post page and want to switch to english, or when I’m on the /news/ page and, the link of an english post is redirected from `example.com/en/news/blog-post-url` to `example.com/blog-post-url` which is a 404 naturally.

---

<div class="post-metadata">

**Author:** ![lukasbestle](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/lukasbestle/32/11437_2.png) [@lukasbestle](https://forum.getkirby.com/u/lukasbestle)\
**Post date:** [October 18, 2024, 4:12pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/6 "2024-10-18T16:12:03Z")

</div>

It could be that the calls to `url()` inside your page model break the URLs you intend. The `url()` helper contains some magic to convert page URIs to URLs dynamically, potentially reversing the effect you intend.

Instead, I’d recommend to use something like:

```php
class ArticlePage extends Page {
	public function urlForLanguage(
		$language = null,
		array $options = null
	): string {
		if ($options !== null) {
			return parent::urlForLanguage($language, $options);
		}

		return $this->url = $this->site()->urlForLanguage($language) . '/' . $this->slug($language);
	}
}

```

---

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 18, 2024, 8:18pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/7 "2024-10-18T20:18:07Z")

</div>

Indeed, that’s better! Thank you very much, I’m not sure I could have figured that myself.  
The rest is, I think, working like I intended it to.

---

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 18, 2024, 10:59pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/8 "2024-10-18T22:59:43Z")

</div>

I detected another issue while adding another language. A very weird issue in fact.  
I’m not sure it is 100% related to this, there is possibly another bug under there.

These bits of code seem to be flipping around with new added language.  
Something very very strange that I don’t understand is that adding a language like Deutsch (/de) seems to work properly: I’ve added this language through the panel menu and adding a language, then translating only the homepage. The only \*.de.txt file in my entire site is home.de.txt.  
It works when I visit it.

Then I added like Chinese (zh\_CN) and Italian (it\_IT) and then the homepage redirects to error 404 page.

`it.php`, `zh.php` and `de.php` all have the default content.

If I comment out those two bits, it works again. So maybe I need to make those routes more versatile. But, still, I don’t understand the different behavior betwen deutsch and other languages.

```auto
      [
        'pattern' => '(:any)',
        'language'=> 'fr',
        'action' => function($language, $uid) {
            $page = page($uid);
            if(!$page) $page = page('articles/' . $uid);
            if(!$page) $page = site()->errorPage();
            return site()->visit($page);
        }
      ],
//[...]
      // Same for English articles /en/news/blog-post
      [
        'pattern' => '(:any)', // Match any UID for English
        'language' => 'en',
        'action' => function($language, $uid) {
            $page = page($uid); // Check for a direct page match
            if (!$page) $page = page('news/' . $uid); // Check for English articles
            if (!$page) $page = site()->errorPage(); // Fallback to the error page if not found
            return site()->visit($page); // Visit the found page
        }
      ]

```

---

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 21, 2024, 5:58pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/9 "2024-10-21T17:58:43Z")

</div>

Furthermore, the combined route and Models/article.php file makes the previewing of draft article impossible 😕

The issue is the same that the one described by several users here:

> [@Draft preview isn't working with custom routes and page url model](https://forum.getkirby.com/t/draft-preview-isnt-working-with-custom-routes-and-page-url-model/21470):
>
> Hi, first of all, thank you for your amazing work on this CMS, I absolutely love working with it ! However, I currently have an issue previewing drafts with my custom routes. This issue has already been raised here : [Draft Preview giving error page](https://forum.getkirby.com/t/draft-preview-giving-error-page/13537) and here : [Issue with route and draft page preview](https://forum.getkirby.com/t/issue-with-route-and-draft-page-preview/16702) so I tried to adapt my routes to make it work but I can’t seem to figure why it doesn’t… This is the routes I am using : [ ‘pattern’ =\> ‘(:any)’, ‘action’ =\> function($uid) { $page…

---

<div class="post-metadata">

**Author:** ![lukasbestle](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/lukasbestle/32/11437_2.png) [@lukasbestle](https://forum.getkirby.com/u/lukasbestle)\
**Post date:** [October 21, 2024, 7:59pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/10 "2024-10-21T19:59:37Z")

</div>

Returning pages from routes and overriding the `url()` method are two implementations that modify Kirby’s behavior in a pretty far-reaching way. Kirby is really flexible with these things and allows many customizations, but we cannot think of all modifications in our code ahead of time and we also cannot support each customization.

If you prefer a robust and reliable setup, it should be much simpler to move the pages to their intended “real” paths and create redirects for the old URLs (be it with custom routes or Retour). If you really need to implement the singular/plural URLs without redirects, a compromise could be to make the singular version the “real” path and only create a single fixed route for the overview page under the plural name.

---

<div class="post-metadata">

**Author:** ![JuG](https://dub1.discourse-cdn.com/flex017/user_avatar/forum.getkirby.com/jug/32/15230_2.png) [@JuG](https://forum.getkirby.com/u/JuG)\
**Post date:** [October 24, 2024, 3:31pm UTC](https://forum.getkirby.com/t/need-help-understanding-configuring-complex-routing-or-redirect-for-multilanguage-urls/32597/11 "2024-10-24T15:31:34Z")

</div>

I totally understand. Can’t anticipate every weird edge case clients or devs could want to modify the site into 😅

Coming back to structuring files as we want our URLs to end up being represented was my conclusion after our back and forth discussions.

Thanks a lot for taking the time to guide me through those issues.
