<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss/styles.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>maxmntl</title><description>Maximilien Monteil&apos;s written thoughts and ideas.</description><link>https://www.maxmntl.com</link><language>en-us</language><item><title>Neovim: Save folds in markdown files</title><link>https://www.maxmntl.com/blog/neovim-save-markdown-folds</link><guid isPermaLink="true">https://www.maxmntl.com/blog/neovim-save-markdown-folds</guid><description>How to save folds in markdown files in neovim using Lua and autocommands.</description><pubDate>Fri, 06 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h1&gt;Neovim: Save folds in markdown files&lt;/h1&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;&amp;lt;h3&amp;gt;TL;DR&amp;lt;/h3&amp;gt;&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This is a short little TIL with Neovim&lt;/p&gt;
&lt;p&gt;I wanted to have the lines I collapse in markdown files stay collapsed after closing the file.&lt;/p&gt;
&lt;p&gt;Most of the solution was in this &lt;a href=&quot;https://vi.stackexchange.com/a/44114&quot;&gt;StackExchange answer&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I then make some tweaks for my personal use case:&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;-- ~/.config/nvim/ftplugin/markdown.lua
vim.opt_local.foldmethod = &apos;manual&apos;

local folds_augroup = vim.api.nvim_create_augroup(&apos;Folds&apos;, { clear = true })

vim.api.nvim_create_autocmd(
  {&apos;BufWritePost&apos;, &apos;QuitPre&apos;},
  { group = folds_augroup, command = &apos;mkview&apos; })

vim.api.nvim_create_autocmd(
  &apos;BufWinEnter&apos;,
  { group = folds_augroup, command = &apos;silent! loadview | normal! zM&apos; })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;p&gt;I&apos;m entering this new era of life where the stuff I like to use needs to be as open to tinkering as possible.&lt;/p&gt;
&lt;p&gt;I gush about having a blog so much because it brings into my life a little personal thing that I can tinker and play with exactly how &lt;em&gt;I&lt;/em&gt; want.&lt;/p&gt;
&lt;p&gt;With each point of contact, I can encounter a new situation that isn&apos;t quite how I want it, then pop open the hood and tune it to my exact specification.&lt;/p&gt;
&lt;p&gt;No payment or subscription, no new contract to sign, no personal information sent to some ad service.&lt;/p&gt;
&lt;p&gt;That&apos;s also why &lt;a href=&quot;https://neovim.io/&quot;&gt;Neovim&lt;/a&gt; is so cool.&lt;/p&gt;
&lt;p&gt;I&apos;ll go into Neovim in more detail at some other time, today I wanted to write about yet another moment where I deepened my understanding of my tool and made it more perfect for me.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;About Neovim&lt;/h2&gt;
&lt;p&gt;Neovim (and vim) is a modal editor.&lt;/p&gt;
&lt;p&gt;This means, unlike text editors like VSCode and Google docs, you can&apos;t just start typing away as soon as the page is open to you.&lt;/p&gt;
&lt;p&gt;With Neovim, you start off in &quot;Normal&quot; mode. How I understand it, here what you type serves to run commands on the editor itself.&lt;/p&gt;
&lt;p&gt;For example, typing &lt;code&gt;:vs&lt;/code&gt; will split your window in 2 vertically. &amp;lt;label for=&quot;quitting&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;quitting&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Obligatory
&amp;lt;br&amp;gt;
&lt;code&gt;:q!&lt;/code&gt; will exit vim without saving.
&amp;lt;br&amp;gt;
&lt;code&gt;:xa&lt;/code&gt; will save all and exit vim.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;Pressing &lt;code&gt;i&lt;/code&gt; will then enter &lt;code&gt;Insert&lt;/code&gt; mode where you can type like normal. &lt;code&gt;Esc&lt;/code&gt; will then bring you back to &lt;code&gt;Normal&lt;/code&gt; mode where you could for example delete a whole paragraph and paste it at the top. &amp;lt;label for=&quot;arcane-spell&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;arcane-spell&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Delete paragraph and paste it at the top: &lt;code&gt;dapggP&lt;/code&gt;
&amp;lt;br&amp;gt;
- &lt;code&gt;dap&lt;/code&gt;: &lt;code&gt;d&lt;/code&gt;elete &lt;code&gt;a&lt;/code&gt;around &lt;code&gt;p&lt;/code&gt;aragraph
&amp;lt;br&amp;gt;
- &lt;code&gt;gg&lt;/code&gt;: &lt;code&gt;g&lt;/code&gt;o to &lt;code&gt;g&lt;/code&gt;top (okay it doesn&apos;t always fit)
&amp;lt;br&amp;gt;
- &lt;code&gt;P&lt;/code&gt;: &lt;code&gt;P&lt;/code&gt;aste above (versus lowercase &lt;code&gt;p&lt;/code&gt; for paste below)
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;It&apos;s pretty arcane and when I started out it was a pain, but the nerdy coolness hacker vibes kept me going. You may have noticed so far we haven&apos;t had to click anywhere. Well Neovim is pretty mouse free.&lt;/p&gt;
&lt;p&gt;Now is this actually more efficient?&lt;/p&gt;
&lt;p&gt;Personally it felt so, and it&apos;s fun solving these mini puzzles when you wanna do something in as few keystrokes as possible. The best vimmers are likely faster than someone doing the same thing with a mouse and multiple cursors. But even that depends.&lt;/p&gt;
&lt;p&gt;Whatever you use, if you take the time to know it, you&apos;ll likely end up as fast as anything else.&lt;/p&gt;
&lt;p&gt;One last command: &lt;code&gt;zf36j&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;z&lt;/code&gt; fold (doesn&apos;t match but see how a &quot;z&quot; kinds looks like a fold? hehe)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;f&lt;/code&gt; create (z is already fold so this could have been &lt;code&gt;ff&lt;/code&gt; or &lt;code&gt;zz&lt;/code&gt; for example)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;36j&lt;/code&gt; 36 lines down&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This will collapse the next 36 lines (plus the one you&apos;re on) into one line. Folding it neatly away.&lt;/p&gt;
&lt;p&gt;I don&apos;t use it often, but it&apos;s practical in markdown files where I write notes.&lt;/p&gt;
&lt;p&gt;The problem is these folds aren&apos;t automatically saved so when you quit vim and come back, everything is expanded again.&lt;/p&gt;
&lt;p&gt;Lets change that.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Feature Spec&lt;/h2&gt;
&lt;p&gt;What I want is to automatically save and restore the folds I make but only in &quot;markdown&quot; files.&lt;/p&gt;
&lt;p&gt;This is all possible in Neovim&apos;s configuration which thankfully uses a full programming language instead of JSON or something worse like YAML. &amp;lt;label for=&quot;yaml-hater&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;yaml-hater&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Yes, that&apos;s right. I have no love in me for &lt;a href=&quot;https://www.ohyaml.wtf/&quot;&gt;yaml&lt;/a&gt;.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;With Neovim your config can be written in &lt;a href=&quot;https://www.lua.org/manual/5.1/&quot;&gt;Lua&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Which, despite having array indices that start at 1, is a very nice language I&apos;ve grown to appreciate.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://neovim.io/doc/user/api.html&quot;&gt;Neovim&apos;s API&lt;/a&gt; is fully accessible through Lua and so you can have it do almost whatever you want.&lt;/p&gt;
&lt;h3&gt;Autocommands&lt;/h3&gt;
&lt;p&gt;&amp;lt;div class=&quot;epigraph&quot;&amp;gt;
&amp;lt;blockquote&amp;gt;
&amp;lt;p&amp;gt;You can specify commands to be executed automatically when reading or writing a file, when entering or leaving a buffer or window, and when exiting Vim.&amp;lt;/p&amp;gt;
&amp;lt;footer&amp;gt;&amp;lt;a href=&quot;https://neovim.io/doc/user/autocmd.html&quot;&amp;gt;Autocmd - Neovim Docs&amp;lt;/a&amp;gt;&amp;lt;/footer&amp;gt;
&amp;lt;/blockquote&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;p&gt;This is what we want, when I&apos;m in a markdown file, automatically save and restore folds.&lt;/p&gt;
&lt;p&gt;Now I didn&apos;t know how to do this exactly so big thanks to Vivian De Smedt for the &lt;a href=&quot;https://vi.stackexchange.com/a/44114&quot;&gt;StackExchange answer&lt;/a&gt; copied (as is tradition) below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- First a group to keep related auto commands together
local folds_augroup = vim.api.nvim_create_augroup(&quot;Folds&quot;, { clear=true })

-- &quot;BufWritePost&quot;: after saving the file
vim.api.nvim_create_autocmd(&quot;BufWritePost&quot;, {
    -- include this auto command in the group
    group = folds_augroup,
    -- &quot;mkview&quot; save the folds
    -- &quot;filetype detect&quot; have vim detect the kind of file we&apos;re in
    -- &quot;set foldmethod=manual&quot; set the kind of folding used in this
    --    file as manual instead of auto on indent
    command = &quot;mkview | filetype detect | set foldmethod=manual&quot;
})

-- &quot;QuitPre&quot;: just before quitting vim
vim.api.nvim_create_autocmd(&quot;QuitPre&quot;, {
    group = folds_augroup,
    command = &quot;mkview | filetype detect | set foldmethod=manual&quot;
})

-- &quot;BufWinEnter&quot;: when entering a window
vim.api.nvim_create_autocmd(&quot;BufWinEnter&quot;, {
    group = folds_augroup,
    -- &quot;silent! loadview&quot; load the folds without announcing anything
    -- same as above
    -- &quot;normal! zM&quot; in Normal mode, execute `zM`
    --    which closes all the folds just loaded
    command = &quot;silent! loadview | filetype detect | set foldmethod=manual | normal! zM&quot;
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pasting this into my config and saving then worked as advertised!&lt;/p&gt;
&lt;p&gt;I can fold up my files just how I want and when I come back, so do they.&lt;/p&gt;
&lt;p&gt;But that isn&apos;t exactly what I want, and if I just copy paste, what would I learn?
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Into the wild&lt;/h2&gt;
&lt;p&gt;First off I want this only in markdown files, and I want to use the Neovim API as much as possible.
Another cool thing I discovered, if you&apos;re making some plugin exclusively for a particular filetype, Neovim has a special folder for them: &lt;a href=&quot;https://neovim.io/doc/user/usr_43.html#filetype-plugin&quot;&gt;ftplugin&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can have it at either of&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.config/nvim/ftplugin/markdown.lua&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.config/nvim/after/ftplugin/markdown.lua&lt;/code&gt;
&lt;ul&gt;
&lt;li&gt;if you want your plugin to run last&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;-- I only want manual folding, omit this if you prefer
-- Being in the markdown ftplugin, I set it locally to the current window
vim.opt_local.foldmethod = &apos;manual&apos;

local folds_augroup = vim.api.nvim_create_augroup(&apos;Folds&apos;, { clear = true })

-- you can give an auto command multiple events
vim.api.nvim_create_autocmd({&apos;BufWritePost&apos;, &apos;QuitPre&apos;}, {
  group = folds_augroup,
  command = &apos;mkview&apos;,
})

vim.api.nvim_create_autocmd(&apos;BufWinEnter&apos;, {
  group = folds_augroup,
  command = &apos;silent! loadview | normal! zM&apos;,
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;And with that my editor is even more perfect. &amp;lt;label for=&quot;meta-curse&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;meta-curse&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
And the &lt;a href=&quot;/blog/maxmntl-the-making-of&quot;&gt;Meta Blog Post Curse&lt;/a&gt; is lifted!!!!
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;Tweaked to perfection. The fact that this is even possible to this degree is reason enough to love Neovim.&lt;/p&gt;
&lt;p&gt;If you wanted to try something similar I hope it helped you out and I hope you&apos;re a bit more interested in Neovim and other tools that give you back control.&lt;/p&gt;
&lt;h3&gt;Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://neovim.io/doc/user/api.html#nvim_create_autocmd()&quot;&gt;&lt;code&gt;nvim_create_autocmd&lt;/code&gt; - Neovim Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://neovim.io/doc/user/fold.html&quot;&gt;Fold - Neovim Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://neovim.io/doc/user/usr_43.html#filetype-plugin&quot;&gt;ftplugins - Neovim Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/MaxMonteil/dotfiles/tree/master/nvim&quot;&gt;My Neovim config - Github&lt;/a&gt;
&amp;lt;/section&amp;gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><author>Maximilien Monteil</author></item><item><title>The Making Of: maxmntl</title><link>https://www.maxmntl.com/blog/maxmntl-the-making-of</link><guid isPermaLink="true">https://www.maxmntl.com/blog/maxmntl-the-making-of</guid><description>The classic meta blog post about how this blog was made.</description><pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h1&gt;The Meta Blog Post&lt;/h1&gt;
&lt;p&gt;This is it, the one you&apos;ve all been waiting for, the meta post.&lt;/p&gt;
&lt;p&gt;Not that Meta! This meta.&lt;/p&gt;
&lt;p&gt;The blog post about the blog itself.&lt;/p&gt;
&lt;p&gt;This will go by fast, there will be nothing surprising, but at the end you&apos;ll know.&lt;/p&gt;
&lt;p&gt;You&apos;ll know the answer to that burning question of yours. The one gnawing at the edge of your soul...&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;My my! What a wonderful little blog!&lt;/p&gt;
&lt;p&gt;So exquisite, such taste, that lettering, the theme switcher jiggle.&lt;/p&gt;
&lt;p&gt;How on God&apos;s green earth was such an achievement of my species even possible?!
&amp;lt;footer&amp;gt;Your soul.&amp;lt;/footer&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Well my dear, beautiful reader, let&apos;s not dally any longer. &amp;lt;label for=&quot;interrobang-punct&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;interrobang-punct&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
The &quot;?!&quot; above is called an interrobang. The more you know.
&amp;lt;br&amp;gt;
I think about that every time I write it, for the correct order.
&amp;lt;br&amp;gt;
Like thinking out &quot;b-e-a-utiful&quot;. Every time.
&amp;lt;/span&amp;gt;
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Inspiration&lt;/h2&gt;
&lt;p&gt;Let&apos;s start at the very beginning.&lt;/p&gt;
&lt;p&gt;~I was born on a Thursday~&lt;/p&gt;
&lt;p&gt;Wait no. Or actually?&lt;/p&gt;
&lt;p&gt;Yes, it was Thursday. I was born on a Thursday, and my first thought was:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I need a blog.&lt;/p&gt;
&lt;p&gt;I must write. Existence has impressed upon me the futility and ephemerality of life. I must put words upon a medium that exists outside of me.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But by the time I was old enough to do something about it, I had forgotten everything.&lt;/p&gt;
&lt;p&gt;You see, no one remembers anything from when they were a baby.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Years later...&lt;/h2&gt;
&lt;p&gt;Decades even!&lt;/p&gt;
&lt;p&gt;I was on vacation in beautiful Colombia.&lt;/p&gt;
&lt;p&gt;Taking in the unblemished sights of the beaches of Tayrona.&lt;/p&gt;
&lt;p&gt;As the waves calmly bobbed me and my turquoise mermaid floaty up and down, I pondered, nay I introspected&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I need a blog.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For yes, how could the world continue spinning without my champagne thoughts written out, almost black on almost white. &amp;lt;label for=&quot;almost&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;almost&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;Or almost white on almost black, did you see that jiggle?!&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;So one evening, awake in bed, the last free evening before resuming gainful employment which started at 3 am because of the time differences and also it was currently 8 pm and I should have been sleeping but revenge is so sweet when served with bedtime procrastination.&lt;/p&gt;
&lt;p&gt;I set out to make 2 second old me proud.&lt;/p&gt;
&lt;p&gt;I made a blog.&lt;/p&gt;
&lt;p&gt;And now, here&apos;s how I made a blog...
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;ugh fine if you really must know&lt;/h2&gt;
&lt;p&gt;I read a lot of &lt;a href=&quot;https://news.ycombinator.com/&quot;&gt;Hacker News&lt;/a&gt; and one day someone shared &lt;a href=&quot;https://edwardtufte.github.io/tufte-css/&quot;&gt;Tufte CSS&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I immediately loved the style and saved that browser tab in the pile for later.&lt;/p&gt;
&lt;p&gt;Then, on that fateful evening, I had the presence of mind to require near 0 Javascript for a static site that serves text. &amp;lt;label for=&quot;jiggle-last&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;jiggle-last&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;Except for that theme switcher, what can you do ¯\_(ツ)_/¯&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;So I chose &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt; which I&apos;d heard a lot about but never used.&lt;/p&gt;
&lt;p&gt;They even have a &lt;a href=&quot;https://docs.astro.build/en/tutorial/0-introduction/&quot;&gt;blog tutorial&lt;/a&gt;, fated as it was. For the sake of pragmatism and learning I just went along with it.&lt;/p&gt;
&lt;p&gt;Then I did some &lt;a href=&quot;/blog/svg-favicons&quot;&gt;SVG shenanigans for the favicon&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;After that I added the theme switcher, did I mention that already?&lt;/p&gt;
&lt;p&gt;Since then, as I write more I&apos;ve edited and expanded the Tufte CSS styles to better suit my style. Which is the coolest thing ever, like a carpenter improving their tools. &amp;lt;label for=&quot;techward-css&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;techward-css&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Those style changes, which internally I call &quot;Techward CSS&quot; are &lt;a href=&quot;https://github.com/MaxMonteil/maxmntl/blob/main/src/styles/global.css&quot;&gt;here&lt;/a&gt;.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;You can find the blog&apos;s repo &lt;a href=&quot;https://github.com/MaxMonteil/maxmntl&quot;&gt;here&lt;/a&gt;. &amp;lt;label for=&quot;personal-void&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;personal-void&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;Do not look at the commits. That&apos;s my own personal screaming void.&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;That&apos;s about it.&lt;/p&gt;
&lt;p&gt;I knew I had to care as little as possible about how the blog is made because the point is what&apos;s written here, not how.&lt;/p&gt;
&lt;p&gt;Now I just need to make sure to have another post after this one, lest the &quot;curse of the meta blog&quot; strike me and turn this blog dusty.
&amp;lt;/section&amp;gt;&lt;/p&gt;
</content:encoded><author>Maximilien Monteil</author></item><item><title>Account Optional Apps with PowerSync</title><link>https://www.maxmntl.com/blog/optional-account-powersync</link><guid isPermaLink="true">https://www.maxmntl.com/blog/optional-account-powersync</guid><description>Using PowerSync to make an app you can use without requiring an account.</description><pubDate>Tue, 03 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h1&gt;Account Optional Apps with PowerSync&lt;/h1&gt;
&lt;p&gt;&amp;lt;p class=&quot;subtitle&quot;&amp;gt;Local-first is cool. PowerSync is local-first. Therefore, PowerSync is cool. CQFD.&amp;lt;/p&amp;gt;&lt;/p&gt;
&lt;p&gt;Now lets look at the proof.&lt;/p&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;&amp;lt;h3&amp;gt;TL;DR&amp;lt;/h3&amp;gt;&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;~What follows is a mathematical proof that local-first is in fact &quot;cool&quot;.~&lt;/p&gt;
&lt;p&gt;I&apos;ll go over a brief intro of local-first and introduce PowerSync, a library that helps you build apps that don&apos;t require a constant internet connection.&lt;/p&gt;
&lt;p&gt;I argue that in the spirit of local-first, you shouldn&apos;t require an account from users.&lt;/p&gt;
&lt;p&gt;Once convinced, I&apos;ll go through the implementation details to enable users to create data without an account and then transfer that into their account if they register or log in.
&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;First, we need some definitions.&lt;/p&gt;
&lt;h2&gt;Local-first&lt;/h2&gt;
&lt;p&gt;Local-first software is a growing movement and pattern in software development aiming to bring back the Good Old Days when you downloaded an app, it was fast, and it worked without internet and your data was all yours to do with what you want.&lt;/p&gt;
&lt;p&gt;It&apos;s a more modern take on that with some modern amenities for the way we work now where internet is at least expected so that you can access your data on multiple devices, work with other people and more.&lt;/p&gt;
&lt;p&gt;The best place to start learning more is with the &lt;a href=&quot;https://www.inkandswitch.com/essay/local-first/#seven-ideals-for-local-first-software:~:text=collaboration%20and%20ownership-,Seven%20ideals%20for%20local%2Dfirst%20software,-1.%20No%20spinners&quot;&gt;Seven Ideals for local-first software&lt;/a&gt; by the Ink and Switch lab who coined the term and played a principal role in its spread.&lt;/p&gt;
&lt;h3&gt;More about local-first&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.inkandswitch.com/essay/local-first&quot;&gt;Ink and Switch - Local-first Essay&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://lofi.so/&quot;&gt;lofi.so&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.localfirst.fm/landscape&quot;&gt;Local-first Landscape&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;PowerSync&lt;/h2&gt;
&lt;p&gt;A large part of what makes modern local-first software lies in the sync-engine.&lt;/p&gt;
&lt;p&gt;Cause in order to have an experience that works offline, you need all your data accessible on your device locally. But to then access it on other devices or work collaboratively with others, some version of it needs to be on someone else&apos;s server.&lt;/p&gt;
&lt;p&gt;This version needs to stay up to date with your local changes, some kind of code, machine, or &lt;em&gt;engine&lt;/em&gt; needs to be in charge of doing the sync.&lt;/p&gt;
&lt;p&gt;You can write your own which Linear did or you can use something like &lt;a href=&quot;https://www.PowerSync.com/&quot;&gt;PowerSync&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;PowerSync will let you have a SQLite database in your user&apos;s browser against which you can manage all their created data while in the background it&apos;ll handle syncing your changes to a remote Postgres database (, MongoDB, MySQL, or SQL Server).&lt;/p&gt;
&lt;p&gt;It also works for pretty much any web or native client framework as well.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Local-first means you shouldn&apos;t &lt;strong&gt;require&lt;/strong&gt; an account&lt;/h2&gt;
&lt;p&gt;What&apos;s not to love about local-first,
&amp;lt;label for=&quot;local-first-shots&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;local-first-shots&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Damn, take a shot everytime you read &quot;local-first&quot;.
&amp;lt;/span&amp;gt; apps will load and behave instantly, you don&apos;t need to be constantly connected, your data is all yours without having to fight a corporate (purposefully?) obtuse export system, and even if the original service dies, you can keep chugging along.&lt;/p&gt;
&lt;p&gt;All while keeping modern benefits like sync and collaboration.&lt;/p&gt;
&lt;p&gt;I wanted that and more for my app so when I took local-first further, I realized I don&apos;t even need to require users to make an account.&lt;/p&gt;
&lt;p&gt;After downloading the app, all your data is there, if you don&apos;t need or want to sync then there&apos;s no point in having an account. That shouldn&apos;t limit you otherwise it goes against ideal 7: &quot;You retain ultimate ownership and control&quot;.
&amp;lt;label for=&quot;free-users&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;free-users&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Another benefit of not requiring an account is that it&apos;s then cheaper to run the app. You can have a majority of users able to use your app without it costing you in auth seats if you use a paid service, or in DB space since sync is off.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;That should still apply after making an account of course, but why not without too?&lt;/p&gt;
&lt;p&gt;So with these values in mind, I set out to update my app such that you can use the full suite of features without an account. Then when you want sync or some account-requiring feature you can choose to make one while keeping all your data.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;The setup&lt;/h2&gt;
&lt;p&gt;Tech here will be PowerSync of course, and Vue.&lt;/p&gt;
&lt;p&gt;A critical resource was PowerSync&apos;s own &lt;a href=&quot;https://github.com/PowerSync-ja/PowerSync-js/tree/main/demos/react-supabase-todolist-optional-sync&quot;&gt;React Supabase Todolist with Optional Sync&lt;/a&gt; example which set the foundation I worked off of. I recommend going over the README.md there for an explanation of how it works.&lt;/p&gt;
&lt;p&gt;Additionally I&apos;ll go over issues and other things I ran into.&lt;/p&gt;
&lt;h3&gt;Rough Steps &amp;lt;label for=&quot;option-function&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;&lt;/h3&gt;
&lt;p&gt;&amp;lt;input type=&quot;checkbox&quot; id=&quot;option-function&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
The [Sidebar] topics are more about the app and business logic steps, not PowerSync directly.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#function-able-schema&quot;&gt;Change your PowerSync schema creation into a function with dynamic names&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#sync-mode-tracking&quot;&gt;Keep track when Sync Mode is enabled or not&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#sidebar-auth-routes-and-pages&quot;&gt;[Sidebar]: Update your routes and UX around the auth and &quot;user setting&quot; pages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#sidebar-default-signed-out-user&quot;&gt;[Sidebar]: Handle the &quot;signed out user&quot;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#sidebar-user-id-references-in-tables&quot;&gt;[Sidebar]: Add a reference to the User ID in all columns if not present&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The rest of the owl
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#local-to-sync-data-transfer&quot;&gt;Implement Schema change and data transfer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#switching-sync-mode-on-auth&quot;&gt;Switching tables and data on auth&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#sidebar-insert-order-of-operations&quot;&gt;[Sidebar]: Handling Foreign Key references during the batch insert&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once again, we&apos;ll be referring to &lt;a href=&quot;https://github.com/PowerSync-ja/PowerSync-js/tree/main/demos/react-supabase-todolist-optional-sync&quot;&gt;PowerSync&apos;s own example on how to do this&lt;/a&gt; quite a lot.&lt;/p&gt;
&lt;p&gt;We&apos;ve got our plan, lets get to it!
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Function-able Schema&lt;/h2&gt;
&lt;p&gt;We start of simple by just making sure our schema is built with a function that can take one argument.&lt;/p&gt;
&lt;p&gt;By default, PowerSync recommends defining your tables at the top-level then passing them into &lt;code&gt;new Schema&lt;/code&gt; like so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const todos = new Table(
  { /* table columns */ },
  { /* table options */ },
)

const lists = new Table({ /* list of columns */ });

export const AppSchema = new Schema({
  todos,
  lists,
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But we&apos;ll need to create our schema with dynamic view names, depending on whether we&apos;re in sync mode or not.&lt;/p&gt;
&lt;p&gt;First we create an object for our table definitions.
&amp;lt;label for=&quot;option-is-function&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;option-is-function&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;⚠ Watch out! &lt;code&gt;options&lt;/code&gt; is a function.&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const todosDef = {
  columns: { /* table columns */ },
  options: (viewName: string, localOnly = false) =&amp;gt; ({
    viewName,
    localOnly,
    /* other table options */
  }),
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;columns&lt;/code&gt; hasn&apos;t changed.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;options&lt;/code&gt; is now a function, this was to avoid having to pass the same table options key value pairs so often.&lt;/p&gt;
&lt;p&gt;This here comes straight from the example. These functions set the correct table view names depending on the sync mode we&apos;re in.
We can set whatever view name we want, but know that this will be the way to reference these tables in all your queries.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function syncedName(table: string, synced: boolean) {
  return synced ? table : `inactive_synced_${table}`
}

function localName(table: string, synced: boolean) {
  return synced ? `inactive_local_${table}` : table
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally there&apos;s the actual schema creation:
&amp;lt;label for=&quot;curry-function&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;curry-function&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
&lt;code&gt;toSyncedName&lt;/code&gt; &amp;amp; &lt;code&gt;toLocalName&lt;/code&gt; are other examples of lazyness. I didn&apos;t want to type the same thing out too much.
&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;
I also set the default argument value here to &lt;code&gt;true&lt;/code&gt; since we haven&apos;t set up anything else yet. This way everything should still continue to work.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function makeSchema(synced = true) {
  const toSyncedName = (table: string) =&amp;gt; syncedName(table, synced)
  const toLocalName = (table: string) =&amp;gt; localName(table, synced)

  return new Schema({
    lists: new Table(
      listsDef.columns,
      listsDef.options(toSyncedName(&apos;lists&apos;)),
    ),
    local_lists: new Table(
      listsDef.columns,
      listsDef.options(toLocalName(&apos;lists&apos;), true),
    ),

    todos: new Table(
      todosDef.columns,
      todosDef.options(toSyncedName(&apos;todos&apos;)),
    ),
    local_todos: new Table(
      todosDef.columns,
      todosDef.options(toLocalName(&apos;todos&apos;), true),
    ),

    /* Local only draft tables */
    draft_todos: new Table(
      todosDef.columns,
      todosDef.options(&apos;draft_todos&apos;, true),
    ),
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Of special note here is the &lt;code&gt;draft_todos&lt;/code&gt; table. This is a table that will always be local only, I don&apos;t want to sync it and in my app the logic already always clears it out.
I used these &lt;code&gt;draft_&lt;/code&gt; tables for long running forms. Copy the live data into a draft table, apply user edits to it, then on save reconcile the changes. With this pattern I had an easier time dropping all changes on quit or cancel. &amp;lt;label for=&quot;repetition&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;repetition&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;
Now in spite of my lazyness, there&apos;s still a ungodly amount of repetition in this schema definition. It nags at me in the night.
It should be possible to have this all in a loop or so while keeping the types but I haven&apos;t gotten around to it yet.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;&amp;lt;h3&amp;gt;Here&apos;s the full thing all together&amp;lt;/h3&amp;gt;&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const listsDef = {
  columns: { /* table columns */ },
  options: (viewName: string, localOnly = false) =&amp;gt; ({
    viewName,
    localOnly,
  }),
}

const todosDef = {
  columns: { /* table columns */ },
  options: (viewName: string, localOnly = false) =&amp;gt; ({
    viewName,
    localOnly,
    /* other table options */
  }),
}

function syncedName(table: string, synced: boolean) {
  return synced ? table : `inactive_synced_${table}`
}

function localName(table: string, synced: boolean) {
  return synced ? `inactive_local_${table}` : table
}

export function makeSchema(synced = true) {
  const toSyncedName = (table: string) =&amp;gt; syncedName(table, synced)
  const toLocalName = (table: string) =&amp;gt; localName(table, synced)

  return new Schema({
    lists: new Table(
      listsDef.columns,
      listsDef.options(toSyncedName(&apos;lists&apos;)),
    ),
    local_lists: new Table(
      listsDef.columns,
      listsDef.options(toLocalName(&apos;lists&apos;), true),
    ),

    todos: new Table(
      todosDef.columns,
      todosDef.options(toSyncedName(&apos;todos&apos;)),
    ),
    local_todos: new Table(
      todosDef.columns,
      todosDef.options(toLocalName(&apos;todos&apos;), true),
    ),

    /* Local only draft tables */
    draft_todos: new Table(
      todosDef.columns,
      todosDef.options(&apos;draft_todos&apos;, true),
    ),
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;p&gt;Finally, make sure to update the creation of your PowerSync instance with the new schema as a function.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// default to `true` so the current app keeps working
const syncEnabled = true

export const powersync = new PowerSyncDatabase({
  schema: makeSchema(syncEnabled),
  database: new WASQLiteOpenFactory({ dbFilename: DB_NAME }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;&amp;lt;h3&amp;gt;Bonus: Types&amp;lt;/h3&amp;gt;&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;p&gt;To get access to the type of your database and tables you can use the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import type { makeSchema } from &apos;/path/to/your/schema&apos;

export type Database = ReturnType&amp;lt;typeof makeSchema&amp;gt;[&apos;types&apos;]
export type Tables = keyof Database

export type TodosRecord = Database[&apos;todos&apos;]
export type ListsRecord = Database[&apos;lists&apos;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Sync Mode Tracking&lt;/h2&gt;
&lt;p&gt;Your schema is now ready to work fully offline or with sync depending on the auth status.&lt;/p&gt;
&lt;p&gt;But like with site themes, your app needs to know the auth state as early as possible for correct bootstrap and outside of the normal auth flow so that it can always be accessed.&lt;/p&gt;
&lt;p&gt;That&apos;s a job for &lt;code&gt;localStorage&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Luckily the PowerSync example has just &lt;a href=&quot;https://github.com/powersync-ja/powersync-js/blob/main/demos/react-supabase-todolist-optional-sync/src/library/powersync/SyncMode.ts&quot;&gt;what we need&lt;/a&gt; so we can copy that over.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const SYNC_KEY = &apos;syncEnabled&apos;

export function getSyncEnabled(dbName: string) {
  const key = `${SYNC_KEY}-${dbName}`
  const value = JSON.parse(localStorage.getItem(key) ?? &apos;null&apos;) as boolean | null

  if (value == null) {
    // the example has a bug here, pass the `dbName` not the key
    // otherwise you&apos;ll get `${key}-${key}-${dbName}` stored
    setSyncEnabled(dbName, false)
    return false
  }

  return value === true
}

export function setSyncEnabled(dbName: string, enabled: boolean) {
  const key = `${SYNC_KEY}-${dbName}`

  localStorage.setItem(key, enabled ? &apos;true&apos; : &apos;false&apos;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can then put that to good use in a few places.&lt;/p&gt;
&lt;h3&gt;PowerSync instance&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import { getSyncEnabled, setSyncEnabled } from &apos;/path/to/syncmode&apos;

// Not the final resting place of this line
setSyncEnabled(DB_NAME, true)

const syncEnabled = getSyncEnabled(DB_NAME)

export const powersync = new PowerSyncDatabase({
  schema: makeSchema(syncEnabled),
  database: new WASQLiteOpenFactory({ dbFilename: DB_NAME }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;code&gt;main.ts&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;This may look a bit weird, &amp;lt;label for=&quot;peak-performance&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;peak-performance&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
This is what peak performance looks like. Not me fighting the linter (shoutout to &lt;a href=&quot;https://github.com/antfu/eslint-config&quot;&gt;Antfu&apos;s ESLint Config&lt;/a&gt;).
&amp;lt;/span&amp;gt;
but I want to avoid a top level await (even though really this is the same thing).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { getSyncEnabled } from &apos;/path/to/syncmode&apos;

async function bootstrap() {
  await powersync.init()

  const connector = new PowersyncConnector(auth)
  connector.registerListener({
    // user is signed in when this is called
    sessionStarted: async (user) =&amp;gt; {
      // STUB for now
      const isSyncMode = getSyncEnabled(powersync.database.name)
      console.warn(&apos;[TODO]: use sync mode&apos;)

      // Only connect PowerSync to Supabase when there&apos;s a signed in user
      await powersync.connect(connector)
    },
  })

  await connector.init()
}

void bootstrap().then(() =&amp;gt; {
  const app = createApp(App)

  app.use(router)
  app.use(store)
  app.mount(&apos;#app&apos;)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the &lt;code&gt;PowersyncConnector&lt;/code&gt; class, you&apos;ll need to register the listener and call it as needed.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// In my case I&apos;m using Firebase for auth, if you use Supabase
// it should be more similar to the PowerSync docs.
// If you use something else this should help you find the
// places to change when using a 3rd party auth service
import type { Auth, User } from &apos;firebase/auth&apos;

type ConnectorListener = {
  sessionStarted: (user: User) =&amp;gt; Promise&amp;lt;void&amp;gt;
}

export class PowersyncConnector
  extends BaseObserver&amp;lt;ConnectorListener&amp;gt;
  implements PowerSyncBackendConnector {
  readonly sbClient: SupabaseClient

  ready: boolean

  auth: Auth

  constructor(auth: Auth) {
    super()

    this.ready = false

    this.auth = auth

    this.sbClient = createClient(
      config.supabaseUrl,
      config.supabaseAnonKey,
      {
        accessToken: async () =&amp;gt;
          (await this.auth.currentUser?.getIdToken(false)) ?? null,
      },
    )

    auth.onAuthStateChanged(user =&amp;gt; this.updateUser(user))
  }

  async init() {
    if (this.ready)
      return

    await this.auth.authStateReady()
    this.updateUser(this.auth.currentUser)

    this.ready = true
  }

  updateUser(user: User | null) {
    if (user)
      this.iterateListeners(async cb =&amp;gt; cb.sessionStarted?.(user))
  }

  // rest of the connector...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Sidebar: Auth Routes and Pages&lt;/h2&gt;
&lt;p&gt;On my journey to support account free usage, there were also UX and Business Logic things to change.&lt;/p&gt;
&lt;p&gt;This may or may not apply to you but I bring it up here for completeness.&lt;/p&gt;
&lt;p&gt;There are 2 main things you need to consider.&lt;/p&gt;
&lt;h3&gt;1. Route redirection on &quot;auth only&quot; pages&lt;/h3&gt;
&lt;p&gt;Like me, you probably had some pages that were meant to be accessible only to authenticated users like User Settings, or element creation, etc.&lt;/p&gt;
&lt;p&gt;Maybe even with a cheeky &lt;code&gt;route.meta.requiresAuth&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Well no longer. Now everything is accessible to everyone.&lt;/p&gt;
&lt;p&gt;So make sure your router redirects for these pages is updated to allow visits and remove the &lt;code&gt;requiresAuth&lt;/code&gt; route meta (or your equivalent) everywhere.&lt;/p&gt;
&lt;p&gt;You may also want to update your catch all route. For me if someone visited &lt;code&gt;/something/thats-not-a/route&lt;/code&gt;, I would redirect to the account sign up page. That no longer makes sense and is now the app homepage.&lt;/p&gt;
&lt;h3&gt;2. Unauthenticated user pages&lt;/h3&gt;
&lt;p&gt;Finally, the User Settings page used to only be worth it for a logged in user.&lt;/p&gt;
&lt;p&gt;Well no longer!&lt;/p&gt;
&lt;p&gt;People without an account may visit this page (or not, you could keep that one locked), it&apos;s worth having a version for logged out users.&lt;/p&gt;
&lt;p&gt;I went with the Log in &amp;amp; Sign up buttons that link to the respective pages plus an explanation of the point of an account.&lt;/p&gt;
&lt;blockquote&gt;
&lt;h3&gt;An account is optional.&lt;/h3&gt;
&lt;p&gt;Mylo is free to use without an account for as long as you want.&lt;/p&gt;
&lt;p&gt;Logging in or creating an account lets you sync your workouts across devices.&lt;/p&gt;
&lt;p&gt;All your workouts and training data will be merged into your account. &lt;strong&gt;You won&apos;t lose anything.&lt;/strong&gt;
&amp;lt;footer&amp;gt;The wording I&apos;ve got on my Account page for signed out users.&amp;lt;/footer&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Sidebar: Default Signed-Out User&lt;/h2&gt;
&lt;p&gt;This once again depends on your particular application.&lt;/p&gt;
&lt;p&gt;If you have a user table, you&apos;re likely referencing it in a few other tables and places.&lt;/p&gt;
&lt;p&gt;Logged out users don&apos;t have a &quot;user&quot; but codewise you&apos;ll want to include some logic to create a type of default user.&lt;/p&gt;
&lt;p&gt;Most important is giving it a User ID which you&apos;ll need to reference in all other tables, the next step.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  async function bootstrap() {
  await powersync.init()

  const connector = new PowersyncConnector(auth)
  connector.registerListener({
    initialized: async (user) =&amp;gt; {
      if (user == null)
        await createUserUseCase() // here is where you create your &quot;default&quot; user
    },

    // user is signed in when this is called
    sessionStarted: async (user) =&amp;gt; {
      // STUB for now
      const isSyncMode = getSyncEnabled(powersync.database.name)
      console.warn(&apos;[TODO]: use sync mode&apos;)

      await powersync.connect(connector)
    },
  })

  await connector.init()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As for the &lt;code&gt;PowersyncConnector&lt;/code&gt;, you&apos;ll also need to add the &lt;code&gt;initialized&lt;/code&gt; listener.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type ConnectorListener = {
  // note that here the user can be `null`
  initialized: (user: User | null) =&amp;gt; Promise&amp;lt;void&amp;gt;
  sessionStarted: (user: User) =&amp;gt; Promise&amp;lt;void&amp;gt;
}

export class PowersyncConnector
  extends BaseObserver&amp;lt;ConnectorListener&amp;gt;
  implements PowerSyncBackendConnector {
  readonly sbClient: SupabaseClient

  ready: boolean

  auth: Auth

  constructor(auth: Auth) {
    super()

    this.ready = false

    this.auth = auth

    this.sbClient = createClient(
      config.supabaseUrl,
      config.supabaseAnonKey,
      {
        accessToken: async () =&amp;gt;
          (await this.auth.currentUser?.getIdToken(false)) ?? null,
      },
    )

    auth.onAuthStateChanged(user =&amp;gt; this.updateUser(user))
  }

  async init() {
    if (this.ready)
      return

    await this.auth.authStateReady()
    this.updateUser(this.auth.currentUser)

    this.ready = true
    this.iterateListeners(
      async cb =&amp;gt; cb.initialized?.(this.auth.currentUser),
    )
  }

  updateUser(user: User | null) {
    if (user)
      this.iterateListeners(async cb =&amp;gt; cb.sessionStarted?.(user))
  }

  // rest of the connector...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Sidebar: User ID References in Tables&lt;/h2&gt;
&lt;p&gt;With Supabase and row-level security, I have a policy enabling changes to a row only if the ID of the user making the request matches the &lt;code&gt;uid&lt;/code&gt; row on the table.
&amp;lt;label for=&quot;supabase-policy&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;supabase-policy&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
⚠ The way I&apos;m getting the &lt;code&gt;uid&lt;/code&gt; here is because of Firebase Auth. Your case may be different or similar. &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc7519&quot;&gt;Who even understands JWTs?&lt;/a&gt;
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER POLICY &quot;Enable all for owner&quot;
ON &quot;public&quot;.&quot;&amp;lt;table_name&amp;gt;&quot;
TO PUBLIC
USING ((SELECT (auth.jwt() -&amp;gt;&amp;gt; &apos;sub&apos;::text)) = uid);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It wasn&apos;t directly used in the app so I hadn&apos;t had it in my schema till now.&lt;/p&gt;
&lt;p&gt;For the schema switch on auth it&apos;ll be especially important to have this row defined so that the logged out data can be assigned to the user on transfer.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import type { BaseColumnType } from &apos;@powersync/web&apos;

import { column } from &apos;@powersync/web&apos;

const listsDef = {
  columns: {
    uid: column.text as BaseColumnType&amp;lt;string&amp;gt;,
    /* other table columns */
  },
  options: (viewName: string, localOnly = false) =&amp;gt; ({
    viewName,
    localOnly,
  }),
}

const todosDef = {
  columns: {
    uid: column.text as BaseColumnType&amp;lt;string&amp;gt;,
    /* other table columns */
  },
  options: (viewName: string, localOnly = false) =&amp;gt; ({
    viewName,
    localOnly,
    /* other table options */
  }),
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Local to Sync Data Transfer&lt;/h2&gt;
&lt;p&gt;Now, after all this prep, lets write the meat and bones of this operation.&lt;/p&gt;
&lt;p&gt;After a user signs in or registers, we want to take all the data from the local tables and insert it into the synced tables.&lt;/p&gt;
&lt;p&gt;Then we update the &lt;code&gt;syncEnabled&lt;/code&gt; state to make sure we&apos;re using these snazzy new tables.&lt;/p&gt;
&lt;p&gt;Finally we clear out the local tables (unless you want that data to still be present when they sign out again).&lt;/p&gt;
&lt;p&gt;When a user signs out, we clear everything and disable sync.&lt;/p&gt;
&lt;p&gt;So here&apos;s the basic approach if you have a just few tables.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// powersync/switchSchema.ts

import type { PowerSyncDatabase } from &apos;@powersync/web&apos;

import { makeSchema } from &apos;./schema&apos;
import { setSyncEnabled } from &apos;./syncMode&apos;

// moved these 2 here for convenience
export function syncedName(table: string, synced: boolean) {
  return synced ? table : `inactive_synced_${table}`
}

export function localName(table: string, synced: boolean) {
  return synced ? `inactive_local_${table}` : table
}

export async function switchToSyncedSchema(db: PowerSyncDatabase, userId: string) {
  await db.updateSchema(makeSchema(true))
  setSyncEnabled(db.database.name, true)

  await db.writeTransaction(async (trx) =&amp;gt; {
    const todosColumns = Object.keys(todosDef.columns).toString()
    await trx.execute(`
      INSERT INTO todos(id, ${todosColumns})
      SELECT id, ${todosColumns}
      FROM ${localName(&apos;todos&apos;, true)}
    `)

    // I filter out `uid` to manually add it in the position I want for the query
    const listsColumns = Object.keys(listsDef.columns).filter(c =&amp;gt; c !== &apos;uid&apos;).toString()
    await trx.execute(`
      INSERT INTO lists(id, ${listsColumns}, uid)
      SELECT id, ${listsColumns}, ?
      FROM ${localName(&apos;lists&apos;, true)}
    `, [userId])


    // clear out all local tables
    trx.execute(`DELETE FROM ${localName(&apos;todos&apos;, true)}`)
    trx.execute(`DELETE FROM ${localName(&apos;lists&apos;, true)}`)
  })
}

export async function switchToLocalSchema(db: PowerSyncDatabase) {
  await db.updateSchema(makeSchema(false))
  setSyncEnabled(db.database.name, false)

  await db.writeTransaction(async (trx) =&amp;gt; {
    await Promise.all([&apos;todos&apos;, &apos;lists&apos;].map(
      async name =&amp;gt; trx.execute(`DELETE FROM ${syncedName(name, true)}`),
    ))
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Not all tables are created equal&lt;/h3&gt;
&lt;p&gt;In my case, I didn&apos;t have just a few tables and some were more equal than others.&lt;/p&gt;
&lt;p&gt;At 10 tables (double that for the &lt;code&gt;local_*&lt;/code&gt; ones), I &lt;em&gt;needed&lt;/em&gt; the loop.&lt;/p&gt;
&lt;p&gt;I also had those pesky &quot;true local only&quot; tables that I don&apos;t want to sync but do want to clear out.&lt;/p&gt;
&lt;p&gt;Finally my user tables would conflict against my Supabase constraints.&lt;/p&gt;
&lt;p&gt;These &quot;lesser&quot; tables had to go, but in slightly different ways, so I make an array of tables to exclude, and turn everything into a loop.&lt;/p&gt;
&lt;p&gt;First, the undesirables:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** Tables to exclude from local -&amp;gt; online sync. */
export const SYNC_EXCLUDED_TABLES = [
  /** `draft_*` tables are local only and can be wiped. */
  &apos;draft_&apos;,

  /** `inactive_*` tables are temporary copies to ignore. */
  &apos;inactive_&apos;,

  /** The local user is discarded to prevent sync conflicts. */
  &apos;users&apos;,
]

/** Tables to exclude from purge during auth state switch. */
export const DELETE_EXCLUDED_TABLES = [
  /** `inactive_*` tables are temporary copies to ignore. */
  &apos;inactive_&apos;,

  /** `draft_*` tables are local only and can be wiped. */
  &apos;draft_&apos;,

  // We DO want to clear the local user table
  // &apos;users&apos;,
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now all loopified:
&amp;lt;label for=&quot;metaphor&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;metaphor&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
There&apos;s also that &lt;code&gt;uid&lt;/code&gt; column on every table that I want to treat extra special.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// powersync/switchSchema.ts

import type { PowerSyncDatabase } from &apos;@powersync/web&apos;

import { DELETE_EXCLUDED_TABLES, SYNC_EXCLUDED_TABLES } from &apos;./constants&apos;
import { makeSchema } from &apos;./schema&apos;
import { setSyncEnabled } from &apos;./syncMode&apos;

export function syncedName(table: string, synced: boolean) {
  return synced ? table : `inactive_synced_${table}`
}

export function localName(table: string, synced: boolean) {
  return synced ? `inactive_local_${table}` : table
}

export async function switchToSyncedSchema(db: PowerSyncDatabase, userId: string) {
  await db.updateSchema(makeSchema(true))
  setSyncEnabled(db.database.name, true)

  await db.writeTransaction(async (trx) =&amp;gt; {
    const tableNames = db.schema.tables
      .filter(t =&amp;gt; SYNC_EXCLUDED_TABLES.every(ex =&amp;gt; !t.viewName.startsWith(ex)))
      .map(t =&amp;gt; ({
        sync: t.viewName,
        local: localName(t.viewName, true),
        columns: Object.keys(t.columnMap).filter(c =&amp;gt; c !== &apos;uid&apos;),
      }))

    // manually add `id`, it&apos;s there by default but not in the table definitions
    // adding `uid` at the end to make sure of the order
    await Promise.all(tableNames.map(async t =&amp;gt; trx.execute(`
      INSERT INTO ${t.sync}(id, ${t.columns.toString()}, uid)
      SELECT id, ${t.columns.toString()}, ?
      FROM ${t.local}
    `, [userId])))

    // clear out all local tables
    const localOnlyTables = db.schema.tables
      .filter(t =&amp;gt; DELETE_EXCLUDED_TABLES.every(ex =&amp;gt; !t.viewName.startsWith(ex)))
      .map(t =&amp;gt; localName(t.viewName, true))
    await Promise.all(localOnlyTables.map(async name =&amp;gt; trx.execute(`DELETE FROM ${name}`)))
  })
}

export async function switchToLocalSchema(db: PowerSyncDatabase) {
  await db.updateSchema(makeSchema(false))
  setSyncEnabled(db.database.name, false)

  await db.writeTransaction(async (trx) =&amp;gt; {
    const syncTables = db.schema.tables
      .filter(t =&amp;gt; DELETE_EXCLUDED_TABLES.every(ex =&amp;gt; !t.viewName.startsWith(ex)))
      .map(t =&amp;gt; syncedName(t.viewName, true))
    await Promise.all(syncTables.map(async name =&amp;gt; trx.execute(`DELETE FROM ${name}`)))
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Presto! We&apos;re ready to wire all this up.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Switching Sync Mode on Auth&lt;/h2&gt;
&lt;p&gt;Now lets do the skin and hair of this operation! &amp;lt;label for=&quot;metaphor-extended&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;metaphor-extended&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
How&apos;s my metaphor extension?
&amp;lt;br&amp;gt;
Call 1-800-888-8888
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;So where are we at now?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tables are ready&lt;/li&gt;
&lt;li&gt;Schema is parametric&lt;/li&gt;
&lt;li&gt;Data switching is set up&lt;/li&gt;
&lt;li&gt;We know whether to sync or not&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now we orchestrate all these elements together.&lt;/p&gt;
&lt;h3&gt;0. In PowerSync init&lt;/h3&gt;
&lt;p&gt;Now that things will be wired up, we can forego the hardcoded value.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const syncEnabled = getSyncEnabled(DB_NAME)

export const powersync = new PowerSyncDatabase({
  schema: makeSchema(syncEnabled),
  database: new WASQLiteOpenFactory({ dbFilename: DB_NAME }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1. On sign in or register&lt;/h3&gt;
&lt;p&gt;Here we have a newly authenticated user while still being in sync mode off.&lt;/p&gt;
&lt;p&gt;That means it&apos;s time to switch to sync mode and transfer the data.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// main.ts

sessionStarted: async (user) =&amp;gt; {
  const isSyncMode = getSyncEnabled(powersync.database.name)
  if (isSyncMode === false)
    await switchToSyncedSchema(powersync, user.uid)

  await powersync.connect(connector)
},
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. On sign out&lt;/h3&gt;
&lt;p&gt;Wherever you&apos;ve got your sign out logic, you&apos;ll want to then switch back to local tables and turn sync mode off.&lt;/p&gt;
&lt;p&gt;Here&apos;s what that looks like for me.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { auth } from &apos;@/features/auth&apos;
import { powersync, switchToLocalSchema } from &apos;@/libraries/powersync&apos;

export async function signOutUserUseCase() {
  if (auth.currentUser == null)
    throw new Error(&apos;Unable to logout.&apos;)

  await powersync.disconnectAndClear()
  await switchToLocalSchema(powersync)
  await auth.signOut()

  // this erases the sync mode key but it gets initialized to `false` so all good
  localStorage.clear()

  window.location.reload()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Sidebar: In the router&lt;/h3&gt;
&lt;p&gt;Now here is a Vue-ism so your version may vary.&lt;/p&gt;
&lt;p&gt;I found that waiting for the first database sync prevented a blank loading page which isn&apos;t very local-first or it loaded partial data until a refresh.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;router.beforeEach(async (to) =&amp;gt; {
  if (auth.currentUser != null)
    await database.waitForFirstSync()

  // rest of your router guard logic...
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And that&apos;s it, you&apos;re all done! &amp;lt;label for=&quot;thats-not-all&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;thats-not-all&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;&lt;code&gt;Narrator:&lt;/code&gt; That was not it, and I was not done...&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;We set up all the dominoes and got to watch them fall (which always happens much faster than the setup).
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Sidebar: Insert Order of Operations&lt;/h2&gt;
&lt;p&gt;So uhm yeah, this is a sidebar, so maybe you&apos;re not gonna run into this at all. But I ran into it, at full speed...&lt;/p&gt;
&lt;p&gt;My riches of tables have multiple foreign key references. They depend on each other in a specific directed acyclic order.&lt;/p&gt;
&lt;p&gt;What ends up happening after the switch is that the transaction operation happens in an order you can&apos;t quite control (as far as I know). Leading to a bunch of:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;ERROR: insert or update on table &quot;child_table&quot; violates foreign key constraint &quot;fk_constraint_name&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DETAIL: Key (parent_id)=(100) is not present in table &quot;parent_table&quot;.&lt;/code&gt;
&amp;lt;footer&amp;gt;My console, multiple times.&amp;lt;/footer&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Our underlying local(-first) SQLite table doesn&apos;t have the foreign key constraint turned on by default. We can insert whatever we want in the order we want, then if all the data references are good when the transaction ends, you&apos;re good. Postgres is not so kind.&lt;/p&gt;
&lt;p&gt;The fix was straightforward but exhausting.&lt;/p&gt;
&lt;p&gt;Have a list of your working table insertion order:
&amp;lt;label for=&quot;insertion-order&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;insertion-order&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
If &lt;code&gt;A&lt;/code&gt; references &lt;code&gt;B&lt;/code&gt; which references &lt;code&gt;C&lt;/code&gt;, you&apos;ll want to go &lt;code&gt;[C, B, A]&lt;/code&gt;
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** Foreign key reference safe table order in which to insert data. */
export const INSERT_ORDER: Tables[] = [
  /* list them here in order of insertion */
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then make sure to sort the operations in the &lt;code&gt;PowersyncConnector&lt;/code&gt;:
&amp;lt;label for=&quot;a-better-place&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;a-better-place&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
I believe there&apos;s a better way and place to do this but I have not found it.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export class PowersyncConnector
  extends BaseObserver&amp;lt;ConnectorListener&amp;gt;
  implements PowerSyncBackendConnector {

  async uploadData(database: AbstractPowerSyncDatabase): Promise&amp;lt;void&amp;gt; {
    const transaction = await database.getNextCrudTransaction()

    if (!transaction)
      return

    let lastOp: CrudEntry | null = null
    try {
      // due to foreign key references inserts need to happen in a particular order
      // this is mostly relevant for transfering logged out data to logged in accounts
      // other places already do it in the right order
      const sortedCrud = transaction.crud
        .toSorted((a, b) =&amp;gt; INSERT_ORDER.indexOf(a.table) - INSERT_ORDER.indexOf(b.table))

      for (const op of sortedCrud) {
        // and so on and so forth...
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That should be it.
&amp;lt;label for=&quot;narrator-crickets&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;narrator-crickets&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
&lt;code&gt;Narrator (begrudgingly):&lt;/code&gt; That was it.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;First off, big thanks for making it to the end. This is the longest post so far but the struggles I faced implementing this drove me to write. If you&apos;re another local-first afficionado and decide to undertake this feature as well I hope this helps you out.&lt;/p&gt;
&lt;p&gt;With this, our app now works without requiring users to create an account unless they want to. Should they choose to do so, they will find their autonomy respected and their data preserved*. &amp;lt;label for=&quot;asterisk&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;asterisk&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
*If you&apos;ve got a PWA, I can&apos;t make any promises for how long a user&apos;s device will keep browser storage before choosing to clear it out. I don&apos;t know if the universe where wasm sqlite stores information is protected. But with an account and data synced, it should last until you accidentally wipe it yourself.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;Make sure to test the hell out of this. You&apos;re dealing with auth and precious user data.
&amp;lt;/section&amp;gt;&lt;/p&gt;
</content:encoded><author>Maximilien Monteil</author></item><item><title>Empty State Onboarding</title><link>https://www.maxmntl.com/blog/empty-state-onboarding</link><guid isPermaLink="true">https://www.maxmntl.com/blog/empty-state-onboarding</guid><description>My approach to onboarding flows in apps</description><pubDate>Mon, 02 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h1&gt;Empty State Onboarding&lt;/h1&gt;
&lt;p&gt;I&apos;ve been working on my app Mylo for quite some time now. It&apos;s a workout app aimed at athletes, or anyone that does &quot;standard&quot; training like in the gym and then does some sport specific training.&lt;/p&gt;
&lt;p&gt;There&apos;s a lot of workout apps out there but I never found one that had the flexibility you&apos;d need for some of the crazy sport-specific training I&apos;ve seen.
&amp;lt;label for=&quot;crazy-sports-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;crazy-sports-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Think about wild sports like Slap Fighting. It&apos;s got pro leagues and all. They&apos;re training for this, but how? I don&apos;t know of an app that would support this.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;Anyways, Mylo has been almost done for quite a long time but lately I&apos;ve really been blasting through my launch checklist and now I only have the dreaded 10% left.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;10% left AKA you forgot about onboarding (1%)&lt;/h2&gt;
&lt;p&gt;One part of the 10% is onboarding.&lt;/p&gt;
&lt;p&gt;Getting strangers familiar with something that&apos;s become second nature to me now after all my time with it.&lt;/p&gt;
&lt;p&gt;Now onboarding, like most things, has a massive rabbit hole behind it with full on psychology studies. For me and my goals here, onboarding is the act of educating and informing a new person how something works.&lt;/p&gt;
&lt;p&gt;I&apos;ve seen 4 main ways to onboard a user onto an app. They all have their goals and likely fit better in some experiences better than others, but I had my gripes with most of them.&lt;/p&gt;
&lt;h3&gt;1. Welcome Slides&lt;/h3&gt;
&lt;p&gt;That&apos;s when the app opens up for the first time with a sort of fullscreen slideshow of the features and benefits of the app that you can click or swipe through.&lt;/p&gt;
&lt;p&gt;I find this is often the same slides you see in the app store showcase. It usually says something you already know, you installed the app, you&apos;re already convinced.&lt;/p&gt;
&lt;p&gt;I tend to swipe through this as fast as possible or press the skip button.&lt;/p&gt;
&lt;h3&gt;2. Guided Clicking Tour&lt;/h3&gt;
&lt;p&gt;Here the app takes you along this on-rails experience, highlighting different parts of the app to click on. Like a video game tutorial.&lt;/p&gt;
&lt;p&gt;This can work if it isn&apos;t too long or if the app is in fact very complicated, but I think it takes away too much agency from the user.&lt;/p&gt;
&lt;p&gt;I&apos;ve just landed in a new place, I&apos;d like to explore a bit at my own pace.&lt;/p&gt;
&lt;p&gt;The tour can also lead to information overload, in a few minutes you get sheparded from place to place, semi-thoughtlessly following prompts. Once done it can be tougher to do the same steps again alone, kinda like the feeling of tutorial hell.&lt;/p&gt;
&lt;h3&gt;3. Intro Setup Form&lt;/h3&gt;
&lt;p&gt;With the intro form, the app opens up with some simple questions to help setup the app for your use case.&lt;/p&gt;
&lt;p&gt;For a workout app for example, this would be the one that asks your experience level, gender, height, blood type, astrological sign, and then does something with it.&lt;/p&gt;
&lt;p&gt;Most egregiously, I&apos;ve seen this use the sunken cost principle to offer some paid thing once your a few steps too far. Some even have little encouragement messages in between to keep you going.&lt;/p&gt;
&lt;p&gt;This once again takes away agency, it can be useful though if you&apos;re filling out info that will rarely need to change and it ends with some actionable result.&lt;/p&gt;
&lt;h3&gt;4. Self Explanatory Interface&lt;/h3&gt;
&lt;p&gt;Maybe a nice way of saying there&apos;s no onboarding. But if the product is extremely well executed, this may be the holy grail.&lt;/p&gt;
&lt;p&gt;I liken it to physical products like a door handle. It doesn&apos;t take too much effort to figure it out without a guide.
&amp;lt;label for=&quot;handle-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;handle-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
Even the &quot;simplest&quot; and &quot;most-obvious&quot; item can still confuse someone or be used in unintended ways. Like door handles serving as in-a-pinch dentists for loose teeth.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;This is the best version for sure, but near impossible to achieve.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Enter: Empty State Onboarding&lt;/h2&gt;
&lt;p&gt;The above methods need careful execution and a good app likely needs some combination.&lt;/p&gt;
&lt;p&gt;In my hubris though, I wanted none of that.&lt;/p&gt;
&lt;p&gt;I wanted onboarding that doesn&apos;t get in your way, onboarding that is present only when you&apos;d need it. It should be dismissable yet still accessible afterwards.&lt;/p&gt;
&lt;p&gt;To make it cool I gave it a name: &lt;strong&gt;Empty State Onboarding&lt;/strong&gt;
&amp;lt;label for=&quot;name-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;name-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
This probably already exists with a different name. &amp;lt;a href=&quot;https://xkcd.com/927/&quot;&amp;gt;Now we have 15 standards.&amp;lt;/a&amp;gt;
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;In a nutshell, this onboarding works kind of like placeholders.&lt;/p&gt;
&lt;p&gt;In a place or situation where some action is expected of the user, relevant help information is presented about exactly the next step to take, right where you need to take it.&lt;/p&gt;
&lt;p&gt;In a multi-step flow, each part would entice the next step, which then reveals the next bit of helpful information.&lt;/p&gt;
&lt;h3&gt;Dynamic Empty State Onboarding ~the Reckoning~&lt;/h3&gt;
&lt;p&gt;One step further is persistent Empty State Onboarding on screens that would get filled up from multiple steps that need to be accomplished elsewhere.&lt;/p&gt;
&lt;p&gt;In the case of Mylo, the main page is your training schedule, which starts off empty when you don&apos;t have any workouts.&lt;/p&gt;
&lt;p&gt;Onboarding there asks you to create a workout as step 1 with a button to directly go do that.&lt;/p&gt;
&lt;p&gt;Step 2 below is visible but grayed out.&lt;/p&gt;
&lt;p&gt;Once step 1 is complete, if you come back here, step 2 is now clearer and shows a helpful list of the workout(s) you just made and you can directly add them to your schedule from there.&lt;/p&gt;
&lt;p&gt;For a new user, this shortcuts the action until you get more familiar with the app. Later on, once the new environment is more familiar, you&apos;ll be more comfortable checking out more things.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Education and Dismissal&lt;/h2&gt;
&lt;p&gt;The last step is education.&lt;/p&gt;
&lt;p&gt;These onboarding bits of info can be dismissed, but they re-appear the next time.&lt;/p&gt;
&lt;p&gt;Dismiss it once more and the next time you see it, you can permanently make it go away.&lt;/p&gt;
&lt;p&gt;That means you see it 3 times at least helping you remember the information (or you can dismiss it right away).&lt;/p&gt;
&lt;p&gt;But what if you dismissed it in a hurry, or some time later you have questions?&lt;/p&gt;
&lt;p&gt;For this, I felt an important part of good onboarding is making this information always available later. So in the settings page you can see a &quot;Help &amp;amp; Guides&quot; section where that same onboarding information is present.
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;So this was my approach to onboarding. &amp;lt;label for=&quot;images-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;images-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
I really need to add some images to my blog, would have made this post much easier to understand...
Thank you for getting this far dear reader!
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;I&apos;ll have to see how people actually respond to it, it might fail and I fallback to a more classic approach.&lt;/p&gt;
&lt;p&gt;But! It might work wonders and help new users find their way around a new app without feeling a loss of control or curiosity.&lt;/p&gt;
&lt;p&gt;Empty state onboarding also better matches my values for Mylo. It&apos;s there to help you and support you but without getting in your way or forcing an approach on you.
&amp;lt;/section&amp;gt;&lt;/p&gt;
</content:encoded><author>Maximilien Monteil</author></item><item><title>SVG Favicons?!</title><link>https://www.maxmntl.com/blog/svg-favicons</link><guid isPermaLink="true">https://www.maxmntl.com/blog/svg-favicons</guid><description>TIL favicons can be SVGs and dynamically styled!</description><pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;SVG Favicons?!&lt;/h1&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;p&gt;In the process of making this site I naturally needed a proper favicon.&lt;/p&gt;
&lt;p&gt;So I first went to delete the default one from the Astro generated site and noticed that it was an SVG file. Up till this point I had only ever encountered good old &lt;code&gt;.ico&lt;/code&gt; files (which I only saw elsewhere with windows desktop icons.)
&amp;lt;label for=&quot;favicon-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;favicon-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
As a tangent, favicons are one of these things I know about but rarely ever touch, and once you dig a bit, turns out &amp;lt;a href=&quot;http://johnsalvatier.org/blog/2017/reality-has-a-surprising-amount-of-detail&quot;&amp;gt;favicons have a surprising amount of detail&amp;lt;/a&amp;gt; with different sizes, browser support, situational formats, and more.
&amp;lt;/span&amp;gt;
&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;Astro&apos;s default favicon&lt;/h2&gt;
&lt;p&gt;I open up the file and find yet another surprise, you can &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/style&quot;&gt;insert stylesheets directly into SVGs&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;In hindsight that seems obvious because SVGs already look so &quot;HTML-like&quot;. In the case of the default Astro favicon (and now mine hehe) you can add some styling to dynamically change the favicon based on color scheme.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;svg
  xmlns=&quot;http://www.w3.org/2000/svg&quot;
  fill=&quot;none&quot;
  viewBox=&quot;0 0 128 128&quot;
&amp;gt;
  &amp;lt;rect width=&quot;128&quot; height=&quot;128&quot; rx=&quot;16&quot;/&amp;gt;
  &amp;lt;path d=&quot;...&quot;/&amp;gt;
  &amp;lt;path fill-opacity=&quot;.7&quot; d=&quot;...&quot;/&amp;gt;
  &amp;lt;style&amp;gt;
    rect { fill: #fffff8; }
    path { fill: #111; }
    @media (prefers-color-scheme: dark) {
      rect { fill: #151515; }
      path { fill: #ddd; }
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/svg&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I didn&apos;t look much deeper but I wonder if you get full CSS access in there, you could animate it to start, but with how capable CSS has become, I&apos;m sure there are some crazy things that could be done.&lt;/p&gt;
&lt;h3&gt;Including your SVG favicon&lt;/h3&gt;
&lt;p&gt;To then include it, just make sure to have the correct &lt;code&gt;type&lt;/code&gt; attribute in the link tag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;link
  rel=&quot;icon&quot;
  type=&quot;image/svg+xml&quot;
  href=&quot;/favicon.svg&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/section&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;section&amp;gt;&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;Bonus&lt;/strong&gt;: Fonts can also be SVGs&lt;/h2&gt;
&lt;p&gt;Is there anything they can&apos;t do?
&amp;lt;label for=&quot;svg-power-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;svg-power-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
I read somewhere that SVGs can in fact do pretty much anything. You can include JS, fetch data and then animate some data visualization directly in there. You can propably run DOOM in an SVG.
&amp;lt;/span&amp;gt;&lt;/p&gt;
&lt;p&gt;Now if SVG is a good format for fonts, I can&apos;t say. There&apos;s likely the usual benefits of SVGs being vectors and thus looking sharp at any size, but maybe the file size is worse? Latest recommended font format (for web at least) is WOFF 2: The Reckoning.&lt;/p&gt;
&lt;p&gt;Regardless, after finding out you can style SVGs, and being super happy with &lt;a href=&quot;https://edwardtufte.github.io/tufte-css/&quot;&gt;Tufte CSS&lt;/a&gt;, I wanted to mimick that.&lt;/p&gt;
&lt;p&gt;Classy favicon with &quot;mm&quot;, so to Figma I go.&lt;/p&gt;
&lt;h3&gt;Roadblock 1&lt;/h3&gt;
&lt;p&gt;I have the font I want (&lt;a href=&quot;https://github.com/edwardtufte/tufte-css/tree/gh-pages/et-book&quot;&gt;ET Book&lt;/a&gt;) but can&apos;t use it in Figma unless I install the app or use their font installer which I don&apos;t want to do.&lt;/p&gt;
&lt;p&gt;Luckily, I can open the SVG font in my editor&amp;lt;label for=&quot;sn-nvim&quot; class=&quot;margin-toggle sidenote-number&quot;&amp;gt;&amp;lt;/label&amp;gt; &amp;lt;input type=&quot;checkbox&quot; id=&quot;sn-nvim&quot; class=&quot;margin-toggle&quot;/&amp;gt;&amp;lt;span class=&quot;sidenote&quot;&amp;gt;I use Neovim btw&amp;lt;/span&amp;gt; and find what I&apos;m looking for at &lt;code&gt;glyph unicode=&quot;m&quot;&lt;/code&gt;, perfect.&lt;/p&gt;
&lt;h3&gt;Roadblock 2&lt;/h3&gt;
&lt;p&gt;I&apos;ve got the glyph line but I can&apos;t paste that into Figma, so I try to jam it into an SVG:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- before --&amp;gt;
&amp;lt;glyph
  unicode=&quot;m&quot;
  horiz-adv-x=&quot;1662&quot;
  d=&quot;M35 8q-3 8&quot;
/&amp;gt;

&amp;lt;!-- after --&amp;gt;
&amp;lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot;&amp;gt;
  &amp;lt;path fill=&quot;#000&quot; d=&quot;M35 8q-3 8&quot; /&amp;gt;
&amp;lt;/svg&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Is this valid? Probably not.&lt;/p&gt;
&lt;p&gt;But I could now paste it into Figma, though it was upside down and flipped vertically, likely from the missing &lt;code&gt;viewBox&lt;/code&gt;, but that&apos;s an easy fix.&lt;/p&gt;
&lt;p&gt;So I make my Favicon, pass it through the eternally amazing &lt;a href=&quot;https://svgomg.net/&quot;&gt;SVGOMG&lt;/a&gt;, slap it onto my site and badabing badaboom &lt;em&gt;SSStylish!&lt;/em&gt;&amp;lt;label for=&quot;theme-toggle-tangent&quot; class=&quot;margin-toggle&quot;&amp;gt;⊕&amp;lt;/label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; id=&quot;theme-toggle-tangent&quot; class=&quot;margin-toggle&quot;/&amp;gt;
&amp;lt;span class=&quot;marginnote&quot;&amp;gt;
I should add a light/dark theme toggle...
&amp;lt;br&amp;gt;
&lt;em&gt;Edit: I added one! And it wiggles!!&lt;/em&gt;
&amp;lt;/span&amp;gt;
&amp;lt;/section&amp;gt;&lt;/p&gt;
</content:encoded><author>Maximilien Monteil</author></item></channel></rss>