<?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: LoveCalcs</title>
    <description>The latest articles on DEV Community by LoveCalcs (lovecalcsapp).</description>
    <link>https://dev.to/lovecalcsapp</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%2Forganization%2Fprofile_image%2F15591%2Fc520cc4c-9d72-4e25-8a0f-ade766896a71.png</url>
      <title>DEV Community: LoveCalcs</title>
      <link>https://dev.to/lovecalcsapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lovecalcsapp"/>
    <language>en</language>
    <item>
      <title>Making a love calculator work in 11 languages: Unicode names, stable scores and locale routing in Next.js</title>
      <dc:creator>LoveCalcs</dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:52:02 +0000</pubDate>
      <link>https://dev.to/lovecalcsapp/making-a-love-calculator-work-in-11-languages-unicode-names-stable-scores-and-locale-routing-in-1304</link>
      <guid>https://dev.to/lovecalcsapp/making-a-love-calculator-work-in-11-languages-unicode-names-stable-scores-and-locale-routing-in-1304</guid>
      <description>&lt;p&gt;A love calculator looks like the simplest app on the internet: two names in, a percentage out. We run one at &lt;a href="https://www.lovecalcs.com" rel="noopener noreferrer"&gt;LoveCalcs&lt;/a&gt; in 11 languages, and doing that properly turned out to have a few interesting edges. Here are the ones that needed most care, with the real code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The same names must always give the same score
&lt;/h2&gt;

&lt;p&gt;If two friends type the same names and get different results, the whole thing feels broken. So there is no randomness: the score is a pure function of the two names.&lt;/p&gt;

&lt;p&gt;The main score is what we call the Affinity Quotient. It blends five sub-scores, each from 0 to 100: letter frequency similarity (L), a simplified phonetic match (P), how close the first letters are (S), vowel balance (B) and a numerology-style "destiny number" (N):&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;// Apply weights and calculate final score&lt;/span&gt;
  &lt;span class="c1"&gt;// AQ_raw = 0.25·L + 0.20·P + 0.15·S + 0.15·B + 0.10·N + 15&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;L&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;N&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Clip to [0,100] and round to 1 decimal place&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawScore&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&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="nx"&gt;S&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;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;final&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weights add up to 0.85, so with the constant 15 a perfect match on every metric lands on exactly 100. Every sub-score is also symmetric: it uses an absolute difference, a cosine similarity or an edit distance. That means swapping the two names never changes the result.&lt;/p&gt;

&lt;p&gt;There is also an older, simpler scorer that we kept as a fallback. It is a hash of the two normalised names:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Use Unicode-aware normalization and keep all Unicode letters&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;keepOnlyUnicodeLetters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeUnicodeName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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;function&lt;/span&gt; &lt;span class="nf"&gt;hashNamesToScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name1&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;name2&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;number&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;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Use Unicode-aware hashing&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hashUnicodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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;hash&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;101&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;Using &lt;code&gt;% 101&lt;/code&gt; rather than &lt;code&gt;% 100&lt;/code&gt; is deliberate: it allows every result from 0 to 100, so a perfect 100% is possible.&lt;/p&gt;

&lt;p&gt;Unlike the Affinity Quotient, this one is order-sensitive because it hashes a concatenation, so the lookup for special-case name pairs checks both orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Names are not ASCII
&lt;/h2&gt;

&lt;p&gt;People type names in Arabic, Devanagari, Japanese, Chinese and Cyrillic as well as Latin. If you keep only &lt;code&gt;a-z&lt;/code&gt;, every non-Latin name collapses to an empty string and every non-Latin couple gets the same score. So the input check accepts any Unicode letter or combining mark, plus spaces, hyphens and apostrophes, and both scorers then run every name through the same two steps:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeUnicodeName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;name&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="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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Use NFKC normalization for better Unicode compatibility&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&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;keepOnlyUnicodeLetters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Keep Unicode letters (\p{L}) and combining marks (\p{M})&lt;/span&gt;
  &lt;span class="c1"&gt;// Combining marks are essential for proper rendering of many scripts&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;name&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;[^\p&lt;/span&gt;&lt;span class="sr"&gt;{L}&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{M}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/gu&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why NFKC and not NFC?&lt;/strong&gt; Both forms fix canonical equivalence: "é" typed as the single code point U+00E9 and "e" followed by the combining accent U+0301 become the same string. NFKC also folds compatibility characters. Full-width Latin letters, which Japanese and Chinese input methods can produce, become ordinary Latin letters. Half-width katakana become standard katakana, and ligatures such as "ﬁ" become two letters. Each is the same name typed on a different keyboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What &lt;code&gt;\p{L}\p{M}&lt;/code&gt; keeps.&lt;/strong&gt; &lt;code&gt;\p{L}&lt;/code&gt; is every letter in every script: Latin, Cyrillic, Arabic, Devanagari, CJK ideographs, hiragana and katakana. It even covers the katakana long vowel mark "ー", which Unicode classes as a modifier letter. &lt;code&gt;\p{M}&lt;/code&gt; keeps combining marks, such as Devanagari vowel signs and Arabic vowel marks. Strip those and you change the name. Everything else goes: spaces, hyphens, apostrophes, digits, punctuation and emoji. So a hyphenated double name scores the same as the same name written with a space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;u&lt;/code&gt; flag matters.&lt;/strong&gt; Without it, &lt;code&gt;\p{L}&lt;/code&gt; is not a Unicode property escape at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count characters, not code units.&lt;/strong&gt; &lt;code&gt;.length&lt;/code&gt; counts UTF-16 code units, so most of the scoring code walks names with &lt;code&gt;Array.from(name)&lt;/code&gt;, which splits by code point.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A 32-bit hash that agrees with itself everywhere
&lt;/h2&gt;

&lt;p&gt;The scoring runs in the browser, but the functions are plain TypeScript with no DOM or Node dependencies. The fallback hash is the classic multiply-by-31 string hash:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashUnicodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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;number&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;hash&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Handle multi-byte Unicode&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chars&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Convert to 32bit integer&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&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;Shifting left by five bits and subtracting the original value is the same as multiplying by 31. The reason this is stable across every browser and Node is that JavaScript's bitwise operators are defined in the language spec to convert their operands to signed 32-bit integers. So &lt;code&gt;&amp;lt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;amp; hash&lt;/code&gt; wrap overflow in exactly the same way in every engine, and the intermediate values stay well inside the range where JavaScript numbers hold integers exactly. &lt;code&gt;Math.abs&lt;/code&gt; then makes the result non-negative before the &lt;code&gt;% 101&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The case folding is stable for a similar reason. &lt;code&gt;toLowerCase()&lt;/code&gt; uses Unicode's default case mapping, not the user's locale, so a browser set to Turkish produces the same result as a server in a data centre. &lt;code&gt;toLocaleLowerCase()&lt;/code&gt; would not.&lt;/p&gt;

&lt;p&gt;One caveat if you adapt it: &lt;code&gt;charCodeAt(0)&lt;/code&gt; reads a single UTF-16 code unit, so for characters outside the Basic Multilingual Plane it sees only the first half of the surrogate pair. That is still deterministic, but &lt;code&gt;codePointAt(0)&lt;/code&gt; would tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One codebase, 11 locales, predictable URLs
&lt;/h2&gt;

&lt;p&gt;Every tool exists in English, Spanish, French, German, Italian, Portuguese, Russian, Chinese, Japanese, Arabic and Hindi, and the middleware, layout and sitemap all import one shared &lt;code&gt;locales&lt;/code&gt; array. We use the Next.js App Router with a &lt;code&gt;[locale]&lt;/code&gt; segment and the &lt;code&gt;next-intl&lt;/code&gt; middleware:&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;createMiddleware&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// A list of all locales that are supported&lt;/span&gt;
  &lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;// Used when no locale matches&lt;/span&gt;
  &lt;span class="na"&gt;defaultLocale&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="c1"&gt;// Disable automatic locale detection to always default to English&lt;/span&gt;
  &lt;span class="na"&gt;localeDetection&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="c1"&gt;// Never use locale prefix for English, always for other languages&lt;/span&gt;
  &lt;span class="na"&gt;localePrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;as-needed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prefixes&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;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="p"&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;With &lt;code&gt;as-needed&lt;/code&gt; and an empty English prefix, English lives at the root (&lt;code&gt;/love-calculator&lt;/code&gt;) and every other language has a prefix, as with the &lt;a href="https://www.lovecalcs.com/ja/love-calculator" rel="noopener noreferrer"&gt;Japanese version of the love calculator&lt;/a&gt;. A request for an &lt;code&gt;/en/&lt;/code&gt; URL redirects to the unprefixed one, so each page has exactly one English address.&lt;/p&gt;

&lt;p&gt;Turning &lt;code&gt;localeDetection&lt;/code&gt; off means nobody is redirected based on their browser's language or a cookie. The URL alone decides the language. That keeps behaviour predictable: a shared link opens in the language the sender saw, and crawlers get the same content as people.&lt;/p&gt;

&lt;p&gt;The middleware's matcher skips API routes, Next.js internals and any path containing a dot, which is what lets files such as &lt;code&gt;robots.txt&lt;/code&gt; and &lt;code&gt;ads.txt&lt;/code&gt; be served as they are rather than treated as locale paths. Below it, the &lt;code&gt;[locale]&lt;/code&gt; layout pre-renders one version per language with &lt;code&gt;generateStaticParams&lt;/code&gt;, calls &lt;code&gt;notFound()&lt;/code&gt; for anything not in the list and sets &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt; to the current locale.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A page that changes daily, without rendering on every request
&lt;/h2&gt;

&lt;p&gt;The home page and the calculator page show a Daily Love Insight (a tip, a fact and a quote) that rotates each day in every language. Rendering per request would be wasteful; a fully static build would freeze it. Incremental Static Regeneration sits in between:&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;// Regenerate at most hourly so the date-based Daily Love Insight rotates each&lt;/span&gt;
&lt;span class="c1"&gt;// day while pages stay statically cached (fast) the rest of the time.&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;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The insight is chosen from the number of whole UTC days since the Unix epoch. Using UTC rather than local time keeps the server render and the browser in agreement. That number seeds a deterministic shuffle, so every render on the same day picks the same items and nothing repeats until the whole pool has been shown. The pick happens on the server and is passed down as props, so crawlers read the same HTML that the browser hydrates. The sitemaps use the same hourly &lt;code&gt;revalidate&lt;/code&gt;, so their dates move forward with the content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use names in at least three scripts from day one,&lt;/strong&gt; including one with combining marks (Hindi is a good choice) and one from a script with no upper and lower case (Japanese or Chinese).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type the same accented name two ways,&lt;/strong&gt; precomposed and with a combining accent, and check that the scores match. That is NFKC doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap the order of the names.&lt;/strong&gt; With a symmetric scorer the result must not change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the pure functions in Node too,&lt;/strong&gt; and compare with what the browser shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Decide what "the same input" means before you score anything. The normalisation is the real logic; the maths on top is a detail.&lt;/p&gt;

&lt;p&gt;To see it working, try the &lt;a href="https://www.lovecalcs.com/love-calculator" rel="noopener noreferrer"&gt;free love calculator that works with any script&lt;/a&gt;. It needs no sign-up, and the same two names will give the same score every time.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>i18n</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
