<?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: Alexander Lukashov</title>
    <description>The latest articles on DEV Community by Alexander Lukashov (@alexander_lukashov).</description>
    <link>https://dev.to/alexander_lukashov</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%2F3831763%2F6d3c59cf-277d-4a77-ae95-a56a85f0f50b.png</url>
      <title>DEV Community: Alexander Lukashov</title>
      <link>https://dev.to/alexander_lukashov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexander_lukashov"/>
    <language>en</language>
    <item>
      <title>Hybrid search for your app in an afternoon, for $0</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Tue, 28 Jul 2026 18:00:44 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/hybrid-search-for-your-app-in-an-afternoon-for-0-5cjg</link>
      <guid>https://dev.to/alexander_lukashov/hybrid-search-for-your-app-in-an-afternoon-for-0-5cjg</guid>
      <description>&lt;p&gt;Somebody's card gets declined. They open your help center and type "my money disappeared". Your article is called "Troubleshooting failed payments". Not one word in common, so search returns nothing and they write to support instead.&lt;/p&gt;

&lt;p&gt;Keyword search only finds what you already named correctly. Semantic search fixes that, and then misses the other way: ask it for the error code &lt;code&gt;NB-2001&lt;/code&gt; and it hands you something thematically close instead of the page with that string in it. Hybrid runs both and merges them, so exact matches take the top and meaning fills in below.&lt;/p&gt;

&lt;p&gt;The usual price for that is a vector database, an embedding pipeline and a sync job between your content and your index. Here is the version with none of those. It took an afternoon and cost nothing.&lt;/p&gt;

&lt;p&gt;Quick disclosure: I built FoxNose, and Loquix is mine too. Everything below runs on a free account, and there is a short list at the end of cases where you should use something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two pieces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://foxnose.net" rel="noopener noreferrer"&gt;FoxNose&lt;/a&gt;&lt;/strong&gt; stores the content and does the search. Mark a field &lt;code&gt;searchable&lt;/code&gt; and &lt;code&gt;vectorizable&lt;/code&gt; and every record you publish is indexed for keywords and vectors. Hybrid search is on the free tier, and for a small product the free tier is enough to just run this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://loquix.dev" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt;&lt;/strong&gt; is MIT web components, including a &lt;code&gt;&amp;lt;loquix-search-dialog&amp;gt;&lt;/code&gt; you can drop in. Plain custom elements, so React, Vue or nothing at all.&lt;/p&gt;

&lt;p&gt;The browser calls the search API directly. No backend of yours in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Describe the content
&lt;/h2&gt;

&lt;p&gt;Sign up and create a project. FoxNose provisions one environment for it, &lt;code&gt;Production&lt;/code&gt;, and you can add more later for staging or previews. An environment is a separate database with its own API host at &lt;code&gt;https://{environment_key}.fxns.io&lt;/code&gt;. Note that key down, you need it in a minute.&lt;/p&gt;

&lt;p&gt;Inside the environment, create a collection. Mine is called &lt;code&gt;articles&lt;/code&gt;. Then give it a schema in the visual editor: fields, types, flags, publish. Schema versions are drafts until you publish them, so there is no way to half-apply a change.&lt;/p&gt;

&lt;p&gt;I used a help center for a fictional product, 24 articles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Flags&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;searchable, vectorizable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;slug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;searchable, vectorizable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;searchable, vectorizable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;searchable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the whole search setup. &lt;code&gt;searchable&lt;/code&gt; indexes the field for querying, &lt;code&gt;vectorizable&lt;/code&gt; means embeddings get generated on every publish. No embedding code, nothing to keep in sync, no index to create.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you load anything. Full-text search lives on &lt;code&gt;text&lt;/code&gt; fields, not &lt;code&gt;string&lt;/code&gt;, so anything people will actually search inside wants to be &lt;code&gt;text&lt;/code&gt;. And a field's type cannot be edited later, only recreated: you remove the field and add a new one in its place, then load the content again. Cheap now, annoying after you have data.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Load the content
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @foxnose/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three values go into your &lt;code&gt;.env&lt;/code&gt; here: the environment key from a minute ago, the collection key, and a management API key you create in the environment settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// seed.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&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;node:fs&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;ManagementClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SimpleKeyAuth&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;@foxnose/sdk&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;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&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;./articles.json&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;utf8&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&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;ManagementClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;environmentKey&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_ENV_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;auth&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;SimpleKeyAuth&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_MGMT_PUBLIC_KEY&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_MGMT_SECRET_KEY&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batchUpsertResources&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXNOSE_COLLECTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;articles&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;article&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="na"&gt;external_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&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="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node --env-file=.env seed.js&lt;/code&gt; on Node 20.6 or newer, so you do not need dotenv for this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;external_id&lt;/code&gt; makes it an upsert, so you can re-run this as often as you like. Each write validates the record, generates the embeddings, indexes it and publishes it.&lt;/p&gt;

&lt;p&gt;You can also just type content into the visual editor, which is the point for a real product: support people edit help articles without a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Put the search online
&lt;/h2&gt;

&lt;p&gt;Content is served through what FoxNose calls a Flux API. Create one, give it a prefix, connect the collection. Three settings matter for calling it from a browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connect the collection with both &lt;code&gt;get_many&lt;/code&gt; and &lt;code&gt;get_one&lt;/code&gt;, or results come back without content&lt;/li&gt;
&lt;li&gt;make the API public, meaning anonymous reads (writes still need a key)&lt;/li&gt;
&lt;li&gt;turn on &lt;strong&gt;Allow all origins&lt;/strong&gt; so the API accepts calls from a browser at all (fine here, these articles are public anyway)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now you have an endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://{environment_key}.fxns.io/help-center/articles/_search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key in your frontend, no proxy to hide that key.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Search
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// flux.js&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://YOUR_ENV_KEY.fxns.io/help-center&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hybridSearch&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/articles/_search`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;search_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;hybrid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;find_text&lt;/span&gt;&lt;span class="p"&gt;:&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;fields&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;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;summary&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;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;vector_search&lt;/span&gt;&lt;span class="p"&gt;:&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;fields&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;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;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;similarity_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;hybrid_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;vector_weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text_weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;rerank_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&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;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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&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="s2"&gt;`Search failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;results&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;One request, two ranked lists, one answer. &lt;code&gt;NB-2001&lt;/code&gt; returns the error codes page. "my money disappeared" returns the payments article. &lt;code&gt;search_mode&lt;/code&gt; also takes &lt;code&gt;text&lt;/code&gt; or &lt;code&gt;vector&lt;/code&gt; if you ever want one side on its own.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;threshold: 0.85&lt;/code&gt; is your typo tolerance: at &lt;code&gt;1.0&lt;/code&gt; matching is exact, lower is fuzzier, so "pyament declined" still lands.&lt;/p&gt;

&lt;p&gt;Then there are two numbers that decide how good this feels, and neither has a universally right value:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;similarity_threshold&lt;/code&gt; is the floor for "close enough" on the semantic side. Every document is a little bit similar to every query, so this is where you cut. Too low and a question about baking bread returns your billing articles. Too high and paraphrases stop working. 0.6 fits these 24 articles.&lt;/p&gt;

&lt;p&gt;The weights settle words against meaning. Text-heavy works for a help center, where people search for strings that literally exist: error codes, plan names, button labels. For a product catalog, where nobody knows your wording, push the vector side up.&lt;/p&gt;

&lt;p&gt;There is no magic here, which I think is the good news. It is arithmetic over two ranked lists. Load your own content, throw your real queries at it, move the two numbers, look at what comes back. Twenty minutes of that beats any default.&lt;/p&gt;

&lt;p&gt;Filters live next to all this: &lt;code&gt;where&lt;/code&gt; takes typed operators over any searchable field, combined with &lt;code&gt;all_of&lt;/code&gt; and &lt;code&gt;any_of&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The dialog
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @loquix/core lit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;loquix-search-dialog&lt;/span&gt;
  &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;
  &lt;span class="na"&gt;heading=&lt;/span&gt;&lt;span class="s"&gt;"Search help center"&lt;/span&gt;
  &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Search help articles..."&lt;/span&gt;
  &lt;span class="na"&gt;kbd=&lt;/span&gt;&lt;span class="s"&gt;"⌘K"&lt;/span&gt;
  &lt;span class="na"&gt;mode=&lt;/span&gt;&lt;span class="s"&gt;"plain"&lt;/span&gt;
  &lt;span class="na"&gt;hide-answer&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/loquix-search-dialog&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mode="plain"&lt;/code&gt; keeps it a search box and &lt;code&gt;hide-answer&lt;/code&gt; drops the LLM answer block, which is a different article. You get a real modal &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; with a focus trap, keyboard navigation, loading states and a chip row, none of which you write.&lt;/p&gt;

&lt;p&gt;It knows nothing about any backend. It fires events and takes plain arrays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@loquix/core/tokens/variables.css&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@loquix/core/define/define-search-dialog&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;hybridSearch&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;./flux.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;dialog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&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&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;CATEGORIES&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getting-started&lt;/span&gt;&lt;span class="dl"&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;Getting started&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&lt;/span&gt;&lt;span class="dl"&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;Billing&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account&lt;/span&gt;&lt;span class="dl"&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;Account&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;id&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&lt;/span&gt;&lt;span class="dl"&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;API&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&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;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&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;render&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;shown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hits&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;hit&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;hit&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;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shown&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;hit&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_sys&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&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;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/help/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hit&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;slug&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;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CATEGORIES&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;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;hit&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;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CATEGORIES&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;c&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hits&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;h&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;h&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;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;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;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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;runSearch&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="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;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="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&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="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;searching&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hybridSearch&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="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a new query starts unfiltered&lt;/span&gt;
    &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notice&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Service is too busy. Try again.&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&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;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-change&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="nx"&gt;e&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="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&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="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Enter fires submit, Cmd+Enter fires ask even in plain mode, so wire both&lt;/span&gt;
&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-search-submit&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="nx"&gt;e&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;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&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="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-search-ask&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="nx"&gt;e&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;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&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="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loquix-search-source-select&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="nx"&gt;e&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;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&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="nx"&gt;e&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metaKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctrlKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&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;k&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&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;Three notes on that, because they are easy to miss:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.results&lt;/code&gt; and &lt;code&gt;.sources&lt;/code&gt; are JavaScript properties, not attributes. Setting them in HTML does nothing.&lt;/p&gt;

&lt;p&gt;Debounce is yours to add. Every keystroke would otherwise be its own request, and one request counts the same whether it returns one result or twenty five.&lt;/p&gt;

&lt;p&gt;The chips filter what you already fetched, so their counts and their clicks describe the same set of results. No extra request, instant. The same strip is built for a bigger job: give each kind of content its own collection, fire one query per collection in parallel, and each chip becomes a source with its own count as the answers arrive.&lt;/p&gt;

&lt;p&gt;React users: &lt;code&gt;@loquix/react&lt;/code&gt; wraps every component, events arrive as props like &lt;code&gt;onSearchSubmit&lt;/code&gt;. Both packages ship types.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is, and where it stops
&lt;/h2&gt;

&lt;p&gt;Worth being straight about the shape of it: FoxNose is not a search engine. It is a typed content database built for LLM agents and RAG, with hybrid search included, which is why one API holds your records and retrieves them and there is nothing to sync in between.&lt;/p&gt;

&lt;p&gt;A product that does search and only search goes further in specific places: merchandising rules, search analytics, highlighted snippets, facet counts across a whole corpus, US data residency, edge latency at very large scale. All real features, and if your product lives on one of them, use Algolia. For most teams shipping a help center or in-app search, none of them come up in a normal month.&lt;/p&gt;

&lt;p&gt;If you would rather run infrastructure, Typesense and Meilisearch are excellent and free at any volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take it
&lt;/h2&gt;

&lt;p&gt;The whole thing is in one repo: &lt;a href="https://github.com/loookashow/hybrid-search-help-center" rel="noopener noreferrer"&gt;https://github.com/loookashow/hybrid-search-help-center&lt;/a&gt;. Copy &lt;code&gt;.env.example&lt;/code&gt;, add your keys, then &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;npm run seed&lt;/code&gt;, &lt;code&gt;npm run dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two flags on a schema, one fetch, one component, two numbers to tune. Next time I will put a language model on top of the same API so the box can answer in sentences and cite the articles it used.&lt;/p&gt;

&lt;p&gt;Go type "my money disappeared" into your own product's search. Curious what comes back.&lt;/p&gt;

</description>
      <category>search</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why I made my paid product fully free for personal use</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Tue, 19 May 2026 16:25:33 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/why-i-made-my-paid-product-fully-free-for-personal-use-5cie</link>
      <guid>https://dev.to/alexander_lukashov/why-i-made-my-paid-product-fully-free-for-personal-use-5cie</guid>
      <description>&lt;p&gt;I spent two months thinking about pricing before I let myself write the pricing page.&lt;/p&gt;

&lt;p&gt;That is not a humblebrag, it slowed everything down. The product was technically ready in March. By April it could have been on sale. But every time I sat down to define the free tier, something did not sit right, and I would close the tab and go fix a bug instead.&lt;/p&gt;

&lt;p&gt;What unstuck it was not a framework, a benchmark, or a YC essay. It was one question I could not answer to my own satisfaction. I want to write about it, because the question is generalizable and I think a lot of solo makers building tools quietly trip over it.&lt;/p&gt;

&lt;p&gt;A bit of context first. I have been a technical product manager at large tech companies for many years, always on B2B SaaS, always with a team, always for someone else's company. The product I was pricing is Notch, a screen recorder for Mac and Windows. It is the first desktop app I have shipped under my own name. So this was the first time I was personally on the line for a pricing decision, with my own money funding the development and my own name on the receiver.&lt;/p&gt;

&lt;h2&gt;
  
  
  The freemium playbook I almost ran
&lt;/h2&gt;

&lt;p&gt;When I sketched the pricing card the first time, it looked almost exactly like what every other screen recorder does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free tier with a 5-project cap, limited by 1080p output quality, and a watermark on exports&lt;/li&gt;
&lt;li&gt;Paid tier at $49 one-time that removes both&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the freemium playbook by default. The free tier is a trial in disguise. Generous enough to demo the product, restrictive enough that anyone using it seriously has to upgrade. Loom does it. Screen Studio does it. Camtasia does it. Tella does it. So did the spreadsheet I was modelling.&lt;/p&gt;

&lt;p&gt;The numbers added up. The conversion funnel made sense. I built the watermark into the export pipeline. I implemented the project counter and a "you've hit your limit" modal. I tested both. They worked.&lt;/p&gt;

&lt;p&gt;I just did not want to ship them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I could not answer
&lt;/h2&gt;

&lt;p&gt;The discomfort was not moral. I had convinced myself the freemium model is honest enough, since the limits are visible upfront. The discomfort was about a specific user I kept picturing.&lt;/p&gt;

&lt;p&gt;That user is someone like me five or ten years ago: an engineer recording their first screencast tutorial for their blog, a student doing a class project, a hobbyist showing their indie game on Mastodon. Someone who is not going to make a dollar from the thing they are recording. Someone for whom $49, even one-time, is meaningful money.&lt;/p&gt;

&lt;p&gt;Why am I asking them to pay?&lt;/p&gt;

&lt;p&gt;I sat with that question for two weeks. Every answer felt thin.&lt;/p&gt;

&lt;p&gt;"Because the software costs me money to maintain."&lt;/p&gt;

&lt;p&gt;OK, true, but that user is not loading my support queue. They are not going to email me. They are using the product silently.&lt;/p&gt;

&lt;p&gt;"Because if I let them export for free, they will never upgrade."&lt;/p&gt;

&lt;p&gt;They were never going to upgrade. They do not have a use case for the Commercial license, they are not running paid work through it. The "upgrade" framing is a trick I would be playing on someone who could not fall for it.&lt;/p&gt;

&lt;p&gt;"Because it is the standard model in the category."&lt;/p&gt;

&lt;p&gt;The least convincing answer of all. "Everyone does it" has never been a strong reason to do anything.&lt;/p&gt;

&lt;p&gt;The honest version of the question kept reformulating itself in my head:&lt;/p&gt;

&lt;p&gt;Why should someone pay me if they are not making money from this themselves?&lt;/p&gt;

&lt;p&gt;I did not have a good answer. So I stopped trying to find one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;I removed the project cap. I removed the watermark. I removed all limits. The personal tier of Notch is fully free. No degraded export, no nag screens, no asterisks. You install the app, you hit record, you export your video. Done.&lt;/p&gt;

&lt;p&gt;The Commercial license is still there. It is $49 one-time, with team pricing for 3 and 10 seats. The split is not "free for limited use, paid for everything". It is "free for personal use, paid for revenue-generating use". The line is the purpose of the recording, not the feature set.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A student making a class project: free, forever, with full features.&lt;/li&gt;
&lt;li&gt;A bootcamp graduate recording tutorials for their portfolio: free.&lt;/li&gt;
&lt;li&gt;An OSS maintainer recording a feature demo for a GitHub issue: free.&lt;/li&gt;
&lt;li&gt;A teacher recording an async lesson for their students: free.&lt;/li&gt;
&lt;li&gt;A consultant making client deliverables: Commercial license.&lt;/li&gt;
&lt;li&gt;A SaaS team recording onboarding videos: Commercial license.&lt;/li&gt;
&lt;li&gt;A YouTuber monetizing their channel: Commercial license.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free tier and the Commercial tier are the same software. The license is what changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The objection I expected
&lt;/h2&gt;

&lt;p&gt;I had a draft of this post that opened with the predictable objection: "But how will you actually make money?"&lt;/p&gt;

&lt;p&gt;I cut it because I think it deserves a real answer, not a defensive setup. Here is the real answer.&lt;/p&gt;

&lt;p&gt;Most non-monetizing users were never going to pay anyway. Walking into a freemium model assuming they are conversion-able is wishful. The honest math: if 95% of free-tier users would never pay regardless of how much you crippled the free tier, your "free tier" was effectively gifting them a worse product than they could have had, and resenting them for not converting.&lt;/p&gt;

&lt;p&gt;Commercial users are different. They have a budget line for tools. They are comparing me to Loom at $18/user/month or Screen Studio at $108/year. A one-time $49, or $29/user for a 10-pack, is the cheapest thing in their procurement queue. They are going to pay.&lt;/p&gt;

&lt;p&gt;Goodwill compounds. The student who got Notch for free for a year, who graduates and goes to a SaaS company that needs internal training videos, that is the person who pitches Notch to their boss. Not because of switching costs, but because they already know it works and trust it. That kind of advocacy does not show up in a conversion funnel, is almost impossible to A/B test, and stacks over years.&lt;/p&gt;

&lt;p&gt;The "real free tier" is a marketing position. It is also a moat. Every competitor who runs the freemium-with-limits playbook is implicitly telling their non-monetizing users "we tolerate you, for now". That is not a feeling people seek out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper reason
&lt;/h2&gt;

&lt;p&gt;I will be honest about the part that is not a pricing argument.&lt;/p&gt;

&lt;p&gt;I went into product management years ago because I love watching the results of my work get used. Not "purchased". Used. The thing I find satisfying about shipping is people getting value. If I could only sell products and never see them in someone's hands, I would have stayed in e-commerce.&lt;/p&gt;

&lt;p&gt;Building "another commercial product", one more entry in a category, optimized for revenue capture, was not a strong enough reason for me to spend a year of my evenings on something. It is a thing I could have done with my day job's time instead. The reason I built Notch is that I kept wanting it to exist and nothing else delivered it. The reason I am shipping it is that I want other people to have it too.&lt;/p&gt;

&lt;p&gt;A pricing model that restricts the people most likely to enjoy the product, in order to maximize revenue from the people also most likely to enjoy the product, is solving for the wrong thing.&lt;/p&gt;

&lt;p&gt;The team and Commercial side will pay for itself. I would rather underearn from the model I chose than overcharge through one that fights my own reason for building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell another maker
&lt;/h2&gt;

&lt;p&gt;Three things, briefly.&lt;/p&gt;

&lt;p&gt;Pricing reveals what you actually think your product is for. If the only way the math works is by limiting the people who would love your product most, the math might be wrong.&lt;/p&gt;

&lt;p&gt;The freemium playbook is the default, not the answer. It works for some categories, usually ones with high marginal cost per user, strong network effects, or genuine paid value the free tier cannot replicate. Most maker tools are none of those.&lt;/p&gt;

&lt;p&gt;Trust your discomfort. I spent two months avoiding writing a pricing page because the model I was about to ship felt wrong. That feeling was data. I should have listened to it sooner.&lt;/p&gt;

&lt;p&gt;If you are curious what the product looks like with this pricing applied: &lt;a href="https://getnotch.co" rel="noopener noreferrer"&gt;Notch is on Mac and Windows&lt;/a&gt;, fully free for personal use, no account, no cloud, no nags. Commercial license is one-time, $49.&lt;/p&gt;

&lt;p&gt;I am happy to argue any of the above in the comments. I think I am right, but I have been wrong about pricing for a living. &lt;/p&gt;

&lt;p&gt;May the force be with you.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>showdev</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Evaluated Every AI Chat UI Library in 2026. Here's What I Found (and What I Built)</title>
      <dc:creator>Alexander Lukashov</dc:creator>
      <pubDate>Wed, 18 Mar 2026 18:00:24 +0000</pubDate>
      <link>https://dev.to/alexander_lukashov/i-evaluated-every-ai-chat-ui-library-in-2026-heres-what-i-found-and-what-i-built-4p10</link>
      <guid>https://dev.to/alexander_lukashov/i-evaluated-every-ai-chat-ui-library-in-2026-heres-what-i-found-and-what-i-built-4p10</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React stack → &lt;a href="https://github.com/assistant-ui/assistant-ui" rel="noopener noreferrer"&gt;assistant-ui&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full agent framework → &lt;a href="https://github.com/CopilotKit/CopilotKit" rel="noopener noreferrer"&gt;CopilotKit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Quick prototype or non-React → &lt;a href="https://github.com/OvidijusParsiunas/deep-chat" rel="noopener noreferrer"&gt;Deep Chat&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Python backend → &lt;a href="https://github.com/Chainlit/chainlit" rel="noopener noreferrer"&gt;Chainlit&lt;/a&gt; (check project status first)&lt;/li&gt;
&lt;li&gt;Multi-framework + composable, early adopter → &lt;a href="https://github.com/loquix-dev/loquix" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt; (no production deployments yet — read the limitations)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Conflict of interest:&lt;/strong&gt; I'm the author of Loquix, one of the libraries reviewed here. The independent reviews come first. Loquix gets its own clearly-labeled section at the end, written by me about my own work — treat it accordingly. If you want the quick take: for most teams, &lt;strong&gt;assistant-ui&lt;/strong&gt; (React) or &lt;strong&gt;Deep Chat&lt;/strong&gt; (everything else) is the more pragmatic answer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Before the reviews: four types of lock-in
&lt;/h2&gt;

&lt;p&gt;After evaluating all of these libraries, the biggest insight wasn't about any specific tool — it was that "lock-in" isn't one thing. There are at least four distinct flavors, and understanding which one you're accepting changes how much it matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;When it bites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;assistant-ui (React-only)&lt;/td&gt;
&lt;td&gt;When you need to support other frameworks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture/runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CopilotKit&lt;/td&gt;
&lt;td&gt;When your agent infrastructure evolves independently of your frontend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ecosystem&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vercel stack&lt;/td&gt;
&lt;td&gt;Creeps up over time; each layer pulls you deeper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API-surface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deep Chat&lt;/td&gt;
&lt;td&gt;When your requirements outgrow the component's config options&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Framework lock-in is rarely a problem if your whole team is on React. Architecture lock-in hurts when backend and frontend evolve at different speeds. Ecosystem lock-in you feel slowly — you start with &lt;code&gt;useChat&lt;/code&gt;, six months later you're on Vercel hosting, each step made sense. API-surface lock-in only matters when your requirements outgrow the defaults.&lt;/p&gt;

&lt;p&gt;I'll reference these types throughout the reviews so you can calibrate the tradeoffs for your situation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "chat UI" actually requires
&lt;/h2&gt;

&lt;p&gt;"Messages going up, input at the bottom" — I thought the same thing. Then I started building. Here's the real scope:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core:&lt;/strong&gt; Token-by-token streaming without UI glitches, markdown + code blocks with syntax highlighting, a composer that auto-grows and handles Shift+Enter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interactive layer:&lt;/strong&gt; File attachments (drag-and-drop + clipboard paste), feedback buttons for RLHF, a stop-generating button (you'll add this at 2 AM after your agent writes a 4,000-word essay about semicolons), model selectors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust layer:&lt;/strong&gt; Citations, reasoning traces, tool execution logs, cost estimates. Users increasingly expect to see &lt;em&gt;why&lt;/em&gt; the AI said what it said.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The invisible stuff:&lt;/strong&gt; Accessibility (WCAG, keyboard nav), theming that survives a designer, i18n.&lt;/p&gt;

&lt;p&gt;Build all of that from scratch: 2–4 weeks, before a single line of agent logic. That's why this market exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The libraries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  assistant-ui ⭐ ~7.9k — React, composable, headless — MIT
&lt;/h3&gt;

&lt;p&gt;YC-backed. The library that shows up in every "build a ChatGPT clone" thread.&lt;/p&gt;

&lt;p&gt;Follows the Radix headless pattern — unstyled primitives (&lt;code&gt;Thread&lt;/code&gt;, &lt;code&gt;Composer&lt;/code&gt;, &lt;code&gt;Message&lt;/code&gt;) you compose yourself. State management is thoughtful, streaming is first-class, Vercel AI SDK integration is tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; React + Next.js teams that want maximum control without implementing the hard parts. If your whole stack is React, this is the safe default — mature ecosystem, responsive maintainers, well-designed composability model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; React-only (framework lock-in). Vue, Svelte, Angular, vanilla JS — not supported. "Headless" also means you do all the design work; getting a polished UI requires assembling many pieces and the learning curve is real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Framework.&lt;/p&gt;




&lt;h3&gt;
  
  
  CopilotKit ⭐ ~28.6k — React, agent framework with UI — commercial tiers available
&lt;/h3&gt;

&lt;p&gt;Not just a UI library — an agentic application framework that includes UI components. Drop in &lt;code&gt;&amp;lt;CopilotPortal /&amp;gt;&lt;/code&gt;, get a full copilot experience. Agents can read your app state, call your functions, generate UI dynamically. They co-created AG-UI and partnered with Google on A2UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Greenfield projects where you're buying into their whole architecture. If you need deep agent-app state sync and generative UI, nothing else comes close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; If all you need is a good chat interface, this is a flamethrower for a candle. More importantly: it's not just framework lock-in, it's &lt;em&gt;architecture&lt;/em&gt; lock-in. You're adopting their agent execution model, state sync, action system — not just swapping in components. On brownfield projects with existing agent infrastructure, that means rethinking things you've already built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Architecture/runtime (the heaviest kind).&lt;/p&gt;




&lt;h3&gt;
  
  
  Vercel AI SDK + AI Elements — React, hooks + growing component layer — MIT
&lt;/h3&gt;

&lt;p&gt;The AI SDK is everywhere: model-provider abstraction with &lt;code&gt;useChat&lt;/code&gt; / &lt;code&gt;useCompletion&lt;/code&gt; hooks. Not a UI library at its core, but Vercel has been expanding into actual components with &lt;strong&gt;AI Elements&lt;/strong&gt; — composable React components on top of the SDK.&lt;/p&gt;

&lt;p&gt;Worth mentioning separately: the &lt;strong&gt;Vercel AI Chatbot&lt;/strong&gt; is the official open-source reference implementation built on this stack. Many teams use it as a starting point or benchmark for what a production chat UI looks like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Anyone already in the Vercel ecosystem. AI Elements is worth evaluating alongside assistant-ui if you're on Next.js.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; The Vercel stack has gravitational pull. You start with &lt;code&gt;useChat&lt;/code&gt;. Six months later you're on Vercel hosting. Each step made sense individually — the cumulative effect is ecosystem lock-in that's softer than CopilotKit's but real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Ecosystem.&lt;/p&gt;




&lt;h3&gt;
  
  
  TanStack AI — alpha, framework-agnostic hooks — MIT
&lt;/h3&gt;

&lt;p&gt;TanStack — the team behind React Query, React Router, React Table — launched their AI toolkit. Framework-agnostic core, adapters for React, Solid, vanilla JS. Headless, type-safe.&lt;/p&gt;

&lt;p&gt;Important context: this is alpha from a team with a strong track record of shipping production-grade, widely-adopted tools. That's different from "alpha from unknowns." If it matures the way React Query did, this will be a serious foundation layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Nobody in production yet. Watch this space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; No pre-built UI components — hooks and utilities, not a chat interface. Vue and Angular adapters aren't there. Not ready for production today.&lt;/p&gt;




&lt;h3&gt;
  
  
  Google A2UI — declarative format for agent-generated UI
&lt;/h3&gt;

&lt;p&gt;Agents send JSON describing which components to render; your frontend renders them from a trusted catalog. No executable code from the agent — no XSS nightmares. Framework-agnostic by design. Ships initial renderers for Lit, Angular, Flutter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Teams building agent-generated dynamic UIs. The CopilotKit integration is worth watching, and this is one of the more interesting directions for generative UI / RSC-style patterns outside the React ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; It's an infrastructure layer, not a component library. Not something you'd drop into a project and ship today.&lt;/p&gt;




&lt;h3&gt;
  
  
  Deep Chat ⭐ ~3.3k — Web Component, framework-agnostic — MIT
&lt;/h3&gt;

&lt;p&gt;The one that made me sit up. A single Web Component for AI chat — framework-agnostic, built-in connections to OpenAI, HuggingFace, Cohere, Azure, Stability AI, AssemblyAI out of the box. Ships with speech-to-text, text-to-speech, file uploads, image handling.&lt;/p&gt;

&lt;p&gt;Time-to-working-chat: under 10 minutes. No backend glue required to get started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; Prototypes, internal tools, non-React projects, or anywhere chat is a feature rather than the core experience. Best option when you need something working fast that doesn't care about your frontend framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt; One component with configuration options, not a composable system. When you need to restructure layout, inject custom UI between messages, or add domain-specific controls — you're working within what the property API can express. Same tradeoff as any "convention over composition" approach: faster start, less flexibility when requirements diverge from defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; API-surface.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chainlit ⭐ ~11.4k — Python-first, full-stack
&lt;/h3&gt;

&lt;p&gt;The go-to for Python developers who wanted a chat UI without touching JavaScript. Write your agent in Python, get a polished web UI with reasoning steps, file uploads, markdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; The original team stepped back from active development in May 2025. Community maintainers have picked it up. Worth verifying current project status before building on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing: "Chat UI" is becoming "Agent UI"
&lt;/h2&gt;

&lt;p&gt;Streaming is solved. Every library handles it well now — it's no longer a differentiator.&lt;/p&gt;

&lt;p&gt;What's not solved is composition for agent-specific UI patterns: approval flows, reasoning traces, tool execution displays, cost transparency. These are becoming table stakes, not features. Libraries that only solve the messaging problem will feel incomplete within a year.&lt;/p&gt;

&lt;p&gt;Generative UI (agents that emit UI, not just text) is the next frontier — watch A2UI + CopilotKit here, and the RSC patterns in the Vercel ecosystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Loquix — what I built, and why you probably shouldn't use it yet
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Repeat disclosure:&lt;/strong&gt; this is my library. Everything below is me describing my own work. The same "where it hurts" standard I applied above applies here too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;None of the options above fit my specific situation: a product where the customer's frontend framework isn't fixed, and I wanted composable building blocks rather than a monolithic widget or agent runtime.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/loquix-dev/loquix" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt; — a Web Components library on Lit, specifically for AI chat interfaces. (&lt;code&gt;npm install @loquix/core&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fopa06wpaz75079x6cxax.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fopa06wpaz75079x6cxax.gif" alt="Loquix demo" width="480" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The approach
&lt;/h3&gt;

&lt;p&gt;35 individual Web Components that compose like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;loquix-chat-container&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loquix-chat-header&lt;/span&gt; &lt;span class="na"&gt;slot=&lt;/span&gt;&lt;span class="s"&gt;"header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-model-selector&lt;/span&gt; &lt;span class="na"&gt;slot=&lt;/span&gt;&lt;span class="s"&gt;"actions"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/loquix-chat-header&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loquix-message-list&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-message-item&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"assistant"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;loquix-message-content&lt;/span&gt; &lt;span class="na"&gt;streaming&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;loquix-message-actions&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;loquix-action-copy&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;loquix-action-feedback&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/loquix-message-actions&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/loquix-message-item&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/loquix-message-list&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loquix-chat-composer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-prompt-input&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loquix-drop-zone&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/loquix-chat-composer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/loquix-chat-container&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verbose, yes. But when your designer says "move feedback above the message" — you move the component. No prop drilling, no checking if &lt;code&gt;feedbackPosition&lt;/code&gt; is a valid prop value.&lt;/p&gt;

&lt;p&gt;Backend integration is a type-only interface with no runtime dependency on any specific provider:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AgentProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;send&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;Message&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;SendOptions&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;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadableStream&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="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="o"&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;Works in React, Vue, Svelte, Angular, vanilla JS. &lt;code&gt;@loquix/react&lt;/code&gt; ships with proper wrappers for React's synthetic event system. Vue/Svelte dedicated packages are planned, not shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in type:&lt;/strong&gt; Platform (Web Components/Shadow DOM model, Lit if you extend components deeply) — just at the browser standards level rather than the library level.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it hurts — same standard as above
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;No production deployments.&lt;/strong&gt; I can't point you to a production app using Loquix. That's a significant unknown that none of the other libraries in this review share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small community.&lt;/strong&gt; No Stack Overflow answers. You'll be reading source code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incomplete roadmap.&lt;/strong&gt; Reasoning traces, citations, cost estimates — Phase 4 and 5 — still in development. If you need those today, build them yourself or wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lit learning curve.&lt;/strong&gt; Consuming is easy. Deep customization requires understanding Lit's reactive model and Shadow DOM. React developers find this counterintuitive at first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No built-in provider integrations.&lt;/strong&gt; Deep Chat connects to OpenAI in 10 minutes. assistant-ui integrates with Vercel AI SDK out of the box. Loquix gives you a TypeScript interface and says "implement it." Better for experienced teams; more friction for everyone else — the same critique I'd apply to any library that takes this approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concrete example where assistant-ui wins:&lt;/strong&gt; chat UI integrating with Vercel AI SDK's &lt;code&gt;useChat&lt;/code&gt;, rendering generative UI streamed from server, thread persistence, all in Next.js. With assistant-ui: well-documented, battle-tested. With Loquix: you'd be wiring streaming yourself, no RSC integration (Web Components and React Server Components don't mix naturally), and you'd be the first person to try this combination in production. Hard to justify.&lt;/p&gt;

&lt;p&gt;If your situation is specifically "multi-framework team that wants composable components and is willing to be an early adopter" — evaluate it. Otherwise, the options above are more pragmatic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Maturity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React + Next.js&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;assistant-ui&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full agent framework, generative UI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;CopilotKit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider abstraction + React&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Vercel AI SDK + AI Elements&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick prototype, non-React, embedded widget&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Deep Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Production-ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python-first backend&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Chainlit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Check project status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-framework + composable, early adopter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/loquix-dev/loquix" rel="noopener noreferrer"&gt;Loquix&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🚧 No production deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent-generated dynamic UI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;A2UI + CopilotKit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;👀 Watch this space&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Greenfield vs. brownfield:&lt;/strong&gt; heavier frameworks (CopilotKit, Vercel full stack) accelerate you on greenfield when you're choosing everything fresh. Thinner UI layers (assistant-ui, Deep Chat, Loquix) cause less friction on brownfield where the architecture already exists.&lt;/p&gt;




&lt;p&gt;The days of spending weeks building chat UI from scratch are over. Pick a library, commit, and spend your engineering time on the thing that actually differentiates your product.&lt;/p&gt;

&lt;p&gt;If I missed a library you've used in production, drop it in the comments.&lt;/p&gt;

&lt;p&gt;Unless you enjoy writing auto-scroll logic at 2 AM. In which case, I have a &lt;code&gt;useEffect&lt;/code&gt; cleanup function I'd like to sell you.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>webcomponents</category>
    </item>
  </channel>
</rss>
