<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MaxLJ</title>
    <description>The latest articles on DEV Community by MaxLJ (@lji).</description>
    <link>https://dev.to/lji</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3863650%2Ffcb7a1df-8bdb-444c-8c63-c84fb0db692a.png</url>
      <title>DEV Community: MaxLJ</title>
      <link>https://dev.to/lji</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lji"/>
    <language>en</language>
    <item>
      <title>Building Hierarchical Destination URLs Without Killing Your Database</title>
      <dc:creator>MaxLJ</dc:creator>
      <pubDate>Thu, 04 Jun 2026 14:25:32 +0000</pubDate>
      <link>https://dev.to/lji/building-hierarchical-destination-urls-without-killing-your-database-3n1g</link>
      <guid>https://dev.to/lji/building-hierarchical-destination-urls-without-killing-your-database-3n1g</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;em&gt;One of the more challenging engineering problems&lt;/em&gt;&lt;/strong&gt; we encountered while building our travel portal, was figuring out a decent URL structure for destination hubs.&lt;br&gt;
On the face of it, this one looks relatively easy to crack. You've got a geographically based hierarchy: Global Region, Subregion, Country, Local Region, Local Subregion &amp;amp; City - &amp;amp; it would be nice to have URLs that make sense with that structure. Something that's clean, readable, semantically meaningful, and also SEO-friendly sounds pretty great. The problem is, turning that into a system that doesn't grind your application to a halt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;destinations&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;global&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;global&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;subregion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;subregion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem we're talking about isn't tied to any particular framework. Whether you're working with Django, FastAPI, Flask, Rails, Laravel, or something you cobbled together yourself, if your content has a recursive tree structure &amp;amp; your URLs need to mirror that tree, you'll run into the same roadblock.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach and Exactly Why It Falls Flat
&lt;/h2&gt;

&lt;p&gt;The super obvious path to take is recursive parent traversal whenever you need it. Each node in your hierarchy has a parent node it can latch onto . To build a full URL for any old node, you just have to walk up the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_full_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# one DB query per level
&lt;/span&gt;    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works great in development when you only have 20 nodes to worry about. But in production with 400+ nodes and actual traffic coming in, it's basically executing one database query per page render - one for every level in the hierarchy. So a six-level deep spot like &lt;a href="https://www.letsjourney.info/destinations/americas/caribbean/dominican-republic/uvero-alto/" rel="noopener noreferrer"&gt;Uvero Alto&lt;/a&gt; just unleashes &lt;strong&gt;six sequential round-trips&lt;/strong&gt; to the database before you've even started throwing page content on the screen. And then - to make matters worse - multiply that by all the concurrent users trying to get in on the action and connection pool exhaustion becomes a real possibility pretty darn quick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second thing&lt;/strong&gt; that usually pops into people's heads is eager loading - grab the node and all its ancestors in a single swoop with a JOIN. That works just fine if your ORM has got the ability to handle recursive CTEs or if you somehow manage to figure out the max depth ahead of time. But most do not handle arbitrary-depth recursive relationships with any kind of finesse. And, a lot of the time, you just can't predict the depth in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Pre-Computing at Write Time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The core idea&lt;/strong&gt; here is that the URL for a destination node only changes when the node or one of its parents is updated - so you don't need to recalculate it every time you go to read it. Just do the calculation once, stash the result, and then just read it straight out.&lt;/p&gt;

&lt;p&gt;When you decide to keep a node, take a moment to calculate and store three related pieces of info:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compute_node_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    fetch_parent_fn: callable that takes a parent_id and returns the parent node
    Works with any storage backend — SQL, NoSQL, graph DB, etc.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# Build full path
&lt;/span&gt;    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;full_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Compute depth
&lt;/span&gt;    &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="c1"&gt;# Build breadcrumbs
&lt;/span&gt;    &lt;span class="n"&gt;crumbs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;crumbs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_path&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;crumbs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;full_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;full_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hierarchy_depth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;breadcrumbs_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;crumbs&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stick the &lt;em&gt;full_path, hierarchy_depth&lt;/em&gt; and _breadcrumbs_data _right into the node record itself - so either add new columns to a database table or add new fields to a document store etc. Don't forget to index the _full_path _if you can.&lt;br&gt;
At request time, the lookup becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_destination_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_by_indexed_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;full_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;full_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# All derived data is pre-computed
&lt;/span&gt;    &lt;span class="c1"&gt;# Zero recursive queries. Zero parent traversal.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a request comes in looking for something, the lookup is now a super quick &lt;strong&gt;O(1)&lt;/strong&gt; regardless of how deep down in the hierarchy the destination is. &lt;em&gt;&lt;strong&gt;Whether that endpoint is buried two levels down or six, doesn't matter a bit.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cascading Update Problem
&lt;/h2&gt;

&lt;p&gt;Precomputing slugs introduces a new headache : what happens when a parent node's slug gets changed? The result is that all of the descendants' _full_path _gets left behind.&lt;/p&gt;

&lt;p&gt;The solution is to sort this out by making the system recompute downwards from the node whenever it gets updated. In a format that's easy to understand (but not code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_node_saved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_node_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Propagate to all direct children
&lt;/span&gt;    &lt;span class="n"&gt;children&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_children&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;on_node_saved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_parent_fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Recursion handles arbitrarily deep subtrees
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a framework with event hooks and all that jazz (post-save signals, lifecycle callbacks, database triggers), make sure this happens automatically as part of your persistence layer. If you're in a framework that doesn't have all that built in, just call it in your write path explicitly.&lt;/p&gt;

&lt;p&gt;To be honest, changes to parent node slugs aren't all that common - hierarchies like &lt;a href="https://www.letsjourney.info/destinations/americas/north-america/mexico/" rel="noopener noreferrer"&gt;Mexico&lt;/a&gt; or &lt;a href="https://www.letsjourney.info/destinations/" rel="noopener noreferrer"&gt;destinations &lt;/a&gt; begin to stabilise pretty quickly once you've set them up initially. So the cascading update is essentially a one-time cost every time you make an edit, not some huge overheard that's eating up resources with every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Legacy URL Handling
&lt;/h2&gt;

&lt;p&gt;If you're adding hierarchical URLs to a system that used to just use flat slugs, you're going to need some kind of fallback. Existing pages that've been indexed under the old URL structure still need to be found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_destination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Try canonical hierarchical path first
&lt;/span&gt;    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_by_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;full_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;

    &lt;span class="c1"&gt;# Fall back to legacy flat slug
&lt;/span&gt;    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_by_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_path&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Issue a permanent redirect to canonical URL
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect_permanent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;not_found&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Legacy URLs redirect neatly to the actual hierarchical path. This means link equity is preserved, old indexed pages resolve correctly and you don't end up with duplicate content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Gets You
&lt;/h2&gt;

&lt;p&gt;By using the &lt;strong&gt;pre-computed approach&lt;/strong&gt; you can take the complexity of your URLs off of your request-time performance. The full path for any &lt;a href="https://www.letsjourney.info/destinations/americas/north-america/united-states/northeast-usa/new-york-state/new-york-city/" rel="noopener noreferrer"&gt;destination&lt;/a&gt;, &lt;strong&gt;&lt;em&gt;no matter how deep you go&lt;/em&gt;&lt;/strong&gt; (or how big the hierarchy is) is just a single field read. Plus you can get breadcrumbs and depth information with zero extra queries. It doesn't matter if your backend is &lt;strong&gt;Django, Rails, Express&lt;/strong&gt; or just some serverless function hitting a database - it all works the same way.&lt;/p&gt;

&lt;p&gt;The catch is that there's a bit more complexity on the write side and you do need to deal with the cascading update thing. But if your content hierarchy isn't changing all that often, then the trade makes sense. Read performance is what scales as your traffic goes up, and a single field lookup is going to scale in a way that a recursive parent lookup just can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;_&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7eplf95uufuoks0n1hij.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7eplf95uufuoks0n1hij.jpg" alt=" " width="799" height="478"&gt;&lt;/a&gt;_&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>python</category>
      <category>django</category>
    </item>
    <item>
      <title>Building a Travel Portal in 2026: Our Story, Our Stack and Why We Are Still Here</title>
      <dc:creator>MaxLJ</dc:creator>
      <pubDate>Mon, 20 Apr 2026 10:38:39 +0000</pubDate>
      <link>https://dev.to/lji/building-a-travel-portal-in-2026-our-story-our-stack-and-why-we-are-still-here-57lf</link>
      <guid>https://dev.to/lji/building-a-travel-portal-in-2026-our-story-our-stack-and-why-we-are-still-here-57lf</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;em&gt;Hey Dev.to community 👋&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We are the development team behind &lt;a href="https://www.letsjourney.info/" rel="noopener noreferrer"&gt;letsjourney.info&lt;/a&gt; - an independent travel deals and destination platform. We wanted to introduce ourselves and be honest about what building something like this actually looks like in 2026, because the reality is considerably messier than most startup introductions suggest.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Started
&lt;/h2&gt;

&lt;p&gt;We launched as a fairly conventional affiliate portal. The idea was straightforward: aggregate travel deals from multiple provider APIs, build clean destination guides, drive organic traffic, collect affiliate commissions. Technically achievable. Business model proven by others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Should work.&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
It worked, partially. The affiliate revenue model functions. The platform is live, it generates traffic, it produces commission income. But about 12 months in we started running into a problem that no affiliate tutorial had prepared us for: &lt;em&gt;we were optimizing for the wrong thing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We were optimizing for deal volume and page count. What we needed to be optimizing for was understanding why specific travelers search for specific things at specific times - and whether what we were surfacing actually matched that intent. The difference between those two approaches is the difference between a content farm and a useful platform.&lt;br&gt;
So we started over. Not technically - Python infrastructure stayed, the API integrations stayed - but philosophically. The question shifted from &lt;em&gt;"how do we add more deals"&lt;/em&gt; to &lt;em&gt;"how do we understand what travelers actually need and build toward that."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack and Why We Chose It
&lt;/h2&gt;

&lt;p&gt;We are running Python on the backend with Wagtail CMS for content management. For anyone unfamiliar with Wagtail: it is a Django-based CMS that gives you the flexibility to build complex content hierarchies without the constraints of WordPress or the overhead of a custom-built system. For a travel portal that needs to manage multi-level destination hierarchy, structured deal models, vendor pages, coupon systems and editorial content all in the same codebase - it has been the right choice.&lt;br&gt;
The API integration layer is the part that consumes the most engineering time by a significant margin. Multiple travel provider APIs each have different data schemas, different update frequencies, different rate limit behaviors, and different failure modes. Normalizing pricing data across heterogeneous sources - so that a deal card on the frontend shows accurate, current, comparable information regardless of which provider it came from - is an unsolved problem you solve incrementally. Every time you think you have it, a new edge case surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 Reality: Competing With the Whales
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Booking.com&lt;/strong&gt; has 28 million listings and a marketing budget we will never approach. &lt;strong&gt;Expedia Group&lt;/strong&gt; owns approximately a dozen booking platforms simultaneously. *&lt;em&gt;Google *&lt;/em&gt; has its own hotel and flight search products embedded directly in search results. Every organic travel search goes through an environment where these companies have built years of infrastructure to appear first.&lt;br&gt;
Taking a meaningful share of that market as a small independent platform is not something we expect to do by being a better version of what they are. The approach that makes sense for a platform our size is being specifically useful to a specific traveler in a specific context - the US traveler planning a Cancun all-inclusive who wants an honest comparison rather than a ranked list shaped by which resort paid for placement, or the Bangkok first-timer who needs neighborhood-level hotel guidance rather than aggregate scores.&lt;br&gt;
AI recommendation is part of how we are building toward this. Not AI in the sense of a chat interface that answers travel questions - but AI that informs how we surface deals, how we identify which destination content gaps matter most to fill, and how we understand where organic demand is moving before it peaks. Small platforms that can move fast on emerging travel trends have an advantage over large platforms with slow editorial cycles. That advantage only exists if you are actually reading the demand signals rather than publishing reactively.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Have Built So Far
&lt;/h2&gt;

&lt;p&gt;The platform currently has several sections we consider reasonably mature:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.letsjourney.info/deals" rel="noopener noreferrer"&gt;Travel Deals&lt;/a&gt;&lt;/strong&gt; - live deal aggregation across flights, hotels, vacation packages, cruise trips, car rentals, and travel insurance. The freshness problem (keeping displayed prices accurate against provider inventory that changes continuously) is the ongoing engineering challenge here.&lt;br&gt;
&lt;strong&gt;&lt;a href="https://www.letsjourney.info/coupons" rel="noopener noreferrer"&gt;Travel Coupons&lt;/a&gt;&lt;/strong&gt; - verified discount codes across airlines, hotel chains, and tour operators. We verify codes manually before listing and remove expired ones within 24 hours. Slower than automated coupon aggregation, more reliable in practice.&lt;br&gt;
&lt;strong&gt;&lt;a href="https://www.letsjourney.info/brands/qatar-airways/" rel="noopener noreferrer"&gt;Premium Coupons&lt;/a&gt;&lt;/strong&gt; - we recently launched a dedicated premium coupon section, starting with Qatar Airways premium offers. This is a different product from the general coupons feed - curated, destination-specific, higher-value codes. It is an early version of what we want the coupon layer to become.&lt;br&gt;
&lt;strong&gt;&lt;a href="https://www.letsjourney.info/travelinsights/hotel-reviews/" rel="noopener noreferrer"&gt;Hotel Reviews &lt;/a&gt;&lt;/strong&gt;- independent editorial assessments of properties across Mexico, the Caribbean, Thailand, Dubai, and Europe. We built this because the gap between aggregate booking platform ratings and what travelers actually need to know is real and consequential. The update policy is the part we are most invested in: reviews get revised when properties change, not on a fixed calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Are Building Next
&lt;/h2&gt;

&lt;p&gt;The section we are actively developing is travel market analysis - demand trend tracking, seasonal pricing pattern documentation, emerging destination identification, and the kind of travel market intelligence that currently exists only inside the large OTAs and does not get published anywhere accessible to independent travelers or small travel businesses.&lt;br&gt;
We are building the data pipeline for this now. The plan is to publish market analysis pieces on the platform itself and distribute them across our developer and travel community accounts: X at &lt;a href="https://x.com/letsjourneyinfo" rel="noopener noreferrer"&gt;&lt;em&gt;@letsjourneyinfo&lt;/em&gt;&lt;/a&gt;, Bluesky at &lt;a href="https://bsky.app/profile/letsjourney.bsky.social" rel="noopener noreferrer"&gt;&lt;em&gt;letsjourney.bsky.social&lt;/em&gt;&lt;/a&gt;, and &lt;strong&gt;&lt;a href="https://medium.com/@letsjourney.info" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;&lt;/strong&gt;. If you are interested in the intersection of travel data, API engineering, and demand analysis - those are the places to follow the work as it develops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Part
&lt;/h2&gt;

&lt;p&gt;Building an independent travel platform in 2026 is genuinely hard. Not primarily for technical reasons - the Python and Django stack handles the complexity well, the API integration problems are solvable, the AI tools are useful. It is hard because the market structure actively works against small independent platforms. The SEO environment, the paid acquisition costs, the API pricing from major travel data providers - all of these are calibrated for large operators and require creative approaches at small scale.&lt;br&gt;
We are not under the illusion that we will compete with the large booking platforms. The goal is to build something specifically useful enough, and specifically honest enough, that the travelers who find it come back. That is a solvable problem at our scale in a way that general market share capture is not.&lt;br&gt;
We will keep posting here as the platform develops - engineering challenges, architecture decisions, what the demand analysis tooling looks like as it comes together. If you are building in the travel or affiliate space, or working on similar API integration challenges, we would genuinely value the conversation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More to come. 🌎&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>marketanalytics</category>
      <category>python</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
