<?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: Thiago Longo Moraes</title>
    <description>The latest articles on DEV Community by Thiago Longo Moraes (@thiagolongom).</description>
    <link>https://dev.to/thiagolongom</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png</url>
      <title>DEV Community: Thiago Longo Moraes</title>
      <link>https://dev.to/thiagolongom</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thiagolongom"/>
    <language>en</language>
    <item>
      <title>[Boost]</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:50:28 +0000</pubDate>
      <link>https://dev.to/thiagolongom/-1a3e</link>
      <guid>https://dev.to/thiagolongom/-1a3e</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf" class="crayons-story__hidden-navigation-link"&gt;Fuzzy search in Postgres with pg_trgm and Supabase (multilingual, typo-tolerant)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/thiagolongom" class="crayons-avatar  crayons-avatar--l  "&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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png" alt="thiagolongom profile" class="crayons-avatar__image" width="96" height="96"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/thiagolongom" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Thiago Longo Moraes
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Thiago Longo Moraes
                
                
              
              &lt;div id="story-author-preview-content-4737717" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/thiagolongom" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png" class="crayons-avatar__image" alt="" width="96" height="96"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Thiago Longo Moraes&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Sep 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf" id="article-link-4737717"&gt;
          Fuzzy search in Postgres with pg_trgm and Supabase (multilingual, typo-tolerant)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/postgres"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;postgres&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/supabase"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;supabase&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/javascript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;javascript&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:39:55 +0000</pubDate>
      <link>https://dev.to/thiagolongom/-4a1a</link>
      <guid>https://dev.to/thiagolongom/-4a1a</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia" class="crayons-story__hidden-navigation-link"&gt;Grading a wrong answer by how wrong it is: ICD-10 proximity scoring in Postgres&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/thiagolongom" class="crayons-avatar  crayons-avatar--l  "&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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png" alt="thiagolongom profile" class="crayons-avatar__image" width="96" height="96"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/thiagolongom" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Thiago Longo Moraes
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Thiago Longo Moraes
                
                
              
              &lt;div id="story-author-preview-content-4710593" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/thiagolongom" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png" class="crayons-avatar__image" alt="" width="96" height="96"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Thiago Longo Moraes&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Sep 21&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia" id="article-link-4710593"&gt;
          Grading a wrong answer by how wrong it is: ICD-10 proximity scoring in Postgres
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/postgres"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;postgres&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/sql"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;sql&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/healthcare"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;healthcare&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/supabase"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;supabase&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:30:51 +0000</pubDate>
      <link>https://dev.to/thiagolongom/-1dfb</link>
      <guid>https://dev.to/thiagolongom/-1dfb</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b" class="crayons-story__hidden-navigation-link"&gt;I used the ICD-10 tree as a scoring function&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/thiagolongom" class="crayons-avatar  crayons-avatar--l  "&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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png" alt="thiagolongom profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/thiagolongom" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Thiago Longo Moraes
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Thiago Longo Moraes
                
                
              
              &lt;div id="story-author-preview-content-4709946" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/thiagolongom" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Thiago Longo Moraes&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Sep 21&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b" id="article-link-4709946"&gt;
          I used the ICD-10 tree as a scoring function
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/computerscience"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;computerscience&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/learning"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;learning&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/science"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;science&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>computerscience</category>
      <category>development</category>
      <category>software</category>
    </item>
    <item>
      <title>ECGs, X-rays and heart sounds as clues: inside NEXO's Multimedia mode for clinical reasoning</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:24:47 +0000</pubDate>
      <link>https://dev.to/thiagolongom/ecgs-x-rays-and-heart-sounds-as-clues-inside-nexos-multimedia-mode-for-clinical-reasoning-2lhl</link>
      <guid>https://dev.to/thiagolongom/ecgs-x-rays-and-heart-sounds-as-clues-inside-nexos-multimedia-mode-for-clinical-reasoning-2lhl</guid>
      <description>&lt;p&gt;NEXO has a Multimedia mode: clinical cases built around an exam you have to read yourself. An ECG, a chest X-ray, a CT, a dermatoscopy image, a heart or lung sound. The exam arrives as one of the clues, and you name the diagnosis the same way as in any NEXO case, with ICD-10 proximity feedback on every guess.&lt;/p&gt;

&lt;p&gt;This post is about why we put images and sounds inside the clues, and what that changes about practicing diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a diagnosis game need images and sounds?
&lt;/h2&gt;

&lt;p&gt;In a text case, someone has already interpreted the exam for you. "The ECG shows ST elevation in the inferior leads" is a finding that was read by somebody else. At the bedside, you get the tracing.&lt;/p&gt;

&lt;p&gt;A lot of the hard part of diagnosis lives in that step: looking at an image or listening to a sound and deciding what it shows. Text cases skip it. So in Multimedia, the clue is the exam itself, and the reading is your job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which exams are in Multimedia mode?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Imaging:&lt;/strong&gt; X-ray, CT, MRI, ultrasound, PET/CT and mammography.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracings:&lt;/strong&gt; ECG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scopes:&lt;/strong&gt; upper endoscopy, colonoscopy, otoscopy, colposcopy and fundoscopy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skin and physical exam:&lt;/strong&gt; dermatoscopy and physical exam images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lab and pathology:&lt;/strong&gt; histopathology, blood smear and karyotype.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sound:&lt;/strong&gt; cardiac and respiratory auscultation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few examples from the mode's showcase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chest X-ray:&lt;/strong&gt; pulmonary tuberculosis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Histopathology:&lt;/strong&gt; colorectal adenocarcinoma&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physical exam:&lt;/strong&gt; petechiae&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cardiac auscultation:&lt;/strong&gt; aortic regurgitation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How does a Multimedia case play?
&lt;/h2&gt;

&lt;p&gt;It works like every NEXO case. The case unfolds in up to 6 clues, and at least one of them carries the exam: an image you can look at, or an audio clip you can play. Captions follow the language you play in.&lt;/p&gt;

&lt;p&gt;You search ICD-10 for the diagnosis you suspect and submit it. Every guess comes back with its distance from the answer: exact ICD, same category, same group, same chapter or different chapter. If you miss, the next clue opens.&lt;/p&gt;

&lt;p&gt;That feedback matters more here than anywhere else. Suppose the tracing shows an arrhythmia and you named the wrong one. A "same group" result tells you that you read the rhythm in the right family and picked the wrong member. A "different chapter" result tells you that you misread the exam. Both are wrong, and they don't mean the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this an ECG quiz?
&lt;/h2&gt;

&lt;p&gt;Not exactly. An ECG quiz usually shows a tracing and asks what it shows. A Multimedia case puts the tracing inside a clinical story: who the patient is, what brought them in, what the exam found. You read the ECG in context, and the answer is a diagnosis, not a pattern name.&lt;/p&gt;

&lt;p&gt;The same goes for auscultation. A heart or lung auscultation quiz plays a sound and asks you to name it. In NEXO, the sound is one clue among others, and you still have to decide what disease explains the whole picture.&lt;/p&gt;

&lt;p&gt;So if you are looking for ECG quiz practice, an auscultation quiz or radiology games for students, Multimedia is close to that, with one difference: the exam is part of the reasoning, not the whole question.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you unlock Multimedia?
&lt;/h2&gt;

&lt;p&gt;Multimedia is unlocked by exam type, with synapses, the in-game currency you earn by solving cases. It works on any plan: Free, Pro or Max. You can start with the exam you most want to practice, say ECG or chest X-ray, and add others later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I turn a Multimedia case into an Anki card?
&lt;/h2&gt;

&lt;p&gt;Yes. The Anki button on the result screen creates a card straight from NEXO and opens it in the Anki app, and on image cases you can take the case image into the card: image on the front, diagnosis on the back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is Multimedia for?
&lt;/h2&gt;

&lt;p&gt;It's for medical students who want to practice reading exams in context, residents who look at tracings and images every day, and doctors who want to keep those skills sharp outside their own specialty. A cardiologist can meet a fundoscopy, and a student can hear a murmur they haven't met on the wards yet.&lt;/p&gt;

&lt;p&gt;Like every case in NEXO, Multimedia cases are fictional cases with real clinical presentation, written for teaching.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is NEXO's Multimedia mode?&lt;/strong&gt;&lt;br&gt;
A mode of clinical cases built around an exam: imaging, ECG, scopes, dermatoscopy, histopathology, blood smear, karyotype, physical exam, and cardiac and respiratory auscultation. The exam is one of up to 6 clues, and every guess gets ICD-10 proximity feedback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a game to practice reading ECGs, X-rays and heart sounds through clinical cases?&lt;/strong&gt;&lt;br&gt;
Yes. NEXO's Multimedia mode puts ECGs, X-rays, other imaging and auscultation audio inside clinical cases. You read the exam in context and name the diagnosis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Multimedia free?&lt;/strong&gt;&lt;br&gt;
It is unlocked by exam type with synapses, which you earn by solving cases, on any plan. The Daily case is free on every plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I make Anki cards from Multimedia cases?&lt;/strong&gt;&lt;br&gt;
Yes. The Anki button creates the card from the case and opens it in the Anki app, with the case image on image cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are there tropical medicine clinical cases?&lt;/strong&gt;&lt;br&gt;
That's a different mode. Continents organizes cases by regional epidemiology across Africa, the Americas, Asia, Europe and Oceania, with diseases that are endemic or prevalent in each region.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where can I play?&lt;/strong&gt;&lt;br&gt;
On the web at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; and on iPhone ("NEXO: Medical Diagnosis" on the App Store).&lt;/p&gt;




&lt;p&gt;NEXO is a daily medical diagnosis game for clinical reasoning, with a Multimedia mode where ECGs, imaging and auscultation are part of the clues. Play at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; or get it on the &lt;a href="https://apps.apple.com/us/app/nexo-medical-diagnosis/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All cases are fictional cases with real clinical presentation, written for teaching. NEXO is an educational game and is not medical advice.&lt;/p&gt;

</description>
      <category>medicine</category>
      <category>gamedev</category>
      <category>healthcare</category>
      <category>learning</category>
    </item>
    <item>
      <title>NEXO and Anki: turn a clinical case into an Anki card, right after you play</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:24:31 +0000</pubDate>
      <link>https://dev.to/thiagolongom/nexo-and-anki-turn-a-clinical-case-into-an-anki-card-right-after-you-play-48p6</link>
      <guid>https://dev.to/thiagolongom/nexo-and-anki-turn-a-clinical-case-into-an-anki-card-right-after-you-play-48p6</guid>
      <description>&lt;p&gt;NEXO integrates with Anki. After every case, the Anki button on the result screen creates a card straight from NEXO and opens it in the Anki app, ready to save. It can also find the cards you already have on that diagnosis, and on image cases you can take the case image into your card.&lt;/p&gt;

&lt;p&gt;NEXO is not a flashcard app. It has no decks and no spaced repetition of its own. The Anki button connects the case you just played to the Anki you already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a medical diagnosis game need Anki?
&lt;/h2&gt;

&lt;p&gt;A lot of medical students run their study life in Anki: medical Anki decks, a deck from medical school, cards written the night before an exam. The review system already exists, and it works.&lt;/p&gt;

&lt;p&gt;What a clinical case adds is context. You meet a diagnosis as a story: a presentation, a history, an exam finding, a test result. When you miss it, or get it only at the last clue, that's exactly the moment you want to keep it.&lt;/p&gt;

&lt;p&gt;The problem is the gap between the two. You finish a case, think "I should make a card for this", and by the time you open Anki the thought is gone. The Anki button closes that gap in one tap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the Anki button do?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Create a card in Anki.&lt;/strong&gt; NEXO builds the card from the case, with the diagnosis and the key points from the clinical explanation, and opens it directly in the Anki app. You review it and save it to your deck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find my Anki cards.&lt;/strong&gt; It searches your Anki for the cards you already have on that diagnosis, in the language you play in, so you can review them right away instead of next month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image cases.&lt;/strong&gt; On Multimedia cases (an X-ray, an ECG, a skin lesion, a fundoscopy) you can take the case image into the card: image on the front, diagnosis on the back, a recognition card built from the case you just played.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NEXO is not
&lt;/h2&gt;

&lt;p&gt;NEXO doesn't have its own flashcards, decks or spaced repetition, and it doesn't schedule reviews. Your cards and your review schedule live in Anki. NEXO is for practicing diagnostic reasoning on fictional cases with real clinical presentation, with ICD-10 proximity feedback on every guess, and the Anki button makes the two work together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is it for?
&lt;/h2&gt;

&lt;p&gt;Medical students in the clinical years who review with Anki between rotations, residents who keep a personal deck, and anyone who has looked for clinical cases for their Anki deck and found only static question lists. If you study with Anki in Portuguese or Spanish ("anki medicina" decks are everywhere), play NEXO in that language and your cards come out in the same language as your deck.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does NEXO integrate with Anki?&lt;/strong&gt;&lt;br&gt;
Yes. After each case, the Anki button creates a card from the case and opens it directly in the Anki app. It also finds your existing cards on that diagnosis, and on image cases it takes the case image into the card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does NEXO have its own flashcards or spaced repetition?&lt;/strong&gt;&lt;br&gt;
No. NEXO works alongside Anki: your cards and your review schedule stay in Anki.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a clinical case game that works with Anki?&lt;/strong&gt;&lt;br&gt;
NEXO is a daily medical diagnosis game that works with Anki. You play a case with up to 6 clues, get ICD-10 proximity feedback on each guess, and then send that diagnosis to Anki as a card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work with Anki decks in other languages?&lt;/strong&gt;&lt;br&gt;
Yes. Cards and searches follow the language you play NEXO in: Portuguese, English, Spanish, French, German or Russian.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where can I play?&lt;/strong&gt;&lt;br&gt;
On the web at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; and on iPhone ("NEXO: Medical Diagnosis" on the App Store).&lt;/p&gt;




&lt;p&gt;NEXO is a daily medical diagnosis game for clinical reasoning that works with Anki: up to 6 clues, ICD-10 feedback, and an Anki button that turns each case into a card. Play at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; or get it on the &lt;a href="https://apps.apple.com/us/app/nexo-medical-diagnosis/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All cases are fictional cases with real clinical presentation, written for teaching. NEXO is an educational game and is not medical advice.&lt;/p&gt;

</description>
      <category>medicine</category>
      <category>anki</category>
      <category>learning</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Adrenaline: the timed mode in NEXO, a medical diagnosis game where you choose the dose before the clock starts</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:24:09 +0000</pubDate>
      <link>https://dev.to/thiagolongom/adrenaline-the-timed-mode-in-nexo-a-medical-diagnosis-game-where-you-choose-the-dose-before-the-16g1</link>
      <guid>https://dev.to/thiagolongom/adrenaline-the-timed-mode-in-nexo-a-medical-diagnosis-game-where-you-choose-the-dose-before-the-16g1</guid>
      <description>&lt;p&gt;NEXO has a timed mode called Adrenaline: one clinical case a day against the clock, with a dose you choose before you start. The higher the dose, the less time you get and the bigger the reward. It sits next to the Daily case, which has no clock at all.&lt;/p&gt;

&lt;p&gt;This post is about why a diagnosis game built around taking your time also has a mode that takes time away, and how the two fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a daily medical diagnosis game need a timed mode?
&lt;/h2&gt;

&lt;p&gt;The Daily case is deliberately slow. You read a clue, form a hypothesis, decide whether you know enough to commit, and you can take as long as you need. That's the part of diagnosis we wanted to train first: reasoning with incomplete information.&lt;/p&gt;

&lt;p&gt;But a lot of clinical reasoning doesn't happen at that pace. On a shift, the first impression of a case is often formed in a minute or two, and it shapes everything after it. We wanted a place to practice that too, without turning the Daily case into a race.&lt;/p&gt;

&lt;p&gt;So the clock lives in its own mode. The Daily case stays the way it was, with no timer. Adrenaline is the opt-in version: the same kind of case, the same up to 6 clues, the same ICD-10 proximity feedback, and a clock that keeps running while you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Adrenaline work?
&lt;/h2&gt;

&lt;p&gt;There is one Adrenaline case per day, and it is the same case for every player that day. Before the clock starts, you pick a dose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low dose:&lt;/strong&gt; 5 minutes, 1.2x reward. The case area and the ICD range still show up as hints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium dose:&lt;/strong&gt; 3 minutes, 1.5x reward. The case area shows up, the ICD range does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High dose:&lt;/strong&gt; 1 minute, 2x reward. No hints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Daily case, the area of the case appears at clue 3 and the ICD range at clue 5. In Adrenaline, the dose decides which of those hints you get. A higher dose takes away time and help at the same time, and pays more for it.&lt;/p&gt;

&lt;p&gt;You choose the dose by dragging the plunger of a syringe on the screen. Once you start, the dose is locked.&lt;/p&gt;

&lt;p&gt;From there it plays like any NEXO case. You search ICD-10 for the diagnosis you suspect and submit it. If you miss, the next clue opens. Every guess gets the same 5-level feedback: exact ICD, same category, same group, same chapter or different chapter. The difference is that the clock is running while you read, search and decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the time runs out?
&lt;/h2&gt;

&lt;p&gt;When the clock reaches zero, you get one chance to add 30 seconds to that case. On the Max plan it's free. On other plans it costs synapses, the in-game currency. If you don't take it, or the extra time runs out too, the case ends without points.&lt;/p&gt;

&lt;p&gt;When you solve it, the dose multiplies the reward for that case: points, synapses and XP. The result screen also tells you how you did against the players who picked the same dose that day.&lt;/p&gt;

&lt;p&gt;A few details matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can pause.&lt;/strong&gt; If you leave the case, the clock stops. You can come back and continue any time before midnight, Brasília time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The connection doesn't eat your time.&lt;/strong&gt; If the connection drops, the clock stops instead of running in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new day closes the case.&lt;/strong&gt; After midnight, Brasília time, an unfinished Adrenaline case no longer counts and a new one is available.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Isn't the point of NEXO that it's not a quiz with a timer?
&lt;/h2&gt;

&lt;p&gt;That's still true for the Daily case. It has no clock, and it's built for the pace of careful reasoning: incomplete information, a working hypothesis, and the decision of when to commit.&lt;/p&gt;

&lt;p&gt;Adrenaline is a separate mode for a different skill. It doesn't replace the Daily case, and the Daily case doesn't become timed. You can play both on the same day, or ignore Adrenaline entirely.&lt;/p&gt;

&lt;p&gt;What the clock adds is pressure on the decision itself. With a high dose, you don't have time to reveal every clue and search every candidate. You have to recognize the pattern, pick the most likely diagnosis and live with the ICD-10 feedback. A near miss under the clock still tells you your reasoning was on track. A distant miss tells you that your first impression went somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is a timed diagnosis game for?
&lt;/h2&gt;

&lt;p&gt;It's for anyone who already plays the Daily case and wants a second, faster case on the same day. Medical students who want to practice first impressions before ward work, residents who already think under time pressure on shift, and doctors who just like the challenge.&lt;/p&gt;

&lt;p&gt;If you are new to NEXO, the Daily case is the better place to start. Adrenaline makes more sense once the clue format and the ICD-10 search feel familiar, and a low dose is the gentle way in.&lt;/p&gt;

&lt;p&gt;Like every case in NEXO, Adrenaline cases are fictional cases with real clinical presentation, written for teaching.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does NEXO have a timed mode?&lt;/strong&gt;&lt;br&gt;
Yes. NEXO has a timed mode called Adrenaline: one case a day against the clock. Before you start, you choose a dose. Low dose gives you 5 minutes, medium 3 minutes and high 1 minute, and a higher dose means a bigger reward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the Adrenaline dose?&lt;/strong&gt;&lt;br&gt;
The dose sets the time limit, the hints you get and the reward multiplier: 1.2x on low, 1.5x on medium and 2x on high. It's chosen before the clock starts and can't be changed after that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the Daily case timed?&lt;/strong&gt;&lt;br&gt;
No. The Daily case has no clock. You read the clues and commit when you think you know enough. Only Adrenaline runs against the clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get more time?&lt;/strong&gt;&lt;br&gt;
Once per case, when the time runs out, you can add 30 seconds. It's free on Max and costs synapses on other plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Adrenaline a USMLE game?&lt;/strong&gt;&lt;br&gt;
No. NEXO is not a USMLE or PLAB simulator. Adrenaline is timed practice of diagnostic reasoning: you name the diagnosis from clinical clues and get feedback on how close your guess was in ICD-10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where can I play?&lt;/strong&gt;&lt;br&gt;
On the web at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; and on iPhone ("NEXO: Medical Diagnosis" on the App Store).&lt;/p&gt;




&lt;p&gt;NEXO is a daily medical diagnosis game for clinical reasoning: the Daily case with no clock, Adrenaline against the clock, up to 6 clues and ICD-10 feedback. Play at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; or get it on the &lt;a href="https://apps.apple.com/us/app/nexo-medical-diagnosis/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All cases are fictional cases with real clinical presentation, written for teaching. NEXO is an educational game and is not medical advice.&lt;/p&gt;

</description>
      <category>medicine</category>
      <category>gamedev</category>
      <category>showdev</category>
      <category>learning</category>
    </item>
    <item>
      <title>Clinical reasoning in six languages: taking a guess-the-diagnosis game to medical students around the world</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:17:48 +0000</pubDate>
      <link>https://dev.to/thiagolongom/clinical-reasoning-in-six-languages-taking-a-guess-the-diagnosis-game-to-medical-students-around-1f16</link>
      <guid>https://dev.to/thiagolongom/clinical-reasoning-in-six-languages-taking-a-guess-the-diagnosis-game-to-medical-students-around-1f16</guid>
      <description>&lt;p&gt;NEXO is a clinical reasoning game for doctors and medical students: you guess the diagnosis of one new fictional case a day, from up to 6 clues, and get a score based on how close you were. You can play it in six languages, and this post is about why and what that changed about the cases themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a clinical reasoning game need more than one language?
&lt;/h2&gt;

&lt;p&gt;Clinical reasoning is universal. Fever, cough and a consolidation on the chest X-ray make the same puzzle in São Paulo, Madrid, Lyon, Munich, Moscow or Chicago. But the way doctors learn to &lt;em&gt;name&lt;/em&gt; things is local. Students study in their own language, sit their exams in it, and talk to patients in it.&lt;/p&gt;

&lt;p&gt;A game that asks you to reason in a second language measures your vocabulary as much as your thinking. We wanted NEXO to measure the thinking. So the goal was never "add subtitles." It was to let a student in each country play in the language they actually practice medicine in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which languages can you play NEXO in?
&lt;/h2&gt;

&lt;p&gt;NEXO is available in Portuguese, English, Spanish, French, German and Russian. The daily case is the same for everyone, so a student in Brazil and a resident in Germany are working on the same case on the same day and can compare how many clues they needed.&lt;/p&gt;

&lt;p&gt;One principle guided the translations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis names follow the official ICD-10 version for each language.&lt;/strong&gt; That means WHO ICD-10 in English, DATASUS CID-10 in Brazilian Portuguese, CIE-10-ES in Spanish, CIM-10 in French, ICD-10-GM in German and МКБ-10 in Russian. When you search for your guess, you see the names doctors in your country actually learned.&lt;/p&gt;

&lt;p&gt;And we keep listening: the site lets players vote for the next language they'd like to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a guess-the-diagnosis game handle diseases that are common somewhere else?
&lt;/h2&gt;

&lt;p&gt;As NEXO reached more countries, we kept asking ourselves one question: &lt;em&gt;common where?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A "classic" case in one country is a rarity in another, and what you see depends a lot on where you train. A diagnosis game that only reflects one country's epidemiology quietly teaches that the rest of the world is exotic.&lt;/p&gt;

&lt;p&gt;So we added &lt;strong&gt;Play by Continent&lt;/strong&gt;, a mode with 103 cases organized by regional epidemiology across Africa, the Americas, Asia, Europe and Oceania. Each region brings conditions that are endemic or prevalent there. A few examples from the mode's showcase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Africa:&lt;/strong&gt; Hansen disease&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Americas:&lt;/strong&gt; histoplasmosis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asia:&lt;/strong&gt; talaromycosis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Europe:&lt;/strong&gt; tularemia&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oceania:&lt;/strong&gt; crusted scabies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a student, it's a way to travel through medicine: to meet the diseases a colleague on another continent sees often, and to practice recognizing them before one shows up in your own clinic. With travel and migration, someone else's disease can become a diagnosis you have to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do medical students around the world play the same case?
&lt;/h2&gt;

&lt;p&gt;The daily case works like a shared ritual. Everyone gets the same case, the clues arrive one at a time, and you decide when you know enough to commit.&lt;/p&gt;

&lt;p&gt;Every guess is placed in the ICD-10 hierarchy, so a guess in the same category as the answer gets different feedback from one in a different chapter. A near miss tells you your reasoning was on track. A distant miss tells you to rethink the problem.&lt;/p&gt;

&lt;p&gt;Afterwards, a global leaderboard and daily streaks give people a reason to come back tomorrow. If you've played Wordle, the shape will feel familiar.&lt;/p&gt;

&lt;p&gt;What the game trains is pattern recognition, differential diagnosis and classification awareness, one case at a time. It runs on the web and on iPhone, so it also works as a clinical reasoning app for medical students who want diagnosis practice between classes, ward work and question-bank sessions.&lt;/p&gt;

&lt;p&gt;The library has 649 cases across the daily case, Play by Continent and a Multimedia mode with clinical imaging and auscultation, so one day's case can come from cardiology and the next from dermatology. All of them are fictional cases with real clinical presentations, written for teaching.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is there a guess the diagnosis game in languages other than English?&lt;/strong&gt;&lt;br&gt;
Yes. NEXO is available in Portuguese, English, Spanish, French, German and Russian, with the same daily case for every player. If you're looking for a daily diagnosis game in Portuguese or Spanish, it's the same game, with diagnosis names from the official ICD-10 version for that language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you guess the diagnosis in NEXO?&lt;/strong&gt;&lt;br&gt;
Read the first clue, search ICD-10 for the diagnosis you suspect and submit it. If you're not sure yet, reveal the next clue, up to 6. Each guess comes back with its distance from the answer, from exact ICD to different chapter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What makes NEXO a clinical reasoning game and not a quiz?&lt;/strong&gt;&lt;br&gt;
You commit to a diagnosis with incomplete information, and every guess gets feedback on its distance from the answer in ICD-10: exact ICD, same category, same group, same chapter or different chapter. A near miss and a distant miss mean different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Play by Continent?&lt;/strong&gt;&lt;br&gt;
A mode with 103 cases organized by regional epidemiology across Africa, the Americas, Asia, Europe and Oceania. It is unlocked with sinapses, the in-game currency you earn in the daily case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do the diagnosis names come from?&lt;/strong&gt;&lt;br&gt;
From the official ICD-10 version for each language, such as ICD-10-GM in German or CIE-10-ES in Spanish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I start?&lt;/strong&gt;&lt;br&gt;
Read &lt;a href="https://nexo.wiki.br/en/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt;, &lt;a href="https://nexo.wiki.br/demo" rel="noopener noreferrer"&gt;try a demo case&lt;/a&gt;, or see how NEXO &lt;a href="https://nexo.wiki.br/en/compare" rel="noopener noreferrer"&gt;compares&lt;/a&gt; with other ways to study. Everything starts at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;NEXO is a clinical reasoning game: guess the diagnosis from up to 6 clues, with ICD-10 feedback, in 6 languages. Play on the web at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; or get it on the &lt;a href="https://apps.apple.com/us/app/nexo-medical-diagnosis/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All cases are fictional cases with real clinical presentations, written for teaching. NEXO is an educational game and is not medical advice.&lt;/p&gt;

</description>
      <category>medicine</category>
      <category>learning</category>
      <category>i18n</category>
      <category>healthcare</category>
    </item>
    <item>
      <title>Why we built NEXO, a daily medical diagnosis game that works like a Wordle for doctors</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:17:03 +0000</pubDate>
      <link>https://dev.to/thiagolongom/why-we-built-nexo-a-daily-medical-diagnosis-game-that-works-like-a-wordle-for-doctors-54k5</link>
      <guid>https://dev.to/thiagolongom/why-we-built-nexo-a-daily-medical-diagnosis-game-that-works-like-a-wordle-for-doctors-54k5</guid>
      <description>&lt;p&gt;NEXO is a daily medical diagnosis game for doctors and medical students. Every day there is one new clinical case, told in up to 6 clues, and you try to name the diagnosis using as few clues as you can. The shortest way to describe it is a Wordle for doctors.&lt;/p&gt;

&lt;p&gt;This post is about why it exists and what we tried to do differently from a quiz.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Wordle for doctors?
&lt;/h2&gt;

&lt;p&gt;Wordle worked because it was small. One puzzle a day, the same for everyone, a few minutes long, and then you're done until tomorrow. Nobody needed a study plan to play it.&lt;/p&gt;

&lt;p&gt;NEXO keeps that shape and changes the content: it's a daily diagnosis puzzle instead of a word puzzle. Instead of letters, you get clinical information: a presentation, a history, an exam finding, a test result. The first clue is visible, and you reveal the next ones only if you need them. Fewer clues, more points.&lt;/p&gt;

&lt;p&gt;The case is the same for every player that day, so you can compare your result with friends and colleagues.&lt;/p&gt;

&lt;p&gt;The point isn't a quiz with a timer. It's a small version of what diagnosis feels like: incomplete information, a working hypothesis, and the decision of when you know enough to commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a medical Wordle different from a word game?
&lt;/h2&gt;

&lt;p&gt;In a word game, a letter is either in the right place or not. A diagnosis has more structure than that, and NEXO is built around one idea: &lt;strong&gt;missing by a little is not the same as missing by a lot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most question banks score you as right or wrong. At the bedside, those two words hide very different situations. If the answer is appendicitis and you said cholecystitis, you were reasoning in the right place and landed on a neighbor. If you said heart attack, you were somewhere else entirely. Both are wrong. They don't mean the same thing.&lt;/p&gt;

&lt;p&gt;So in NEXO you submit each guess by searching ICD-10, the international classification of diseases, and the guess is scored by its distance from the answer in the ICD-10 tree. The feedback has 5 levels: exact ICD, same category, same group, same chapter, or different chapter. That ICD-10 proximity feedback is the core of the game.&lt;/p&gt;

&lt;p&gt;If the answer is an acute myocardial infarction (I21) and your guess comes back as "same chapter", you know you were thinking about the circulatory system. You missed, but not at random.&lt;/p&gt;

&lt;p&gt;We treat a wrong guess as information, not failure. A near miss tells you your reasoning was on track. A distant miss tells you to step back and rethink the problem. Both are more useful than a red X.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a daily medical diagnosis game fit into a busy week?
&lt;/h2&gt;

&lt;p&gt;Clinical reasoning is a skill, and skills improve with repetition and feedback. For most students and residents the hard part isn't motivation. It's time. Long study sessions compete with shifts, exams and sleep.&lt;/p&gt;

&lt;p&gt;One daily case fits in the gaps: the five minutes between two consultations, or the train ride before a shift. You read, you think, you commit, you get feedback. Then you come back tomorrow.&lt;/p&gt;

&lt;p&gt;Over a few weeks, that turns into a five-minute clinical reasoning habit: practice that fits between ward work, classes or question-bank sessions, instead of an occasional cram session.&lt;/p&gt;

&lt;p&gt;That rhythm is deliberate. Streaks, achievements and a global leaderboard turn it into a habit rather than a chore.&lt;/p&gt;

&lt;p&gt;The daily case is also free, forever. We don't see that as a pricing tier. It's a position we hold: the daily practice of clinical reasoning shouldn't live behind a paywall.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NEXO is not
&lt;/h2&gt;

&lt;p&gt;NEXO is not a USMLE or PLAB simulator, a flashcard app, an AI tutor or a clinical reference. Those tools treat medical knowledge as content to study. NEXO treats diagnostic reasoning as something you practice.&lt;/p&gt;

&lt;p&gt;It isn't an ICD-10 coding trainer either. ICD-10 enters as the ruler: you search it to submit a guess, and it measures how far that guess landed from the answer.&lt;/p&gt;

&lt;p&gt;It works alongside whatever you already use. If you want to see where it sits next to AMBOSS and Prognosis, there's a &lt;a href="https://nexo.wiki.br/en/compare" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is a daily medical diagnosis game for?
&lt;/h2&gt;

&lt;p&gt;It's a medical diagnosis game for doctors and students alike: medical students, residents, new doctors, practicing physicians and specialists. Anyone who enjoys the challenge of diagnosis.&lt;/p&gt;

&lt;p&gt;If you're looking for a daily diagnosis game for medical students, the format stays approachable: you can try a diagnosis after the first clue or keep exploring as the case unfolds, and faster, sharper reasoning earns more points.&lt;/p&gt;

&lt;p&gt;Beyond the daily case, there is a Continents mode organized by regional epidemiology and a Multimedia mode with clinical imaging and auscultation. Together that's 649 cases, available in 6 languages. A cardiologist can meet a dermatology case, and a student can meet a presentation they haven't seen on the wards yet.&lt;/p&gt;

&lt;p&gt;All of them are fictional cases with real clinical presentations, written for teaching. None of them describes an identifiable person.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's a good Wordle-like game for doctors?&lt;/strong&gt;&lt;br&gt;
There are a few. Doctordle is a diagnosis guessing game built on daily clinical scenarios. NEXO is a daily medical diagnosis game with up to 6 clues per case, where every guess gets ICD-10 feedback on how close it was, from exact ICD to different chapter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the best medical Wordle?&lt;/strong&gt;&lt;br&gt;
It depends on what you want to practice. If the goal is diagnosis rather than vocabulary, look for a game where a wrong answer still tells you something. In NEXO, every guess is placed in the ICD-10 hierarchy, so a near miss and a distant miss get different feedback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are there games like Doctordle?&lt;/strong&gt;&lt;br&gt;
Yes. If you want a Doctordle alternative with ICD-10 feedback, NEXO is one option: one fictional case a day, up to 6 clues, and ICD-10 proximity feedback on every guess. NEXO also has archived cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does scoring work in this medical Wordle?&lt;/strong&gt;&lt;br&gt;
Each guess is placed in the ICD-10 hierarchy: exact ICD, same category, same group, same chapter or different chapter. The fewer clues you use, the more points you get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this medical diagnosis game free?&lt;/strong&gt;&lt;br&gt;
Yes, the daily case is free, forever. The Continents and Multimedia modes are unlocked with sinapses, the in-game currency you earn in the daily case, on any plan. Pro and Max add more cases beyond the daily one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does a case take?&lt;/strong&gt;&lt;br&gt;
About 5 minutes, which is why it works as a daily habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where can I play?&lt;/strong&gt;&lt;br&gt;
On the web at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt;, and as a medical diagnosis game app for iPhone ("NEXO: Medical Diagnosis" on the App Store). You can read &lt;a href="https://nexo.wiki.br/en/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt; or &lt;a href="https://nexo.wiki.br/demo" rel="noopener noreferrer"&gt;try a demo case&lt;/a&gt; first.&lt;/p&gt;




&lt;p&gt;NEXO is a daily medical diagnosis game for clinical reasoning: one case a day, up to 6 clues, ICD-10 feedback, 6 languages. Play at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; or get it on the &lt;a href="https://apps.apple.com/us/app/nexo-medical-diagnosis/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All cases are fictional cases with real clinical presentations, written for teaching. NEXO is an educational game and is not medical advice.&lt;/p&gt;

</description>
      <category>medicine</category>
      <category>gamedev</category>
      <category>showdev</category>
      <category>wordle</category>
    </item>
    <item>
      <title>i18n in Expo (React Native) and Next.js with one shared dictionary and a fallback language</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Fri, 25 Sep 2026 02:38:12 +0000</pubDate>
      <link>https://dev.to/thiagolongom/i18n-in-expo-react-native-and-nextjs-with-one-shared-dictionary-and-a-fallback-language-4ij8</link>
      <guid>https://dev.to/thiagolongom/i18n-in-expo-react-native-and-nextjs-with-one-shared-dictionary-and-a-fallback-language-4ij8</guid>
      <description>&lt;p&gt;When you ship the same product on mobile and on the web, translations tend to drift apart: a label gets fixed in the app and stays wrong on the site. The simplest cure is one dictionary, shared by both, with one clear rule for what happens when a key is missing.&lt;/p&gt;

&lt;p&gt;This is the setup I'd recommend after working on NEXO, a daily medical diagnosis game for clinical reasoning practice, which runs in six languages on an Expo app and a Next.js site. The code below is a generic example with a tiny dictionary, not NEXO's actual files. It uses no i18n library, just TypeScript. Libraries like i18next are great too, and the concepts are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should I structure translation files for React Native and Next.js?
&lt;/h2&gt;

&lt;p&gt;Keep them in a folder both apps can import (a workspace package, or a shared folder with a path alias):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/i18n/messages.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCALES&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Dict&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Today&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.greeting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hi, {name}!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak.one&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{count} day&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak.other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{count} days&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hoy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.greeting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;¡Hola, {name}!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Aujourd'hui&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.greeting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;de&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="na"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hoje&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ru&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Сегодня&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak.one&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{count} день&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak.few&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{count} дня&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak.many&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{count} дней&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak.other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{count} дня&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flat keys (&lt;code&gt;'home.title'&lt;/code&gt;) are easy to grep, easy to diff, and easy to check for missing entries in CI. Notice the empty French greeting. Half-finished files look like that all the time, and your fallback should handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should happen when a translation is missing?
&lt;/h2&gt;

&lt;p&gt;Decide it once, in one function. A sensible chain is: current language, then the fallback language, then the key itself. An empty string counts as missing, so a blank entry never renders as a blank button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/i18n/t.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// '' counts as missing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{(\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`{&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.greeting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ana&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// "Hi, Ana!" (French entry is empty)&lt;/span&gt;
&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.subtitle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                  &lt;span class="c1"&gt;// "home.subtitle" (missing everywhere)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning the raw key is deliberate. In development, &lt;code&gt;home.subtitle&lt;/code&gt; on screen tells you exactly what's missing. In code, you can detect it (&lt;code&gt;result === key&lt;/code&gt;) and choose a different fallback for a specific case.&lt;/p&gt;

&lt;p&gt;A tip from experience: long content (articles, product descriptions, help pages) often deserves a different rule from interface labels. A missing button label in English is fine. A paragraph that switches language halfway through is not. Whatever you choose there, make it an explicit decision rather than a side effect of the UI fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I handle plurals across languages?
&lt;/h2&gt;

&lt;p&gt;English has two plural forms. Russian has more, and the choice depends on the number's last digits: 1 день, 2 дня, 5 дней, 21 день. &lt;code&gt;Intl.PluralRules&lt;/code&gt; knows the rules for each locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;translate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PluralRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'one' | 'few' | 'many' | 'other' ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasKey&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.other`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "3 дня"&lt;/span&gt;
&lt;span class="nf"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "21 день"&lt;/span&gt;
&lt;span class="nf"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;streak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "3 days"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always define &lt;code&gt;.other&lt;/code&gt; in every language that has plurals. It's the category &lt;code&gt;Intl.PluralRules&lt;/code&gt; returns for fractions in Russian, and it's the safety net when a category is missing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Intl.PluralRules&lt;/code&gt; is available in modern browsers and Node. On React Native, check what your JavaScript engine supports; if it's missing, add a polyfill such as &lt;code&gt;@formatjs/intl-pluralrules&lt;/code&gt; with the locale data you need.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;Intl.DateTimeFormat(locale)&lt;/code&gt; and &lt;code&gt;Intl.NumberFormat(locale)&lt;/code&gt; for dates and numbers too, instead of formatting them by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I detect the device language in Expo?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;expo-localization&lt;/code&gt; gives you the user's preferred locales in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/i18n/LanguageProvider.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getLocales&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-localization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@react-native-async-storage/async-storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/i18n/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;translate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/i18n/t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app.language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deviceLocale&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;getLocales&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;languageCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;languageCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;I18n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;setLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;I18n&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LanguageProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLocaleState&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deviceLocale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userChose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userChose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;setLocaleState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;userChose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// an explicit choice beats anything loaded later&lt;/span&gt;
    &lt;span class="nf"&gt;setLocaleState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLocale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useI18n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;useI18n must be used inside LanguageProvider&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;userChose&lt;/code&gt; ref covers a subtle case: if saved settings (or a user profile from your backend) load after the user has picked a language, they shouldn't overwrite that choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I set up locale routes in the Next.js App Router?
&lt;/h2&gt;

&lt;p&gt;Put the locale in the URL. It's shareable, cacheable and good for SEO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  [lang]/
    layout.tsx
    page.tsx
middleware.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirect bare paths to a locale based on the &lt;code&gt;Accept-Language&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts (Next.js 16 renames this file to proxy.ts and the export to `proxy`)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/i18n/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accept-language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preferred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;preferred&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!api|_next|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;..*).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browsers send their languages in order of preference, so taking the first supported one works for almost all traffic. If you need full &lt;code&gt;q&lt;/code&gt;-value handling, the &lt;code&gt;negotiator&lt;/code&gt; package parses the header properly.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt;, reject unknown locales, and tell search engines about every version of the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/[lang]/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/i18n/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;translate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/i18n/t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Metadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&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="na"&gt;metadataBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;])),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;notFound&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Next.js 15 and later &lt;code&gt;params&lt;/code&gt; is a promise, so it's awaited. Typing it as &lt;code&gt;string&lt;/code&gt; and narrowing with &lt;code&gt;isLocale&lt;/code&gt; keeps TypeScript honest, because the URL can contain anything. The &lt;code&gt;alternates.languages&lt;/code&gt; map becomes &lt;code&gt;hreflang&lt;/code&gt; links (with &lt;code&gt;metadataBase&lt;/code&gt; making them absolute), which help search engines show the right language to the right user.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep six languages in sync?
&lt;/h2&gt;

&lt;p&gt;A fallback hides gaps, which is good for users and bad for noticing them. Add a CI check that lists keys present in the fallback language but missing or empty elsewhere. It compares base keys, because Russian legitimately has &lt;code&gt;.few&lt;/code&gt; and &lt;code&gt;.many&lt;/code&gt; entries that English doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-messages.ts (run with: npx tsx scripts/check-messages.ts)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../shared/i18n/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLURAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;zero|one|two|few|many|other&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLURAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;have&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the example dictionary, it fails and prints lines like &lt;code&gt;fr: home.greeting, streak&lt;/code&gt; and &lt;code&gt;de: home.title, home.greeting, streak&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two more habits help. First, have specialized text reviewed by a native speaker who knows the field; machine translation is a first draft at best, and for a medical product like NEXO one wrong term can change the meaning. Second, use one locale format everywhere (&lt;code&gt;en&lt;/code&gt; or &lt;code&gt;en-US&lt;/code&gt;) and convert only at the edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One shared dictionary for the app and the site, imported by both.&lt;/li&gt;
&lt;li&gt;One definition of "missing" (absent or empty), applied in one function, so the two platforms can never disagree about it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Intl.PluralRules&lt;/code&gt; instead of hand-written plural logic, with &lt;code&gt;.other&lt;/code&gt; always defined.&lt;/li&gt;
&lt;li&gt;The language in the URL on the web, and the device language (overridable by the user) in the app.&lt;/li&gt;
&lt;li&gt;A CI check, so gaps show up in a pull request instead of in front of a user.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need an i18n library?&lt;/strong&gt;&lt;br&gt;
Not for small or medium apps. A typed dictionary and a &lt;code&gt;translate()&lt;/code&gt; function go a long way. Reach for a library when you need ICU message syntax or translation-platform integrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should the fallback be English?&lt;/strong&gt;&lt;br&gt;
Usually, for interface text. Choose whatever language most of your users can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the Expo app and the Next.js site share the same files?&lt;/strong&gt;&lt;br&gt;
Yes. Plain TypeScript objects work in both, and sharing them is what keeps the two apps from drifting.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://nexo.wiki.br" rel="noopener noreferrer"&gt;NEXO&lt;/a&gt;, a daily medical diagnosis game for clinical reasoning practice, where a wrong guess is scored by its proximity in the ICD-10 hierarchy, available in six languages. See &lt;a href="https://nexo.wiki.br/en/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt;, or play in &lt;a href="https://nexo.wiki.br/es" rel="noopener noreferrer"&gt;Spanish&lt;/a&gt; or &lt;a href="https://nexo.wiki.br/de" rel="noopener noreferrer"&gt;German&lt;/a&gt;. On iPhone: &lt;a href="https://apps.apple.com/app/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every case in NEXO is fictional and written for teaching. NEXO is not medical advice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>nextjs</category>
      <category>i18n</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Fuzzy search in Postgres with pg_trgm and Supabase (multilingual, typo-tolerant)</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Fri, 25 Sep 2026 02:35:02 +0000</pubDate>
      <link>https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf</link>
      <guid>https://dev.to/thiagolongom/fuzzy-search-in-postgres-with-pgtrgm-and-supabase-multilingual-typo-tolerant-2ipf</guid>
      <description>&lt;p&gt;Users type "cafe" for "Café", "munchen" for "München", or miss a letter. You don't need a separate search engine for that. Postgres ships &lt;code&gt;pg_trgm&lt;/code&gt; for fuzzy matching and &lt;code&gt;unaccent&lt;/code&gt; for accent-insensitive comparison, and both are available on Supabase.&lt;/p&gt;

&lt;p&gt;NEXO is a daily medical diagnosis game for clinical reasoning practice, built on Supabase in six languages, so tolerant search matters to me. This tutorial uses an invented &lt;code&gt;places&lt;/code&gt; table; every snippet was run on PostgreSQL 17.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I enable pg_trgm and unaccent on Supabase?
&lt;/h2&gt;

&lt;p&gt;In the Supabase dashboard, go to Database → Extensions and enable &lt;code&gt;pg_trgm&lt;/code&gt; and &lt;code&gt;unaccent&lt;/code&gt;, or run this in the SQL editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;extension&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;extension&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase's default &lt;code&gt;search_path&lt;/code&gt; includes &lt;code&gt;extensions&lt;/code&gt;; inside functions, set it explicitly or schema-qualify.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is pg_trgm and how does it match typos?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_trgm&lt;/code&gt; splits text into trigrams (three-character groups, with words padded by spaces) and compares how many two strings share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;show_trgm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'londn'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- {"  l"," lo","dn ",lon,ndn,ond}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It provides scoring functions, threshold operators, and GIN/GiST operator classes so they (and &lt;code&gt;LIKE '%...%'&lt;/code&gt;) can use an index.&lt;/p&gt;

&lt;h2&gt;
  
  
  What example table does this tutorial use?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;    &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lang&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'es'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'de'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ru'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang&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="k"&gt;values&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'London'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Londonderry'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'New London'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Babylon'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Café de Flore'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'de'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'München'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ru'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Москва'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I make Postgres search accent-insensitive?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;unaccent()&lt;/code&gt; is &lt;code&gt;STABLE&lt;/code&gt;, not &lt;code&gt;IMMUTABLE&lt;/code&gt;, because the dictionary it uses depends on &lt;code&gt;search_path&lt;/code&gt;. Indexes and generated columns need immutable functions, so wrap it and pin the dictionary with the schema-qualified two-argument form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;immutable_unaccent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;immutable&lt;/span&gt;
&lt;span class="n"&gt;parallel&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;
&lt;span class="k"&gt;strict&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'extensions.unaccent'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regdictionary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If you installed the extension into &lt;code&gt;public&lt;/code&gt;, use &lt;code&gt;public.unaccent('public.unaccent', $1)&lt;/code&gt;.) Now add a normalized column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt;
  &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
  &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;immutable_unaccent&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="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lower()&lt;/code&gt; follows the database's &lt;code&gt;LC_CTYPE&lt;/code&gt;, so with a UTF-8 locale "МОСКВА" and "москва" normalize to the same value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which index should I use for pg_trgm, GIN or GiST?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;places_name_norm_trgm&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GIN accelerates &lt;code&gt;%&lt;/code&gt;, &lt;code&gt;&amp;lt;%&lt;/code&gt;, &lt;code&gt;LIKE&lt;/code&gt; and &lt;code&gt;ILIKE&lt;/code&gt;, but not &lt;code&gt;ORDER BY ... &amp;lt;-&amp;gt;&lt;/code&gt;; index-assisted nearest-neighbour ordering needs GiST (&lt;code&gt;gist_trgm_ops&lt;/code&gt;). When you filter first and sort the candidates, GIN is a good default.&lt;/p&gt;

&lt;h2&gt;
  
  
  similarity vs word_similarity: which pg_trgm function should I use?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'london'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'londn'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;-- 0.44444445&lt;/span&gt;
       &lt;span class="n"&gt;word_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lond'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new london'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;-- 0.8&lt;/span&gt;
       &lt;span class="s1"&gt;'londn'&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'london'&lt;/span&gt;     &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fuzzy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- true&lt;/span&gt;
       &lt;span class="s1"&gt;'lond'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="s1"&gt;'new london'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;word_fuzzy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;similarity(a, b)&lt;/code&gt; compares whole strings; &lt;code&gt;a % b&lt;/code&gt; is true when it exceeds &lt;code&gt;pg_trgm.similarity_threshold&lt;/code&gt; (default 0.3).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;word_similarity(a, b)&lt;/code&gt; compares &lt;code&gt;a&lt;/code&gt; with the best-matching part of &lt;code&gt;b&lt;/code&gt;; &lt;code&gt;a &amp;lt;% b&lt;/code&gt; uses &lt;code&gt;pg_trgm.word_similarity_threshold&lt;/code&gt; (default 0.6). Ideal for search-as-you-type.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;a &amp;lt;-&amp;gt; b&lt;/code&gt; is the distance, &lt;code&gt;1 - similarity(a, b)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thresholds are ordinary settings (per session, role or function):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;similarity_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'munchen'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;places&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'munchen'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'munchen'&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- München | 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I rank exact, prefix and fuzzy matches in one query?
&lt;/h2&gt;

&lt;p&gt;Users expect "lon" to show "London" before "Babylon", so the function ranks exact matches, then prefixes, then word prefixes, then fuzzy matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_query&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_lang&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_limit&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="nb"&gt;real&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;invoker&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;word_similarity_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;
  &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;immutable_unaccent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&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;query&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;
            &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;                   &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
            &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;         &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
            &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="s1"&gt;'% '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;word_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;real&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;places&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_lang&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;
      &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="s1"&gt;'% '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;
      &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
  &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="n"&gt;least&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;greatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- London 3, Londonderry 3, New London 2, Babylon 0.5&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cafe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;-- Café de Flore&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MUNCHEN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'de'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- München&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'москв'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ru'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;-- Москва&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;security invoker&lt;/code&gt;&lt;/strong&gt; (the default, made explicit) runs with the caller's privileges, so row-level security still applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stable&lt;/code&gt;&lt;/strong&gt; declares that it doesn't modify data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set search_path&lt;/code&gt;&lt;/strong&gt; makes the extension operators resolve no matter who calls it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip fuzzy matching under three characters&lt;/strong&gt;; prefix matching is enough there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the limit&lt;/strong&gt; in the database, not only in the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the threshold by scoring your own typos and unrelated words, not by guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I secure the search function with grants and RLS on Supabase?
&lt;/h2&gt;

&lt;p&gt;Postgres grants &lt;code&gt;execute&lt;/code&gt; on new functions to &lt;code&gt;PUBLIC&lt;/code&gt;, and Supabase's default privileges also grant it to &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt;. If search should require sign-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt;  &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"places are readable by signed-in users"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;using (true)&lt;/code&gt; with your own rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I call the search function from supabase-js?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;searchPlaces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search_places&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;p_query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;p_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;p_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Debounce input and ignore out-of-order responses. In NEXO, that plus a server-side limit keeps search responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check that Postgres uses the trigram index?
&lt;/h2&gt;

&lt;p&gt;With seven rows Postgres will (correctly) scan the table, so I loaded 200,000 extra rows and ran the function's query with a literal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;explain&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;costs&lt;/span&gt; &lt;span class="k"&gt;off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;places&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="s1"&gt;'londn%'&lt;/span&gt;
       &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="s1"&gt;'% londn%'&lt;/span&gt;
       &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="s1"&gt;'londn'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;places&lt;/span&gt;
   &lt;span class="k"&gt;Recheck&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;~~&lt;/span&gt; &lt;span class="s1"&gt;'londn%'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;~~&lt;/span&gt; &lt;span class="s1"&gt;'% londn%'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'londn'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
   &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;~~&lt;/span&gt; &lt;span class="s1"&gt;'londn%'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;~~&lt;/span&gt; &lt;span class="s1"&gt;'% londn%'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'londn'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;name_norm&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
   &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;BitmapOr&lt;/span&gt;
         &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;places_name_norm_trgm&lt;/span&gt;
               &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;~~&lt;/span&gt; &lt;span class="s1"&gt;'londn%'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;places_name_norm_trgm&lt;/span&gt;
               &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;~~&lt;/span&gt; &lt;span class="s1"&gt;'% londn%'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;places_name_norm_trgm&lt;/span&gt;
               &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_norm&lt;/span&gt; &lt;span class="o"&gt;%&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'londn'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For broad terms matching many rows, a sequential scan can be the right plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does pg_trgm work for Cyrillic, CJK and other languages?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check your locale.&lt;/strong&gt; &lt;code&gt;pg_trgm&lt;/code&gt; only keeps characters that &lt;code&gt;LC_CTYPE&lt;/code&gt; considers letters or digits. In a UTF-8 locale (such as &lt;code&gt;C.UTF-8&lt;/code&gt; or &lt;code&gt;en_US.UTF-8&lt;/code&gt;) Cyrillic, Greek and accented Latin work. In a database with &lt;code&gt;LC_CTYPE = C&lt;/code&gt;, &lt;code&gt;show_trgm('москва')&lt;/code&gt; returns &lt;code&gt;{}&lt;/code&gt; and "Café" loses its "é". Check with &lt;code&gt;select datctype from pg_database where datname = current_database();&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CJK is weak.&lt;/strong&gt; Without spaces between words, a phrase becomes one long "word" and short queries score low: &lt;code&gt;similarity('東京', '東京タワー')&lt;/code&gt; is about 0.29, under the default threshold. Consider bigram-based approaches such as the &lt;code&gt;pg_bigm&lt;/code&gt; extension, where available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unaccent&lt;/code&gt; is for Latin-script diacritics.&lt;/strong&gt; It won't transliterate "Москва" to "Moskva". If you need cross-script matching, store a transliterated alias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigrams aren't stemming.&lt;/strong&gt; Long documents want full-text search.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd keep from this
&lt;/h2&gt;

&lt;p&gt;An immutable &lt;code&gt;unaccent&lt;/code&gt; wrapper, a generated normalized column, one GIN index and a &lt;code&gt;stable&lt;/code&gt;, &lt;code&gt;security invoker&lt;/code&gt; function cover most search boxes without new infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is pg_trgm available on Supabase?&lt;/strong&gt;&lt;br&gt;
Yes. Enable it under Database, Extensions, or run &lt;code&gt;create extension pg_trgm with schema extensions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why can't I use unaccent() directly in an index?&lt;/strong&gt;&lt;br&gt;
It is &lt;code&gt;STABLE&lt;/code&gt;, not &lt;code&gt;IMMUTABLE&lt;/code&gt;. Wrap it in an immutable SQL function that pins the dictionary, then index a generated column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIN or GiST for trigram search?&lt;/strong&gt;&lt;br&gt;
GIN is a good default when you filter first and then sort. GiST is needed for index-assisted &lt;code&gt;ORDER BY ... &amp;lt;-&amp;gt;&lt;/code&gt; nearest-neighbour ordering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does pg_trgm replace full-text search?&lt;/strong&gt;&lt;br&gt;
For names, titles and short labels, usually yes. For long documents and stemming, use full-text search.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://nexo.wiki.br" rel="noopener noreferrer"&gt;NEXO&lt;/a&gt;, a daily medical diagnosis game for clinical reasoning practice, where a wrong guess is scored by its proximity in the ICD-10 hierarchy, available in six languages. See &lt;a href="https://nexo.wiki.br/en/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt; or &lt;a href="https://nexo.wiki.br/en/compare" rel="noopener noreferrer"&gt;how it compares&lt;/a&gt;. On iPhone: &lt;a href="https://apps.apple.com/app/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every case in NEXO is fictional and written for teaching. NEXO is not medical advice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Grading a wrong answer by how wrong it is: ICD-10 proximity scoring in Postgres</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Mon, 21 Sep 2026 22:01:43 +0000</pubDate>
      <link>https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia</link>
      <guid>https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia</guid>
      <description>&lt;p&gt;Most quiz apps have one bit of output: right or wrong. For a medical diagnosis game, one bit throws away almost everything interesting.&lt;/p&gt;

&lt;p&gt;A physician who answers &lt;em&gt;unstable angina&lt;/em&gt; when the case is a &lt;em&gt;myocardial infarction&lt;/em&gt; has done something very different from one who answers &lt;em&gt;bacterial meningitis&lt;/em&gt;. The first is a neighbour on the differential. The second is a different organ system entirely. Collapse both into "wrong" and the learner gets no signal about which mistake they made.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://nexo.wiki.br" rel="noopener noreferrer"&gt;NEXO&lt;/a&gt;, a daily clinical reasoning game. Players answer with an ICD-10 diagnosis, and we score the answer by &lt;strong&gt;how far it sits from the correct code on the ICD-10 tree&lt;/strong&gt;. This post is about how that is implemented, why it lives in the database, and a tautology that quietly killed one of the five levels for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ICD-10 code is already a tree
&lt;/h2&gt;

&lt;p&gt;An ICD-10 code carries its own hierarchy in its characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;G40.1
│││ └── subdivision
││└──── category    (G40, epilepsy)
│└───── block       (G4x, episodic and paroxysmal disorders)
└────── chapter     (G, diseases of the nervous system)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you do not need a graph traversal or a distance matrix to know that &lt;code&gt;G40&lt;/code&gt; and &lt;code&gt;G43&lt;/code&gt; are closer than &lt;code&gt;G40&lt;/code&gt; and &lt;code&gt;J18&lt;/code&gt;. You need a prefix comparison. Walk up from the full code, one character at a time, and the first level where the two codes agree is the proximity.&lt;/p&gt;

&lt;p&gt;That gives five outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix match&lt;/th&gt;
&lt;th&gt;Feedback&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;full code&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;correct answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 characters&lt;/td&gt;
&lt;td&gt;category&lt;/td&gt;
&lt;td&gt;right category, wrong subdivision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 characters&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;right block of related conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 character&lt;/td&gt;
&lt;td&gt;chapter&lt;/td&gt;
&lt;td&gt;right chapter, wrong disease&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;distant&lt;/td&gt;
&lt;td&gt;wrong branch entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why this runs on the server
&lt;/h2&gt;

&lt;p&gt;The scoring is a Postgres function, not client code. That is deliberate.&lt;/p&gt;

&lt;p&gt;The client never holds the correct answer and never decides whether a guess was right. It sends a string; it gets back a verdict. Putting the comparison in the app would mean shipping the answer to the device, which for a daily game with a global leaderboard means shipping tomorrow's answers to anyone willing to read a memory dump.&lt;/p&gt;

&lt;p&gt;The function is &lt;code&gt;SECURITY DEFINER&lt;/code&gt; with a pinned &lt;code&gt;search_path&lt;/code&gt;, and it takes a row lock on the game session before it touches anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_sessao&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sessoes_jogo&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_sessao_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;usuario_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_uid&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;FOR UPDATE&lt;/code&gt; matters more than it looks. Without it, two submissions racing from a flaky connection can both read the same attempt count, both pass the six-attempt cap, and both append to the attempts array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalising before comparing
&lt;/h2&gt;

&lt;p&gt;ICD-10 codes reach us in several shapes. &lt;code&gt;G40.1&lt;/code&gt;, &lt;code&gt;G401&lt;/code&gt;, &lt;code&gt;g40.1&lt;/code&gt;. Before any comparison, both sides are stripped to alphanumerics and upper-cased:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;   &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_tentativa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[^A-Za-z0-9]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;v_correto_norm&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_caso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cid10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'[^A-Za-z0-9]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normalising here and not at the edge means the rule holds no matter which client is calling. It also means the prefix comparison below can be plain &lt;code&gt;left()&lt;/code&gt; on a clean string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;v_guess_norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_correto_norm&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'exato'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'categoria'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'grupo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'capitulo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ELSE&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'distante'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four comparisons, no lookup table, no ICD-10 reference data at query time. The hierarchy is in the string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a condition that was always true
&lt;/h2&gt;

&lt;p&gt;The version before this one tried to express "same numeric block" as a range check instead of a prefix check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'9'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'grupo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'capitulo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the range condition carefully. If the first characters already match, then &lt;code&gt;left(guess, 2)&lt;/code&gt; is that letter followed by one more character. The bounds are that same letter followed by &lt;code&gt;0&lt;/code&gt; and by &lt;code&gt;9&lt;/code&gt;. Every digit falls inside. So for any two codes in the same chapter, the &lt;code&gt;grupo&lt;/code&gt; branch always fired.&lt;/p&gt;

&lt;p&gt;Which made the &lt;code&gt;capitulo&lt;/code&gt; branch &lt;strong&gt;dead code&lt;/strong&gt;. The fifth level existed in the schema, in the client, in the copy on the website, and never once reached a player.&lt;/p&gt;

&lt;p&gt;Nothing crashed. No error was logged. Players got a slightly-too-generous verdict and there was no way to notice from the outside, because "same block" and "same chapter" both render as an amber near miss. It only surfaced when someone went looking for how often each level fires and found one of them at exactly zero.&lt;/p&gt;

&lt;p&gt;The fix was to delete the cleverness and compare prefixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A tautology is the quietest kind of bug.&lt;/strong&gt; It does not throw. It does not return null. It returns a plausible answer, forever. The only signal was a distribution with a zero in it, and you only see that if you go looking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the hierarchy in the identifier when you can.&lt;/strong&gt; ICD-10, and a lot of other clinical coding, is designed so that the code is the path. That turns "how related are these two diagnoses" from a graph problem into string slicing, which is fast enough to run inside the transaction that records the attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boolean ranges deserve a truth table.&lt;/strong&gt; Both branches of that &lt;code&gt;ELSIF&lt;/code&gt; chain read fine in review. The failure only shows up if you write down what the range actually evaluates to when the first character matches.&lt;/p&gt;




&lt;p&gt;NEXO is a daily clinical reasoning game for physicians and medical students. One fictional case a day, six clues in order, and an answer given as an ICD-10 code. The daily case is free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nexo.wiki.br" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; · &lt;a href="https://apps.apple.com/app/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every case is fictional and written for teaching. NEXO is not medical advice and is not for use with real patients.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>healthcare</category>
      <category>supabase</category>
    </item>
    <item>
      <title>I used the ICD-10 tree as a scoring function</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Mon, 21 Sep 2026 18:41:33 +0000</pubDate>
      <link>https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b</link>
      <guid>https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b</guid>
      <description>&lt;p&gt;Most medical quiz apps score you as right or wrong. That throws away the part that matters. In clinical reasoning, mistaking cholecystitis for appendicitis and mistaking it for a myocardial infarction are not the same mistake. One is a near miss inside the acute abdomen. The other is a different organ system.&lt;/p&gt;

&lt;p&gt;So when we built NEXO, a daily diagnosis game for physicians and medical students, we made the scoring reflect that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;ICD-10 is a tree. A code like I21.0 sits inside category I21, inside group I20-I25, inside the chapter for diseases of the circulatory system. That hierarchy is already a distance metric. We just had to use it.&lt;/p&gt;

&lt;p&gt;When a player submits a guess, we compare the guessed code against the answer and return the deepest node they share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exact code         I21.0  vs  I21.0
same category      I21.9  vs  I21.0
same group         I20    vs  I21.0
same chapter       I50    vs  I21.0
different chapter  J18    vs  I21.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five levels instead of two. A player who lands on "same chapter" learns they were reasoning in the right system and missed the specific entity. A player who lands on "different chapter" learns something else entirely. Both are wrong answers, and they are not worth the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the answer secret
&lt;/h2&gt;

&lt;p&gt;The daily case is the same for every player worldwide, and the answer has to stay hidden until the day rolls over. The comparison runs server side against the stored code. The client never receives the answer, only the level. That kept the API small: send a code, receive an enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder problem: cases that hold up
&lt;/h2&gt;

&lt;p&gt;Generating a plausible clinical case with an LLM takes seconds. Generating one a physician will not tear apart is a different problem. A clue that sounds right but does not belong to the diagnosis is worse than no clue at all.&lt;/p&gt;

&lt;p&gt;We built NEXO Core for that. Every disease, syndrome, exam, clinical finding and ICD-10 code is a node. Edges connect what clinical practice treats as neighbors: pneumonia connects to its etiological agent, its radiological pattern, its differentials. The graph currently holds around 11,500 concepts and 26,000 edges, drawn from 27 reference textbooks.&lt;/p&gt;

&lt;p&gt;Every generated case crosses that graph before approval. Each clue has to appear among the neighboring concepts of the proposed diagnosis. A clue that does not, fails. It is a structural check, not a human reading it over and nodding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React Native with Expo for the app&lt;/li&gt;
&lt;li&gt;Supabase for Postgres, auth, storage and edge functions&lt;/li&gt;
&lt;li&gt;Next.js on Vercel for the marketing site and the case management backoffice&lt;/li&gt;
&lt;li&gt;RevenueCat and StoreKit 2 for subscriptions&lt;/li&gt;
&lt;li&gt;Sentry for crash reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;649 cases across 25 specialties, in six languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Start with the graph, not the cases. We wrote a few hundred cases before the graph existed and had to re-audit all of them against it afterwards. That audit found problems a read-through had missed.&lt;/p&gt;

&lt;p&gt;NEXO is live on iOS and the daily case is free forever, with no ads. The site is at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; and the app is on the &lt;a href="https://apps.apple.com/app/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the graph or the scoring.&lt;/p&gt;

</description>
      <category>computerscience</category>
      <category>learning</category>
      <category>science</category>
    </item>
  </channel>
</rss>
