<?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: nook</title>
    <description>The latest articles on DEV Community by nook (@nookllr).</description>
    <link>https://dev.to/nookllr</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%2F4134401%2F62a63291-f560-4520-b21e-1028fc860c06.jpg</url>
      <title>DEV Community: nook</title>
      <link>https://dev.to/nookllr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nookllr"/>
    <language>en</language>
    <item>
      <title>Designing an offline notes app for sync that doesn't exist yet (and a landing page with zero build tools)</title>
      <dc:creator>nook</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:24:48 +0000</pubDate>
      <link>https://dev.to/nookllr/designing-an-offline-notes-app-for-sync-that-doesnt-exist-yet-and-a-landing-page-with-zero-build-28md</link>
      <guid>https://dev.to/nookllr/designing-an-offline-notes-app-for-sync-that-doesnt-exist-yet-and-a-landing-page-with-zero-build-28md</guid>
      <description>&lt;p&gt;I've spent the last several months building &lt;strong&gt;Nookly&lt;/strong&gt;, a local-first notes app (pages, block editor, tables/kanban, full-text search) in Flutter, backed by Drift/SQLite. No account, no cloud — everything lives on the user's device.&lt;/p&gt;

&lt;p&gt;This post isn't really about the app itself. It's about two engineering decisions that might be useful outside this specific project: how to design a schema for sync you haven't built yet, and how to ship a landing page with literally zero build tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every table gets &lt;code&gt;id&lt;/code&gt; (uuid), &lt;code&gt;updatedAt&lt;/code&gt;, &lt;code&gt;isDeleted&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt; from day one — sync becomes a data-layer swap later, not a rewrite.&lt;/li&gt;
&lt;li&gt;Reordering (drag-and-drop) uses fractional indices instead of reindexing siblings on every move.&lt;/li&gt;
&lt;li&gt;Search is SQLite FTS5 kept in sync via SQL triggers, not application code.&lt;/li&gt;
&lt;li&gt;The landing page is a single &lt;code&gt;index.html&lt;/code&gt; — React + Tailwind loaded straight from CDNs, JSX compiled in-browser. No npm, no bundler.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Schema designed for sync that isn't built yet
&lt;/h2&gt;

&lt;p&gt;There's no multi-device sync in Nookly right now — it's on the roadmap. But retrofitting it later usually means touching every table and half the data layer. So from the start, every table carries these through a shared mixin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;mixin&lt;/span&gt; &lt;span class="nc"&gt;SyncColumns&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientDefault&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;())();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientDefault&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientDefault&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())();&lt;/span&gt;
  &lt;span class="n"&gt;BoolColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;isDeleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&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="n"&gt;IntColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UUID instead of autoincrement&lt;/strong&gt; — no collisions once you have multiple devices generating records independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isDeleted&lt;/code&gt; instead of a real &lt;code&gt;DELETE&lt;/code&gt;&lt;/strong&gt; — you need the fact of a deletion to propagate to other devices; a hard delete just erases that information.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;version&lt;/code&gt;&lt;/strong&gt;, bumped on every write — not used for anything yet, but it's the natural hook for conflict resolution later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repository interfaces live in the domain layer and know nothing about Drift specifically, so in theory the data layer is swappable for a syncing backend without touching UI or business logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reordering without touching every sibling row
&lt;/h2&gt;

&lt;p&gt;Drag-and-drop reordering for pages and blocks is a frequent operation. The naive approach — integer &lt;code&gt;order&lt;/code&gt; column, incrementing/decrementing every sibling on each move — turns every drag into a burst of UPDATE statements across half the table.&lt;/p&gt;

&lt;p&gt;Fractional indices avoid that. Inserting between two neighbors is just their average:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop something between positions &lt;code&gt;1.0&lt;/code&gt; and &lt;code&gt;2.0&lt;/code&gt; → it gets &lt;code&gt;1.5&lt;/code&gt;. Drop again between &lt;code&gt;1.0&lt;/code&gt; and &lt;code&gt;1.5&lt;/code&gt; → &lt;code&gt;1.25&lt;/code&gt;. Neighboring rows are never touched. The one caveat: floating-point numbers aren't infinitely divisible, so heavy repeated reordering in the same spot can eventually erode precision — a production system doing this at scale should plan for periodic rebalancing of the whole collection's indices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search that maintains itself
&lt;/h2&gt;

&lt;p&gt;Full-text search runs on SQLite's FTS5 virtual table. The usual pain with FTS indices is drift from the source data — you update a row, forget to update the index, and search quietly goes stale. The fix is to not rely on application code remembering to do it at all — SQL triggers handle it at the database level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;pages_ai&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;search_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rowid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;pages_au&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;search_index&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rowid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index updates atomically with the data change, at the database layer — no Dart code that could forget to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The landing page: one HTML file, no build step
&lt;/h2&gt;

&lt;p&gt;Separate little side-quest: the app needed a landing page. Unlike the app itself, this really didn't need a build pipeline — just something that opens in a browser and deploys anywhere by copying one file.&lt;/p&gt;

&lt;p&gt;React, ReactDOM and Babel Standalone loaded directly from CDN:&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;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.tailwindcss.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text/babel"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-3xl font-bold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Hello&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;; &lt;/span&gt;&lt;span class="err"&gt;}
&lt;/span&gt;  &lt;span class="nx"&gt;ReactDOM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRoot&lt;/span&gt;&lt;span class="p"&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;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;App&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JSX gets transpiled in-browser via Babel Standalone. Tailwind's Play CDN script watches the DOM through a &lt;code&gt;MutationObserver&lt;/code&gt; and generates classes on the fly, so it works fine even with React re-rendering the tree dynamically.&lt;/p&gt;

&lt;p&gt;The obvious downside: runtime Babel transpilation isn't free, and this approach would be a bad idea for anything bigger than a single page — it'll show on low-end devices. But for a one-pager it's a reasonable trade: zero config, and deployment is literally copying one file.&lt;/p&gt;

&lt;p&gt;The interactive bits on the page aren't decorative — they're tied to real features. The theme toggle swaps between two actual screenshots of the same page in light/dark mode (matching the app's real switch behavior), and the search widget is a working filter over a small dataset with match highlighting, mirroring the app's actual Ctrl+K search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;None of this is individually novel — UUID + soft-delete + version columns for future sync, fractional indices for drag-and-drop, and trigger-maintained FTS indices all show up repeatedly in local-first app writeups. But since they keep coming up, it seemed worth writing down in one place.&lt;/p&gt;

&lt;p&gt;The app itself: &lt;a href="https://nookly.ru/" rel="noopener noreferrer"&gt;Nookly&lt;/a&gt;. Source repo (showcase, releases): &lt;a href="https://github.com/nooklyru/nookly-release" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>sqlite</category>
      <category>react</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
