<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Sarah Thinks]]></title><description><![CDATA[Sarah Thinks]]></description><link>https://sarah-dyne.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 14 Oct 2026 10:59:09 GMT</lastBuildDate><atom:link href="https://sarah-dyne.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Build a Next.js MDX docs site with five dependencies and no framework]]></title><description><![CDATA[Most teams adopt a docs framework before they have a docs problem. Docusaurus, Nextra and Fumadocs are all competent, but each ships its own router, theme layer and build pipeline. If your docs alread]]></description><link>https://sarah-dyne.hashnode.dev/build-a-next-js-mdx-docs-site-with-five-dependencies-and-no-framework</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/build-a-next-js-mdx-docs-site-with-five-dependencies-and-no-framework</guid><category><![CDATA[Next.js]]></category><category><![CDATA[mdx]]></category><category><![CDATA[documentation]]></category><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Wed, 14 Oct 2026 05:23:07 GMT</pubDate><content:encoded><![CDATA[<p>Most teams adopt a docs framework before they have a docs problem. <a href="https://docusaurus.io">Docusaurus</a>, <a href="https://nextra.site">Nextra</a> and <a href="https://fumadocs.vercel.app">Fumadocs</a> are all competent, but each ships its own router, theme layer and build pipeline. If your docs already live inside a Next.js app — next to a marketing site, a dashboard, a product catalog — that is a second copy of infrastructure you already have.</p>
<p>This tutorial builds the alternative: docs as one more route in an existing App Router project. Five dependencies. One catch-all route. One loader file. A sidebar whose order you control by hand. It is the setup the <a href="https://www.applighter.com/docs?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=we-rebuilt-our-docs-site-from-scratch-what-we-kept">Applighter docs</a> run on today, after we ripped out Contentlayer, an auto-generated table of contents, and a framework we had half-adopted.</p>
<p>By the end you will have:</p>
<ul>
<li><p><code>/docs</code> and every nested path served by a single route handler</p>
</li>
<li><p>Content authored as <code>.mdx</code> files, with the folder structure as the only navigation config</p>
</li>
<li><p>VS Code-grade syntax highlighting via Shiki</p>
</li>
<li><p>Every page pre-rendered at build time, with no MDX runtime shipped to the browser</p>
</li>
</ul>
<p>The things you will <em>not</em> have — search, versioning, a right-hand TOC — get their own section at the end, with the conditions under which each one is worth adding.</p>
<h2>Prerequisites</h2>
<ul>
<li><p>Next.js 15 or later with the App Router (the route code below awaits <code>params</code>)</p>
</li>
<li><p>TypeScript</p>
</li>
<li><p>Tailwind is used for class names in the snippets, but nothing depends on it</p>
</li>
</ul>
<h2>1. Install the five dependencies</h2>
<pre><code class="language-bash">npm install next-mdx-remote@^6 remark-gfm@^4 rehype-pretty-code@^0.14 shiki@^4 gray-matter@^4
</code></pre>
<table>
<thead>
<tr>
<th>Package</th>
<th>Job</th>
</tr>
</thead>
<tbody><tr>
<td><code>next-mdx-remote</code></td>
<td>Compiles MDX to React on the server, inside a Server Component</td>
</tr>
<tr>
<td><code>remark-gfm</code></td>
<td>GitHub-flavoured Markdown: tables, task lists, strikethrough</td>
</tr>
<tr>
<td><code>rehype-pretty-code</code></td>
<td>Turns fenced code blocks into highlighted HTML at build time</td>
</tr>
<tr>
<td><code>shiki</code></td>
<td>The highlighter behind it — the same TextMate grammars VS Code uses</td>
</tr>
<tr>
<td><code>gray-matter</code></td>
<td>Splits YAML frontmatter from the body of each file</td>
</tr>
</tbody></table>
<p>There are no <code>next.config</code> changes. <code>next-mdx-remote</code> compiles on the server, so you do not need <code>@next/mdx</code>, a webpack loader, or <code>.mdx</code> registered as a page extension.</p>
<h2>2. Lay out the content folder</h2>
<p>Content lives in <code>content/docs/</code> at the project root. The folder structure <em>is</em> the site structure:</p>
<pre><code class="language-plaintext">content/
└── docs/
    ├── index.mdx                      → /docs
    ├── getting-started/
    │   ├── installation.mdx           → /docs/getting-started/installation
    │   └── first-template.mdx         → /docs/getting-started/first-template
    └── core-concepts/
        ├── project-structure.mdx      → /docs/core-concepts/project-structure
        └── environment-variables.mdx  → /docs/core-concepts/environment-variables
</code></pre>
<p>Each file carries three frontmatter fields and nothing else:</p>
<pre><code class="language-mdx">---
title: Installation
description: Get a template running locally in under five minutes.
order: 1
---

## Clone the template

Run the following in an empty directory...
</code></pre>
<p><code>title</code> becomes the H1 and the <code>&lt;title&gt;</code> tag. <code>description</code> becomes the lead paragraph and the meta description. <code>order</code> positions the page inside its section. Resist adding <code>author</code>, <code>date</code> or <code>category</code> — docs are not blog posts, and every extra field is one more thing that goes stale the first time someone forgets to update it.</p>
<p>Because the URL is derived from the path, nothing else in the repo needs to know a page exists. If you were documenting, say, the <a href="https://www.applighter.com/apps/ai-calorie-tracker?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=we-rebuilt-our-docs-site-from-scratch-what-we-kept">AI Calorie Tracker template</a>, you would create <code>content/docs/templates/ai-calorie-tracker.mdx</code> and be done. No manifest. No <code>_meta.json</code>. No sidebar registry.</p>
<h2>3. Write the loader</h2>
<p>Create <code>lib/docs.ts</code>. This single file is the entire "content layer".</p>
<h3>Types and section config</h3>
<pre><code class="language-typescript">// lib/docs.ts
import fs from "node:fs"
import path from "node:path"
import matter from "gray-matter"

const DOCS_DIR = path.join(process.cwd(), "content/docs")

export type DocFrontmatter = {
  title: string
  description?: string
  order?: number
}

export type Doc = {
  slug: string[]
  frontmatter: DocFrontmatter
  content: string
}

export type NavItem = { title: string; href: string; order: number }
export type NavSection = { title: string; order: number; items: NavItem[] }

const sectionConfig: Record&lt;string, { title: string; order: number }&gt; = {
  "": { title: "Overview", order: 0 },
  "getting-started": { title: "Getting Started", order: 1 },
  "core-concepts": { title: "Core Concepts", order: 2 },
}
</code></pre>
<p><code>sectionConfig</code> is the one piece of navigation you write by hand, and that is deliberate. Alphabetical sidebars put "Core Concepts" before "Getting Started" because C sorts before G. Readers do not care how letters sort. The map above is where human reading order lives.</p>
<p>Note the fallback that comes later: a section folder with no entry in this map lands at order 99 — the bottom of the sidebar. That is an intentionally awkward failure mode. Whoever adds a section is forced to also decide where it belongs.</p>
<h3>Walk the filesystem</h3>
<pre><code class="language-typescript">function walkDocs(dir: string): string[] {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) =&gt; {
    const full = path.join(dir, entry.name)
    if (entry.isDirectory()) return walkDocs(full)
    return entry.name.endsWith(".mdx") ? [full] : []
  })
}

function toSlug(file: string): string[] {
  const parts = path
    .relative(DOCS_DIR, file)
    .replace(/\.mdx$/, "")
    .split(path.sep)
  if (parts[parts.length - 1] === "index") parts.pop()
  return parts
}

function resolveDocFile(slug: string[]): string | null {
  const base = path.join(DOCS_DIR, ...slug)
  if (!base.startsWith(DOCS_DIR)) return null // refuse path traversal
  for (const candidate of [`${base}.mdx`, path.join(base, "index.mdx")]) {
    if (fs.existsSync(candidate)) return candidate
  }
  return null
}
</code></pre>
<p><code>toSlug</code> strips the extension and treats <code>index.mdx</code> as its parent folder, so <code>content/docs/index.mdx</code> becomes <code>[]</code>. <code>resolveDocFile</code> does the reverse: given a slug, it checks for <code>slug.mdx</code> first, then <code>slug/index.mdx</code>.</p>
<h3>Public API</h3>
<pre><code class="language-typescript">export function getAllDocPaths(): string[][] {
  return walkDocs(DOCS_DIR).map(toSlug)
}

export function getDocBySlug(slug: string[]): Doc | null {
  const file = resolveDocFile(slug)
  if (!file) return null
  const raw = fs.readFileSync(file, "utf8")
  const { data, content } = matter(raw)
  return { slug, frontmatter: data as DocFrontmatter, content }
}
</code></pre>
<p>That is the complete replacement for Contentlayer: read a file, split frontmatter from body, return both. No generated <code>.contentlayer/</code> directory, no extra build step, no typed cache that can disagree with what is on disk.</p>
<h3>Build the nav tree</h3>
<pre><code class="language-typescript">export function getNavTree(): NavSection[] {
  const sections = new Map&lt;string, NavSection&gt;()

  for (const slug of getAllDocPaths()) {
    const doc = getDocBySlug(slug)
    if (!doc) continue

    const key = slug.length &gt; 1 ? slug[0] : ""
    const config = sectionConfig[key] ?? { title: key.replace(/-/g, " "), order: 99 }
    const section = sections.get(key) ?? { ...config, items: [] }

    section.items.push({
      title: doc.frontmatter.title,
      href: slug.length ? `/docs/${slug.join("/")}` : "/docs",
      order: doc.frontmatter.order ?? 99,
    })
    sections.set(key, section)
  }

  return [...sections.values()]
    .map((s) =&gt; ({ ...s, items: s.items.sort((a, b) =&gt; a.order - b.order) }))
    .sort((a, b) =&gt; a.order - b.order)
}
</code></pre>
<p>Two things worth knowing. First, this reads every file to get its title, which is fine at build time for dozens of pages; past a couple of hundred, wrap <code>getNavTree</code> in React's <code>cache()</code> so it runs once per build instead of once per page. Second, section folders do not get their own landing page in this setup — the first ordered page in each section is the overview. If you want <code>getting-started/index.mdx</code> to render as a section landing page, key the section by the file's directory instead of <code>slug[0]</code>.</p>
<h2>4. Add the catch-all route</h2>
<pre><code class="language-tsx">// app/docs/[[...slug]]/page.tsx
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { getAllDocPaths, getDocBySlug, getNavTree } from "@/lib/docs"
import { DocLayout } from "@/components/docs/doc-layout"

type Props = { params: Promise&lt;{ slug?: string[] }&gt; }

// Only the paths returned by generateStaticParams exist. Anything else is a 404.
export const dynamicParams = false

export function generateStaticParams() {
  return getAllDocPaths().map((slug) =&gt; ({ slug }))
}

export async function generateMetadata({ params }: Props): Promise&lt;Metadata&gt; {
  const { slug = [] } = await params
  const doc = getDocBySlug(slug)
  if (!doc) return {}
  return {
    title: doc.frontmatter.title,
    description: doc.frontmatter.description,
  }
}

export default async function DocPage({ params }: Props) {
  const { slug = [] } = await params
  const doc = getDocBySlug(slug)
  if (!doc) notFound()
  return &lt;DocLayout doc={doc} nav={getNavTree()} /&gt;
}
</code></pre>
<p><code>[[...slug]]</code> is Next.js's <em>optional</em> catch-all. The double brackets matter: a single-bracket <code>[...slug]</code> would not match the bare <code>/docs</code> URL. With the optional form, <code>/docs</code> arrives as <code>slug: undefined</code> (hence the <code>= []</code> default), and <code>/docs/getting-started/installation</code> arrives as <code>["getting-started", "installation"]</code>.</p>
<p><code>dynamicParams = false</code> is the line that makes this a static site rather than a lazily-rendered one. Without it, a request for a slug that was not pre-rendered would try to render on demand. With it, unknown paths are a 404 and every doc page is a file in <code>.next/</code> produced during <code>next build</code>. If a doc is wrong in production, it is wrong because the main branch merged it wrong — not because a cache is lying.</p>
<h2>5. Render the MDX</h2>
<pre><code class="language-tsx">// components/docs/doc-layout.tsx
import { MDXRemote } from "next-mdx-remote/rsc"
import remarkGfm from "remark-gfm"
import rehypePrettyCode from "rehype-pretty-code"
import type { Doc, NavSection } from "@/lib/docs"
import { Sidebar } from "./sidebar"
import { mdxComponents } from "./mdx-components"

export function DocLayout({ doc, nav }: { doc: Doc; nav: NavSection[] }) {
  return (
    &lt;div className="mx-auto flex max-w-6xl gap-10 px-4 py-10"&gt;
      &lt;Sidebar sections={nav} /&gt;
      &lt;article className="prose prose-invert min-w-0 flex-1"&gt;
        &lt;h1&gt;{doc.frontmatter.title}&lt;/h1&gt;
        {doc.frontmatter.description &amp;&amp; (
          &lt;p className="lead"&gt;{doc.frontmatter.description}&lt;/p&gt;
        )}
        &lt;MDXRemote
          source={doc.content}
          components={mdxComponents}
          options={{
            mdxOptions: {
              remarkPlugins: [remarkGfm],
              rehypePlugins: [[rehypePrettyCode, { theme: "github-dark-dimmed" }]],
            },
          }}
        /&gt;
      &lt;/article&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<p><code>next-mdx-remote/rsc</code> is the important import. The <code>/rsc</code> entry point compiles MDX inside the Server Component, during <code>next build</code>. What ships to the browser is the resulting HTML — no MDX compiler, no runtime evaluation, no client-side highlighting pass.</p>
<p>The theme is pinned to <code>github-dark-dimmed</code> rather than inherited from a toggle. Shiki can emit dual-theme output, but a second theme is a second set of colour bugs to debug, and we found the benefit did not justify it.</p>
<p>One CSS rule you will want from day one, because docs get read on phones more often than build logs suggest:</p>
<pre><code class="language-css">/* app/globals.css */
[data-rehype-pretty-code-figure] pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
</code></pre>
<p>Horizontal scroll inside a code block on a 375px screen is a small act of hostility. Wrap instead.</p>
<h2>6. Build the sidebar</h2>
<pre><code class="language-tsx">// components/docs/sidebar.tsx
import Link from "next/link"
import type { NavSection } from "@/lib/docs"

export function Sidebar({ sections }: { sections: NavSection[] }) {
  return (
    &lt;nav aria-label="Docs" className="hidden w-56 shrink-0 md:block"&gt;
      {sections.map((section) =&gt; (
        &lt;div key={section.title} className="mb-6"&gt;
          &lt;p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500"&gt;
            {section.title}
          &lt;/p&gt;
          &lt;ul className="space-y-1"&gt;
            {section.items.map((item) =&gt; (
              &lt;li key={item.href}&gt;
                &lt;Link href={item.href} className="text-sm hover:underline"&gt;
                  {item.title}
                &lt;/Link&gt;
              &lt;/li&gt;
            ))}
          &lt;/ul&gt;
        &lt;/div&gt;
      ))}
    &lt;/nav&gt;
  )
}
</code></pre>
<p>It receives the tree from the route handler, so it stays a Server Component. If you want the current page highlighted, split the list into a small client component that calls <code>usePathname()</code> and compares against <code>item.href</code>. The sidebar is hidden below <code>md</code>; a mobile menu is a product decision, not a docs-stack one, so it is out of scope here.</p>
<h2>7. Override the MDX components</h2>
<pre><code class="language-tsx">// components/docs/mdx-components.tsx
import type { ComponentPropsWithoutRef } from "react"
import Link from "next/link"

export const mdxComponents = {
  a: ({ href = "", ...props }: ComponentPropsWithoutRef&lt;"a"&gt;) =&gt;
    href.startsWith("/") ? (
      &lt;Link href={href} {...props} /&gt;
    ) : (
      &lt;a href={href} target="_blank" rel="noreferrer" {...props} /&gt;
    ),

  // rehype-pretty-code stamps data-language on block &lt;code&gt;; inline &lt;code&gt; has none.
  code: (props: ComponentPropsWithoutRef&lt;"code"&gt;) =&gt;
    "data-language" in props ? (
      &lt;code {...props} /&gt;
    ) : (
      &lt;code {...props} className="rounded bg-zinc-800 px-1.5 py-0.5 text-[0.9em]" /&gt;
    ),
}
</code></pre>
<p>Two overrides, two reasons. Internal links go through <code>next/link</code> so navigation between docs pages is a client-side transition rather than a full reload. And <code>rehype-pretty-code</code> applies its own styling to code elements; the branch on <code>data-language</code> leaves fenced blocks alone and gives inline code your own classes. Without that branch, inline backticks pick up the highlighter's styling and fight whatever typography plugin you are using.</p>
<p>This file is also where any custom MDX components go — a <code>&lt;Callout&gt;</code>, a <code>&lt;PricingTable&gt;</code>, whatever your pages need. That is the reason to keep MDX over plain Markdown even when most pages never use a component: the one page that does should not have to switch dialects.</p>
<h2>8. Build and verify</h2>
<pre><code class="language-bash">npm run build
</code></pre>
<p>Every <code>/docs/...</code> path should appear in the build output as a pre-rendered page. From here, the maintenance model is the filesystem:</p>
<ul>
<li><p>Add a page: create a <code>.mdx</code> file in the right folder.</p>
</li>
<li><p>Remove a page: delete the file.</p>
</li>
<li><p>Move a page: <code>git mv</code> the file. The URL follows.</p>
</li>
<li><p>Reorder a section: change <code>order</code> in frontmatter.</p>
</li>
<li><p>Add a section: create a folder, then add it to <code>sectionConfig</code> before it embarrasses you at order 99.</p>
</li>
</ul>
<p>The git log of <code>content/docs/</code> is the docs changelog. Nothing else records it, and nothing else needs to.</p>
<h2>What you deliberately do not have</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Add it when</th>
</tr>
</thead>
<tbody><tr>
<td>Full-text search</td>
<td>None</td>
<td>Readers arrive from Google already deep in the tree; add Pagefind when analytics show in-site hunting. It indexes the static output and needs no server.</td>
</tr>
<tr>
<td>Right-hand TOC</td>
<td>None</td>
<td>Pages grow long enough that scrolling is slower than scanning. Add <code>rehype-slug</code> + <code>rehype-autolink-headings</code> for anchors at the same time.</td>
</tr>
<tr>
<td>Versioning</td>
<td>None</td>
<td>You ship a V2 that breaks the V1 API surface. Versioning a one-version product is overhead with no reader.</td>
</tr>
<tr>
<td>Live code playground</td>
<td>None</td>
<td>Probably never. A sandboxed runtime plus an API proxy costs more over three years than copy-paste snippets ever will.</td>
</tr>
<tr>
<td>i18n</td>
<td>None</td>
<td>You have more pages than you can translate by hand.</td>
</tr>
</tbody></table>
<p>Each of these is a real feature that a framework hands you on day one. The point of this setup is not that they are bad — it is that paying their maintenance cost before you need them is how a docs site becomes something nobody on the team fully understands.</p>
<h2>When this is the wrong call</h2>
<ul>
<li><p><strong>500+ pages.</strong> Hand-authoring section order for a tree that size is masochism. Take the framework.</p>
</li>
<li><p><strong>Non-developer writers.</strong> A CMS or GitBook-style editor earns its keep the moment someone without a terminal needs to ship a correction.</p>
</li>
<li><p><strong>Versioning on day one.</strong> Docusaurus has solved this. Do not re-solve it.</p>
</li>
<li><p><strong>Docs are a standalone site.</strong> If there is no existing Next.js app to live inside, a framework's second router is not a second router — it is the only one.</p>
</li>
</ul>
<h2>Why bother: the surface-area argument</h2>
<p>The honest answer is that this setup does not make the docs better for readers. Content is the same. Styling is roughly the same. What changes is whether the team can audit the docs.</p>
<p>Our old docs had a section describing an integration the product did not actually ship. It had been written aspirationally and published alongside the real pages, and it stayed live for months. Nobody caught it — not because nobody read it, but because the system around it had grown too large for anyone to hold in their head and notice. After the rebuild, the docs mirror the templates that are actually in the <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=we-rebuilt-our-docs-site-from-scratch-what-we-kept">Applighter catalog</a>, and every rendering path is one file we can open.</p>
<p>Docs stay honest only when someone can notice them going stale. Roughly two hundred lines is a size at which that is possible.</p>
<h2>Next steps</h2>
<p>If you want to go further with the same small-surface-area philosophy on the mobile side, the <a href="https://www.applighter.com/blog/react-native-boilerplate?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=we-rebuilt-our-docs-site-from-scratch-what-we-kept">React Native boilerplate guide</a> applies it to an Expo project: filesystem-first routing, no generator magic, every dependency justified.</p>
<p>And if you build docs this way and hit a wall I have not covered — a mobile nav pattern, a Pagefind integration, dual-theme Shiki — leave a comment. The follow-up post writes itself from the questions.</p>
<h2>References</h2>
<ul>
<li><p><a href="https://nextjs.org/docs/app">Next.js App Router</a> — routing, <code>generateStaticParams</code>, <code>dynamicParams</code></p>
</li>
<li><p><a href="https://github.com/hashicorp/next-mdx-remote">next-mdx-remote</a> — the <code>/rsc</code> entry point and <code>options.mdxOptions</code></p>
</li>
<li><p><a href="https://rehype-pretty.pages.dev/">rehype-pretty-code</a> — theme options and the <code>data-*</code> attributes it emits</p>
</li>
<li><p><a href="https://shiki.style/">Shiki</a> — bundled themes and grammars</p>
</li>
<li><p><a href="https://mdxjs.com/docs/getting-started/">MDX</a> — syntax and component usage</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Edge Functions Explained: A Practical Guide with Real Examples]]></title><description><![CDATA[Most explanations of edge functions stop at "your code runs closer to the user." That is true, and it is also the least useful thing to know when you are deciding whether to put one in production.
Thi]]></description><link>https://sarah-dyne.hashnode.dev/edge-functions-explained-a-practical-guide-with-real-examples</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/edge-functions-explained-a-practical-guide-with-real-examples</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[serverless]]></category><category><![CDATA[edgecomputing]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[webdevelopment]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Tue, 13 Oct 2026 05:47:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/e6d1311c-d68d-4c4a-921f-b80a402124d3.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most explanations of edge functions stop at "your code runs closer to the user." That is true, and it is also the least useful thing to know when you are deciding whether to put one in production.</p>
<p>This guide covers what edge functions actually are, how the runtime differs from Node.js, four patterns you can copy today, and the one architectural mistake that makes edge functions slower than the regional server they replaced.</p>
<p>The examples use Cloudflare Workers syntax. The same ideas apply to Vercel Edge Functions, Deno Deploy, Netlify Edge Functions, and Supabase Edge Functions with small changes.</p>
<h2>What is an edge function?</h2>
<p>An edge function is a small piece of server-side code that is deployed to many locations around the world at once. When a request arrives, the network routes it to the nearest location and runs your code there.</p>
<p>Compare that with a traditional backend:</p>
<ul>
<li><p><strong>Regional server or serverless function:</strong> your code lives in one region, for example <code>ap-south-1</code>. A user in Sydney waits for the request to travel to Mumbai and back.</p>
</li>
<li><p><strong>Edge function:</strong> your code lives in hundreds of locations. The Sydney user is served from Sydney.</p>
</li>
</ul>
<p>The network round trip to a distant region can cost 200 to 300 ms before your code runs a single line. Edge functions remove most of that.</p>
<h2>How the runtime works: isolates, not containers</h2>
<p>Traditional serverless platforms start a container or micro-VM for your function. If no warm instance exists, the user waits for a cold start, which can take hundreds of milliseconds.</p>
<p>Most edge platforms use <strong>V8 isolates</strong> instead. An isolate is the same sandbox that separates tabs in Chrome. Thousands of isolates can share one process, and a new one starts in a few milliseconds.</p>
<p>This design has two consequences:</p>
<ol>
<li><p><strong>Cold starts are close to zero.</strong> You can run code on every request without a latency penalty.</p>
</li>
<li><p><strong>You do not get Node.js.</strong> You get a runtime built on Web standard APIs: <code>Request</code>, <code>Response</code>, <code>fetch</code>, <code>URL</code>, <code>crypto.subtle</code>, and streams. Packages that depend on native modules or on <code>fs</code> will not work. Node compatibility layers exist on most platforms, but they are partial.</p>
</li>
</ol>
<h2>Your first edge function</h2>
<pre><code class="language-javascript">export default {
  async fetch(request, env, ctx) {
    const country = request.cf?.country ?? "unknown";

    return Response.json({
      message: "Hello from the edge",
      country,
    });
  },
};
</code></pre>
<p>A function receives a <code>Request</code> and returns a <code>Response</code>. That is the whole contract. <code>request.cf</code> is Cloudflare-specific and carries geolocation data; other platforms expose the same information through headers.</p>
<h2>Edge functions vs serverless functions</h2>
<table>
<thead>
<tr>
<th></th>
<th>Serverless functions</th>
<th>Edge functions</th>
</tr>
</thead>
<tbody><tr>
<td>Location</td>
<td>One region you choose</td>
<td>Many locations worldwide</td>
</tr>
<tr>
<td>Isolation</td>
<td>Container or micro-VM</td>
<td>V8 isolate</td>
</tr>
<tr>
<td>Cold start</td>
<td>Tens to hundreds of ms</td>
<td>A few ms</td>
</tr>
<tr>
<td>Runtime</td>
<td>Full Node.js, Python, Go, Java</td>
<td>Web standard APIs, partial Node support</td>
</tr>
<tr>
<td>CPU and memory</td>
<td>Generous</td>
<td>Strict limits</td>
</tr>
<tr>
<td>Max duration</td>
<td>Minutes</td>
<td>Seconds of CPU time</td>
</tr>
<tr>
<td>Best for</td>
<td>Heavy computation, long jobs, native dependencies</td>
<td>Short, latency-sensitive logic on every request</td>
</tr>
</tbody></table>
<p>These are not competing options. Most production systems use both: edge functions in front, regional functions or servers behind.</p>
<h2>Four patterns worth copying</h2>
<h3>1. Verify auth before the request reaches your origin</h3>
<p>Rejecting bad requests at the edge saves origin capacity and gives unauthenticated users a fast answer.</p>
<pre><code class="language-javascript">import { jwtVerify } from "jose";

export default {
  async fetch(request, env) {
    const header = request.headers.get("Authorization") ?? "";
    const token = header.replace("Bearer ", "");

    if (!token) {
      return new Response("Unauthorized", { status: 401 });
    }

    try {
      const secret = new TextEncoder().encode(env.JWT_SECRET);
      const { payload } = await jwtVerify(token, secret);

      // Forward to the origin with the verified user id attached
      const forwarded = new Request(request);
      forwarded.headers.set("x-user-id", payload.sub);
      return fetch(forwarded);
    } catch {
      return new Response("Invalid token", { status: 401 });
    }
  },
};
</code></pre>
<p><code>jose</code> works here because it is built on the Web Crypto API. The popular <code>jsonwebtoken</code> package depends on Node's <code>crypto</code> module and is a common first stumbling block.</p>
<h3>2. Proxy third-party APIs to keep keys out of your app</h3>
<p>Any key you ship inside a mobile or web bundle can be extracted. An edge function is the cheapest place to hide it.</p>
<pre><code class="language-javascript">export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const { prompt } = await request.json();

    const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model: "gpt-6-luna",
        messages: [{ role: "user", content: prompt }],
      }),
    });

    return new Response(upstream.body, {
      status: upstream.status,
      headers: { "Content-Type": "application/json" },
    });
  },
};
</code></pre>
<p>The client only ever sees your endpoint:</p>
<pre><code class="language-javascript">const res = await fetch("https://api.example.com/ai", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ prompt: "Summarise my notes" }),
});
const data = await res.json();
</code></pre>
<p>This pattern matters most for React Native and Expo apps, where there is no server by default. If you generate your app with <a href="https://rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=edge-functions-explained">RapidNative</a>, put a proxy like this in front of every paid API before you ship. Add rate limiting per user as well, because a hidden key behind an open endpoint is still an open wallet.</p>
<h3>3. A/B testing without flicker</h3>
<p>Client-side experiments load the page and then swap content, which users see as a flash. Deciding the variant at the edge avoids it.</p>
<pre><code class="language-javascript">export default {
  async fetch(request) {
    const cookie = request.headers.get("Cookie") ?? "";
    let variant = cookie.match(/variant=(a|b)/)?.[1];
    const isNew = !variant;

    if (isNew) {
      variant = Math.random() &lt; 0.5 ? "a" : "b";
    }

    const url = new URL(request.url);
    url.pathname = `/${variant}${url.pathname}`;

    const response = await fetch(new Request(url, request));
    const result = new Response(response.body, response);

    if (isNew) {
      result.headers.append(
        "Set-Cookie",
        `variant=${variant}; Path=/; Max-Age=2592000`
      );
    }

    return result;
  },
};
</code></pre>
<h3>4. Cache API responses close to the user</h3>
<pre><code class="language-javascript">export default {
  async fetch(request, env, ctx) {
    const cache = caches.default;

    let response = await cache.match(request);
    if (response) return response;

    response = await fetch(request);
    response = new Response(response.body, response);
    response.headers.set("Cache-Control", "public, max-age=60");

    ctx.waitUntil(cache.put(request, response.clone()));
    return response;
  },
};
</code></pre>
<p><code>ctx.waitUntil</code> lets the cache write finish after the response has been sent, so the user does not wait for it.</p>
<h2>The mistake that makes edge functions slower</h2>
<p>Here is the part most tutorials skip. Your function runs in Sydney. Your Postgres database runs in Virginia. Every query crosses the Pacific.</p>
<p>If a handler makes three sequential queries, you have paid for three long round trips instead of one. A regional function sitting next to the database would have been faster.</p>
<p>There are four ways to deal with this:</p>
<ul>
<li><p><strong>Cache reads at the edge.</strong> Use the Cache API or a key-value store for data that tolerates being slightly stale.</p>
</li>
<li><p><strong>Use a globally distributed data store.</strong> Edge KV stores, replicated SQLite, or Postgres read replicas put the data near the function.</p>
</li>
<li><p><strong>Use an HTTP-based driver or connection pooler.</strong> Isolates cannot hold long-lived TCP connection pools the way a server can. Without a pooler you can exhaust database connections quickly.</p>
</li>
<li><p><strong>Move the function to the data.</strong> Several platforms let you pin a function to a region. For write-heavy endpoints this is often the right answer.</p>
</li>
</ul>
<p>A simple rule: if a handler makes more than one uncached database round trip, measure it before you assume the edge is faster.</p>
<h2>Other limitations to plan for</h2>
<ul>
<li><p><strong>CPU time limits.</strong> Image processing, PDF generation, and large JSON transforms can hit the ceiling.</p>
</li>
<li><p><strong>Bundle size limits.</strong> Large dependencies may not fit.</p>
</li>
<li><p><strong>Package compatibility.</strong> Check that a library supports edge runtimes before you design around it.</p>
</li>
<li><p><strong>Observability.</strong> Code running in hundreds of locations needs structured logs and a request id on every line.</p>
</li>
</ul>
<h2>When to use edge functions</h2>
<p>Use them when the logic is:</p>
<ul>
<li><p>short,</p>
</li>
<li><p>run on most requests,</p>
</li>
<li><p>sensitive to latency, and</p>
</li>
<li><p>not dependent on a far-away database.</p>
</li>
</ul>
<p>Auth checks, redirects, geolocation, personalisation, API proxying, webhooks, and caching all fit.</p>
<p>Use regional serverless functions or a server for heavy computation, long-running jobs, native dependencies, and transactional database work.</p>
<h2>Shipping it</h2>
<p>For a mobile app, the practical stack is usually a client, a thin edge layer for auth and proxying, and a regional backend for data. If you would rather not wire that together by hand, <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=edge-functions-explained">RapidNative Deploy</a> handles shipping the app and its backend from one place, and a starter kit such as <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=edge-functions-explained">AppLighter</a> gives you a pre-configured Expo project to start from.</p>
<h2>Summary</h2>
<ul>
<li><p>Edge functions run your code in many locations, on V8 isolates, with almost no cold start.</p>
</li>
<li><p>The runtime is Web standard APIs, not Node.js. Check your dependencies.</p>
</li>
<li><p>They are ideal for auth, proxying, A/B tests, and caching.</p>
</li>
<li><p>They do not fix a slow database path. If your data is in one region, cache it, replicate it, or run the function next to it.</p>
</li>
<li><p>Use edge and regional functions together, each for the work it suits.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[ASO Screenshots That Actually Convert: A Developer's Playbook for 2026]]></title><description><![CDATA[Most developers treat store screenshots as the last chore before hitting "Submit for Review." You've spent weeks on the app, so you open the simulator, grab five screens, drop them into App Store Conn]]></description><link>https://sarah-dyne.hashnode.dev/aso-screenshots-that-actually-convert-a-developer-s-playbook-for-2026</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/aso-screenshots-that-actually-convert-a-developer-s-playbook-for-2026</guid><category><![CDATA[React Native]]></category><category><![CDATA[mobile app development]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Mon, 12 Oct 2026 10:40:40 GMT</pubDate><content:encoded><![CDATA[<p>Most developers treat store screenshots as the last chore before hitting "Submit for Review." You've spent weeks on the app, so you open the simulator, grab five screens, drop them into App Store Connect, and move on.</p>
<p>That's a mistake. Your screenshots are the single biggest lever you have on <strong>conversion rate</strong>: the percentage of people who see your listing and actually tap "Get" or "Install." Keywords get people <em>to</em> the page. Screenshots decide whether they stay.</p>
<p>This guide covers the current specs for both stores, how to structure a screenshot set that sells, and how to automate the whole thing so you're not redoing it by hand every release.</p>
<h2>Why screenshots matter more than your description</h2>
<p>Think about how people actually browse a store:</p>
<ol>
<li><p>They search a keyword.</p>
</li>
<li><p>They see a results list where each app shows its icon, name, rating, and the <strong>first few screenshots</strong>.</p>
</li>
<li><p>Most decide right there, without ever opening your listing.</p>
</li>
</ol>
<p>That means your first 2–3 screenshots are doing the work of an ad, and your description is something a small minority will read after they're already interested. If your first screenshot is a login screen, you've lost the people who never scrolled.</p>
<p>The shift in thinking: <strong>screenshots are marketing assets, not documentation.</strong> Their job isn't to show every feature. Their job is to answer "what is this and why should I care?" in about the time it takes to scroll past.</p>
<h2>Current specs: what you actually need to upload</h2>
<p>The good news is that both stores have simplified. You no longer need a separate set for every device size.</p>
<h3>Apple App Store</h3>
<table>
<thead>
<tr>
<th>Device class</th>
<th>Portrait size (px)</th>
<th>Required?</th>
</tr>
</thead>
<tbody><tr>
<td>6.9" iPhone</td>
<td>1320 × 2868 (also accepts 1290 × 2796, 1260 × 2736)</td>
<td>Yes, if your app runs on iPhone</td>
</tr>
<tr>
<td>13" iPad</td>
<td>2064 × 2752 (also accepts 2048 × 2732)</td>
<td>Yes, if your app runs on iPad</td>
</tr>
</tbody></table>
<ul>
<li><p>Upload the largest size and App Store Connect scales it down for smaller devices.</p>
</li>
<li><p>1–10 screenshots per device class, per localization.</p>
</li>
<li><p>Formats: <code>.png</code>, <code>.jpg</code>, <code>.jpeg</code>.</p>
</li>
</ul>
<h3>Google Play</h3>
<table>
<thead>
<tr>
<th>Asset</th>
<th>Size</th>
<th>Required?</th>
</tr>
</thead>
<tbody><tr>
<td>Phone screenshots</td>
<td>Each side 320–3840 px, long side ≤ 2× short side. 1080 × 1920 is the common choice</td>
<td>Minimum 2 to publish</td>
</tr>
<tr>
<td>Feature graphic</td>
<td>Exactly 1024 × 500 px, JPEG or 24-bit PNG (no alpha)</td>
<td>Yes</td>
</tr>
<tr>
<td>App icon</td>
<td>512 × 512 px</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>The feature graphic trips people up because Apple has no equivalent. It shows up in promotional placements and becomes your video thumbnail if you add a preview video, so keep the focal point centered and avoid fine detail.</p>
<blockquote>
<p><strong>Tip:</strong> Store specs change quietly. Verify against App Store Connect Help and the Play Console Help center before every major release.</p>
</blockquote>
<h2>The anatomy of a high-converting screenshot set</h2>
<p>Here's a structure that works for most utility and productivity apps. Adapt it, but have a reason for every slot.</p>
<h3>Screenshot 1: The core promise</h3>
<p>One sentence, one hero screen. This is the screenshot that shows up in search results, so it has to stand on its own.</p>
<ul>
<li><p>❌ "Welcome to TaskFlow"</p>
</li>
<li><p>✅ "Plan your whole week in 2 minutes"</p>
</li>
</ul>
<p>Lead with the <strong>outcome</strong> the user gets, not the feature name.</p>
<h3>Screenshots 2–3: The top differentiators</h3>
<p>What makes you different from the five other apps in the same search result? Show those things, one per screenshot. If you can't name a differentiator, that's a product problem, not a screenshot problem.</p>
<h3>Screenshots 4–6: Supporting features</h3>
<p>Now you can show breadth: integrations, widgets, dark mode, offline support. People who scroll this far are already interested, so you can be a little more detailed here.</p>
<h3>Final screenshot: Trust or a closer</h3>
<p>Social proof (an award, a press mention you're allowed to use), a privacy promise, or a "works with" row of integrations.</p>
<h2>Design rules that make or break readability</h2>
<p>Screenshots are viewed as thumbnails on a phone. Design for that size, not for your 27" monitor.</p>
<p><strong>Caption text</strong></p>
<ul>
<li><p>Keep captions to roughly 3–7 words.</p>
</li>
<li><p>Use a large, heavy font. If you can't read it at thumbnail size, it doesn't exist.</p>
</li>
<li><p>Put captions in the same position on every screenshot so the eye knows where to look.</p>
</li>
</ul>
<p><strong>The app UI</strong></p>
<ul>
<li><p>Show real UI with realistic data. Empty states and "Lorem ipsum" look unfinished.</p>
</li>
<li><p>Crop or zoom into the part of the screen that matters. A full-screen capture of a settings page tells the user nothing.</p>
</li>
<li><p>Hide the clutter: clean status bar (full battery, strong signal, a neat time like 9:41), no debug banners.</p>
</li>
</ul>
<p><strong>Visual consistency</strong></p>
<ul>
<li><p>Pick a background color system that matches your icon and brand.</p>
</li>
<li><p>Panoramic sets (one continuous background spanning screenshots) look polished in the gallery, but make sure screenshot 1 still works alone in search results, where it's cropped from its neighbors.</p>
</li>
</ul>
<p><strong>Accuracy</strong></p>
<ul>
<li>Both stores expect screenshots to reflect the real app experience. Don't show features that don't exist yet, and don't fake UI.</li>
</ul>
<h2>Automating screenshot capture</h2>
<p>If you rebuild screenshots by hand every release, they'll drift out of date. Automate the capture step so updating them is a single command.</p>
<h3>Option 1: Maestro flows (React Native / Expo / native)</h3>
<p>Maestro lets you script the app into the exact state you want, then capture it:</p>
<pre><code class="language-yaml"># .maestro/screenshots.yaml
appId: com.yourcompany.taskflow
---
- launchApp:
    clearState: true
- tapOn: "Skip onboarding"
- tapOn: "This week"
- takeScreenshot: 01-week-view
- tapOn: "Add task"
- inputText: "Ship v2.0 release"
- takeScreenshot: 02-add-task
- tapOn: "Insights"
- takeScreenshot: 03-insights
</code></pre>
<p>Run it against a 6.9" simulator and a 1080 × 1920 Android emulator, and you have raw captures for both stores.</p>
<h3>Option 2: fastlane snapshot (iOS) and screengrab (Android)</h3>
<p>If you already use fastlane for releases, <code>snapshot</code> and <code>screengrab</code> drive UI tests across devices and languages:</p>
<pre><code class="language-ruby"># fastlane/Snapfile
devices([
  "iPhone 17 Pro Max",
  "iPad Pro 13-inch (M5)"
])
languages(["en-US", "de-DE", "ja"])
output_directory("./fastlane/screenshots")
clear_previous_screenshots(true)
</code></pre>
<h3>Seed realistic demo data</h3>
<p>The most common reason screenshots look bad is empty or ugly data. Add a demo-data mode that's only enabled for screenshot runs:</p>
<pre><code class="language-javascript">// src/config/demoData.js
export const DEMO_MODE = process.env.EXPO_PUBLIC_DEMO_MODE === "true";

export const demoTasks = [
  { id: 1, title: "Ship v2.0 release", due: "Today", priority: "high" },
  { id: 2, title: "Review design specs", due: "Tomorrow", priority: "medium" },
  { id: 3, title: "Plan Q4 roadmap", due: "Fri", priority: "low" },
];
</code></pre>
<p>If you're prototyping the screens themselves, a tool like <a href="https://rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=aso-screenshots-guide">RapidNative</a> can get you to polished React Native UI quickly, which means you have something worth screenshotting earlier in the process.</p>
<h3>Validate dimensions before upload</h3>
<p>A rejected upload at 11pm on release night is avoidable. A small Node script with <code>sharp</code> catches size mistakes:</p>
<pre><code class="language-javascript">// scripts/check-screenshots.js
import sharp from "sharp";
import { readdir } from "node:fs/promises";
import path from "node:path";

const IOS_IPHONE = [[1320, 2868], [1290, 2796], [1260, 2736]];

function validAndroid(w, h) {
  const short = Math.min(w, h);
  const long = Math.max(w, h);
  return short &gt;= 320 &amp;&amp; long &lt;= 3840 &amp;&amp; long &lt;= short * 2;
}

async function check(dir, store) {
  const files = (await readdir(dir)).filter((f) =&gt; /\.(png|jpe?g)$/i.test(f));
  for (const file of files) {
    const { width, height } = await sharp(path.join(dir, file)).metadata();
    const ok =
      store === "ios"
        ? IOS_IPHONE.some(([w, h]) =&gt; w === width &amp;&amp; h === height)
        : validAndroid(width, height);
    console.log(`${ok ? "✅" : "❌"} ${store} ${file} (${width}×${height})`);
  }
}

await check("./screenshots/ios", "ios");
await check("./screenshots/android", "android");
</code></pre>
<p>Add it to CI and you'll never upload a wrong-sized file again.</p>
<h2>Localization: the cheapest conversion win</h2>
<p>Screenshots are localized per language on both stores. If you've translated your app but kept English screenshots, international visitors see captions they may not read.</p>
<p>You don't need to localize everything on day one:</p>
<ol>
<li><p>Check your store analytics for the top 3–5 countries by page views.</p>
</li>
<li><p>Translate captions for those languages first.</p>
</li>
<li><p>Re-run your automated capture with the device locale switched, so the UI inside the screenshots is localized too.</p>
</li>
</ol>
<p>Because the capture step is scripted, adding a language is mostly a translation task, not a design task.</p>
<h2>Test, don't guess</h2>
<p>Your opinion of which screenshot is best is a hypothesis. Both stores let you test it:</p>
<ul>
<li><p><strong>Apple:</strong> Product Page Optimization lets you test alternate screenshot sets against your default page.</p>
</li>
<li><p><strong>Google Play:</strong> Store listing experiments let you A/B test graphics and text.</p>
</li>
</ul>
<p>Some practical rules:</p>
<ul>
<li><p><strong>Test one variable at a time.</strong> Change the first screenshot's caption <em>or</em> its visual, not both.</p>
</li>
<li><p><strong>Test the first screenshot first.</strong> It has the most impact because it's visible in search.</p>
</li>
<li><p><strong>Let tests run to significance.</strong> Low-traffic apps may need weeks. Calling a winner after two days is noise.</p>
</li>
</ul>
<h2>A release-day checklist</h2>
<p>Before you submit, run through this:</p>
<ul>
<li><p>[ ] iPhone set at a 6.9" size (1320 × 2868 recommended)</p>
</li>
<li><p>[ ] iPad set at 13" if your app supports iPad</p>
</li>
<li><p>[ ] Android phone screenshots within 320–3840 px and ≤ 2:1 ratio</p>
</li>
<li><p>[ ] Google Play feature graphic at exactly 1024 × 500, no transparency</p>
</li>
<li><p>[ ] Screenshot 1 works on its own as a search-result thumbnail</p>
</li>
<li><p>[ ] Captions readable at thumbnail size</p>
</li>
<li><p>[ ] Realistic demo data, clean status bar</p>
</li>
<li><p>[ ] Screenshots match the current version of the app</p>
</li>
<li><p>[ ] Top locales have translated captions</p>
</li>
<li><p>[ ] Dimension check script passes</p>
</li>
</ul>
<p>If you're also automating the build-and-submit side of the release, <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=aso-screenshots-guide">RapidNative Deploy</a> is worth a look for getting your app into the stores with less manual setup.</p>
<h2>Wrapping up</h2>
<p>Screenshots aren't decoration. They're the part of your listing most people actually see, and they're where most installs are won or lost. The playbook is simple:</p>
<ol>
<li><p>Lead with the outcome in screenshot 1.</p>
</li>
<li><p>Use the next slots for real differentiators.</p>
</li>
<li><p>Design for thumbnail size.</p>
</li>
<li><p>Automate capture so screenshots never go stale.</p>
</li>
<li><p>Localize for your top markets.</p>
</li>
<li><p>A/B test instead of guessing.</p>
</li>
</ol>
<p>And if you're starting a new app and want a head start on the code you'll eventually be screenshotting, check out <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=aso-screenshots-guide">AppLighter</a>.</p>
<p>What's the screenshot change that made the biggest difference for your app? Drop it in the comments. I'd love to see what's working for other developers.</p>
]]></content:encoded></item><item><title><![CDATA[The Worst Week of My Indie SaaS Life, In Code]]></title><description><![CDATA[Every indie SaaS founder has a week they'd pay money to erase. I had mine about a year ago. In seven days I got a broken Stripe webhook, a one-star App Store review from my first paying customer, and ]]></description><link>https://sarah-dyne.hashnode.dev/the-worst-week-of-my-indie-saas-life-in-code</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/the-worst-week-of-my-indie-saas-life-in-code</guid><category><![CDATA[React Native]]></category><category><![CDATA[supabase]]></category><category><![CDATA[stripe]]></category><category><![CDATA[indie hackers]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Fri, 09 Oct 2026 08:41:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/f741b2bc-6ac2-49d8-883f-51efc7361244.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every indie SaaS founder has a week they'd pay money to erase. I had mine about a year ago. In seven days I got a broken Stripe webhook, a one-star App Store review from my first paying customer, and a Supabase row-level security hole that a stranger on Twitter found. I also realised I'd spent four months shipping features nobody asked for. A Saturday-morning spreadsheet then showed I was earning about <strong>$4.80/hour</strong>.</p>
<p>This post covers that week one day at a time. Each day has the lesson it taught me and the code or process I now ship by default in every React Native + Expo + Supabase project. Paying for the same lesson twice is a tax I refuse to keep paying.</p>
<p><strong>TL;DR</strong></p>
<ul>
<li><p>Treat your money path as production infrastructure. Make webhooks idempotent, add retries, and page yourself when they fail.</p>
</li>
<li><p>Test RLS in CI. A policy in a migration file proves nothing.</p>
</li>
<li><p>Reply to public complaints the same day, with specifics and a date.</p>
</li>
<li><p>Build only what paying customers ask for in writing.</p>
</li>
<li><p>Charge more than feels comfortable.</p>
</li>
<li><p>Take one day off a week. Burnout is a production incident.</p>
</li>
</ul>
<h2>Monday — the webhook that wasn't</h2>
<p>I woke up to seven emails from customers who had paid but hadn't been granted access. Stripe said the charges succeeded. My database said no licenses existed.</p>
<p>A dependency bump I'd pushed on Friday night had broken my <code>charge.succeeded</code> handler. The endpoint had been returning 500s for three days. Nothing alerted me, because my "monitoring" was me remembering to check the dashboard.</p>
<p>The lesson arrived before coffee: <strong>your money path is a production system and deserves production-grade observability.</strong> I'd spent an afternoon tuning animations but shipped Stripe with no retries, no dead-letter handling and no paging.</p>
<p>I rebuilt the webhook that morning. The design has four parts:</p>
<ol>
<li><p>The endpoint verifies the signature, records the event, and returns <code>200</code> within 2 seconds.</p>
</li>
<li><p>A unique index on <code>stripe_event_id</code> makes the write idempotent, so Stripe's retries can't double-grant anything.</p>
</li>
<li><p>An async consumer does the real work and records any failures.</p>
</li>
<li><p>A cron job retries pending events and pages me after three failed attempts.</p>
</li>
</ol>
<h3>The webhook route</h3>
<pre><code class="language-ts">// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { supabaseServer } from "@/modules/db/supabaseServer";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature");
  if (!sig) return new Response("missing signature", { status: 400 });

  const body = await req.text();
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response(`bad signature: ${(err as Error).message}`, { status: 400 });
  }

  // Idempotent write — unique index on stripe_event_id.
  const { error } = await supabaseServer
    .from("webhook_events")
    .insert({
      stripe_event_id: event.id,
      type: event.type,
      payload: event as unknown as object,
      status: "pending",
    });

  if (error &amp;&amp; error.code !== "23505") {
    // Insert failed for a non-duplicate reason — tell Stripe we failed, let it retry.
    return new Response("db failure", { status: 500 });
  }

  // Fire-and-forget to the async consumer. Return 200 within 2s regardless.
  queueMicrotask(() =&gt; processEvent(event.id).catch(() =&gt; {}));
  return new Response("ok", { status: 200 });
}
</code></pre>
<h3>The async consumer</h3>
<pre><code class="language-ts">// modules/stripe/processEvent.ts
export async function processEvent(stripeEventId: string) {
  const { data: row } = await supabaseServer
    .from("webhook_events")
    .select("*")
    .eq("stripe_event_id", stripeEventId)
    .single();

  if (!row || row.status === "done") return;

  try {
    await handle(row.payload);
    await supabaseServer
      .from("webhook_events")
      .update({ status: "done", processed_at: new Date().toISOString() })
      .eq("stripe_event_id", stripeEventId);
  } catch (err) {
    const attempts = (row.attempts ?? 0) + 1;
    await supabaseServer
      .from("webhook_events")
      .update({
        status: attempts &gt;= 3 ? "failed" : "pending",
        attempts,
        last_error: (err as Error).message,
      })
      .eq("stripe_event_id", stripeEventId);
    if (attempts &gt;= 3) await page("stripe webhook failed", stripeEventId);
  }
}
</code></pre>
<h3>The schema</h3>
<pre><code class="language-sql">create table public.webhook_events (
  id uuid primary key default gen_random_uuid(),
  stripe_event_id text not null unique,
  type text not null,
  payload jsonb not null,
  status text not null default 'pending',
  attempts int not null default 0,
  last_error text,
  processed_at timestamptz,
  created_at timestamptz not null default now()
);

create index webhook_events_pending_idx
  on public.webhook_events (status, created_at)
  where status = 'pending';
</code></pre>
<p>A cron job runs every 15 minutes. It picks up rows with <code>status = 'pending'</code> that are older than 2 minutes and reruns <code>processEvent</code>. With that in place, the webhook survives bad deploys, rate limits, Supabase blips and me being asleep.</p>
<p>The pattern took one afternoon to build and would have saved me the entire Monday. If you'd rather not build it from scratch, the <a href="https://www.applighter.com/apps/ai-voice-notes?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=lessons-from-the-worst-week-of-an-indie-saas-founder">AI Voice Notes template</a> ships with this exact pattern already wired into its Supabase schema.</p>
<h2>Tuesday — the one-star review</h2>
<p>My first paying customer, the one I'd personally messaged to thank, left a one-star App Store review: <em>"Doesn't work, waste of money, don't buy."</em></p>
<p>I drafted an angry reply, then a defensive one, then a self-pitying one. Then I closed the laptop and went for a walk. On the walk I realised two things:</p>
<ul>
<li><p><strong>They weren't attacking me.</strong> They'd paid $79, hit a bug, and the review was the only way they had to tell me.</p>
</li>
<li><p><strong>Everyone reading that review would believe them</strong> unless I replied.</p>
</li>
</ul>
<p>So I replied in public within three hours. I named the bug, named the fix date (Friday), and gave them my direct email. Two weeks later they changed the review to five stars and mentioned the fast response.</p>
<p>This is the reply template I now use:</p>
<blockquote>
<p>Thanks for taking the time to leave this. The bug you hit is a [specific name], caused by [specific cause]. The fix will ship by [specific date]. If you'd like, email me at [email] and I'll make sure your account is covered through next month either way.</p>
</blockquote>
<p>A good reply names the bug and the cause, gives a date, and doesn't get defensive. <strong>Public complaints are free QA and free marketing.</strong> One honest reply converts better than a hundred testimonials.</p>
<h2>Wednesday — the RLS hole</h2>
<p>Mid-afternoon, a stranger on Twitter sent me a screenshot. A <code>select *</code> against my <code>user_subscriptions</code> table returned every row, not just his own.</p>
<p>The RLS policy was in my migration file. But the <code>enable row level security</code> statement had failed silently during a bad deploy six weeks earlier. The policy existed in my repo but had never been applied in production.</p>
<p>Thirty minutes of panic later, I'd done four things:</p>
<ul>
<li><p>applied the policy</p>
</li>
<li><p>rotated the anon key</p>
</li>
<li><p>audited the logs, which showed nobody else had queried the table</p>
</li>
<li><p>emailed every affected customer</p>
</li>
</ul>
<p>There was no real damage, and that was pure luck.</p>
<p>The lesson: <strong>your database security is only as good as the test that proves it works.</strong> The migration file proves nothing, and neither does SQL sitting in your repo. Only a test does. Every sensitive table now gets a pgTAP fixture that runs on every PR:</p>
<pre><code class="language-sql">-- supabase/tests/rls_user_subscriptions.sql
begin;
select plan(3);

set local role authenticated;
set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001';

-- 1. RLS must be ON
select ok(
  (select relrowsecurity from pg_class where relname = 'user_subscriptions'),
  'row level security is enabled on user_subscriptions'
);

-- 2. User can see their own rows
select results_eq(
  'select user_id from user_subscriptions',
  array['00000000-0000-0000-0000-000000000001'::uuid],
  'user can see own subscription'
);

-- 3. User cannot see other users' rows
select is_empty(
  'select 1 from user_subscriptions where user_id != ''00000000-0000-0000-0000-000000000001''',
  'user cannot see others subscriptions'
);

select * from finish();
rollback;
</code></pre>
<p>If RLS is off, CI fails loudly and I find out before a stranger does. The <a href="https://www.applighter.com/docs?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=lessons-from-the-worst-week-of-an-indie-saas-founder">Applighter docs</a> include the full set of RLS fixtures that ship with every template. Supabase's own <a href="https://supabase.com/docs/guides/database/postgres/row-level-security">RLS guide</a> covers the underlying policies.</p>
<h2>Thursday — the features nobody wanted</h2>
<p>On Thursday I did the maths I'd been avoiding. I listed every feature I'd shipped in four months, who asked for it, and whether anyone used it.</p>
<table>
<thead>
<tr>
<th>Feature source</th>
<th>Count</th>
<th>Avg usage (first 60 days)</th>
<th>Churn impact</th>
</tr>
</thead>
<tbody><tr>
<td>Built from my own gut</td>
<td>12</td>
<td>3–8%</td>
<td>Neutral to slightly negative</td>
</tr>
<tr>
<td>Explicitly requested by paying customers</td>
<td>2</td>
<td>40–70%</td>
<td>Positive</td>
</tr>
</tbody></table>
<p>Worse, one of the two requested features was still half-finished, because I'd been distracted by the other twelve.</p>
<p>The lesson is the oldest one in the indie book: <strong>you are not your user.</strong> Your taste is a liability until customers validate it. Every feature now goes through this gate in the issue template:</p>
<pre><code class="language-md">## Feature gate
- [ ] At least three different paying customers have asked for this, by name, in writing.
- [ ] Each ask is linked below.
Asks: _links_
</code></pre>
<p>If both boxes aren't ticked, the feature waits in <code>maybe.md</code>. Here's how my prioritisation changed:</p>
<table>
<thead>
<tr>
<th>What I used to do</th>
<th>What I do now</th>
</tr>
</thead>
<tbody><tr>
<td>Prioritise by excitement</td>
<td>Prioritise by number of paying customers who asked</td>
</tr>
<tr>
<td>Ship on gut, learn later</td>
<td>Reply to the request with a mock, learn before shipping</td>
</tr>
<tr>
<td>Keep features that "feel important"</td>
<td>Delete features with &lt; 5% usage after 60 days</td>
</tr>
<tr>
<td>Build dashboards for me</td>
<td>Build dashboards for customers</td>
</tr>
<tr>
<td>Measure velocity</td>
<td>Measure churn</td>
</tr>
</tbody></table>
<h2>Friday — the shipping day</h2>
<p>On Friday I shipped the Stripe fix, the RLS fix, and the half-finished requested feature. Then I sent every customer a changelog email, written the way a competent friend would tell you what broke:</p>
<blockquote>
<p>This week: three bugs fixed, one security issue patched, one feature added. Named and dated below.</p>
<p><strong>Bug: Stripe webhook failure.</strong> Seven customers didn't receive their license on Monday. All affected accounts were fixed manually the same day. The webhook is now idempotent with automatic retries.</p>
<p><strong>Security: RLS was off on</strong> <code>user_subscriptions</code> <strong>in production.</strong> No evidence of data access by anyone else. Policy applied, anon key rotated, automated RLS tests added to CI.</p>
<p><strong>Feature: [X]</strong> — requested by three of you, by name. Shipped today.</p>
</blockquote>
<p>I expected churn and got the opposite. Three customers upgraded, two referred a friend, and the one-star reviewer became my unofficial beta tester.</p>
<p><strong>Transparency builds trust faster than polish does.</strong> People don't expect perfection from indie founders. They expect to hear from you.</p>
<h2>Saturday — the pricing</h2>
<p>Revenue divided by hours worked that week came to <strong>$4.80/hour</strong>. Demand wasn't the problem. I was charging \(29/month for something that solved a \)500/month problem for small studios.</p>
<p>So I doubled the price overnight:</p>
<ul>
<li><p>Existing customers stayed on the old rate.</p>
</li>
<li><p>New signups moved to <strong>$59/month</strong>.</p>
</li>
<li><p>I added an <strong>annual plan at $499</strong>, which cost me nothing to offer.</p>
</li>
</ul>
<p>Signups dropped about 20%, and revenue per signup roughly doubled. I made more money, and the new users were more serious and complained less.</p>
<p><strong>If your pricing feels comfortable, it's almost certainly too low.</strong></p>
<p>Starting from a solid foundation also changed what I could charge:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Time to first dollar</th>
<th>Monthly ops cost</th>
<th>Price I could charge</th>
<th>Payback point</th>
</tr>
</thead>
<tbody><tr>
<td>Build from scratch</td>
<td>3–6 months</td>
<td>~$80</td>
<td>$9–19</td>
<td>~6 months</td>
</tr>
<tr>
<td>Generic boilerplate</td>
<td>3–6 weeks</td>
<td>~$80</td>
<td>$19–29</td>
<td>~3 months</td>
</tr>
<tr>
<td>Full-stack template with RLS, Stripe and AI wired in</td>
<td>1–2 weeks</td>
<td>~$80</td>
<td>$49–99+</td>
<td>&lt; 1 month</td>
</tr>
</tbody></table>
<h2>Sunday — the day off</h2>
<p>On Sunday I slept until noon, read a novel in the sun, and didn't open the laptop. The business didn't fall over. I haven't worked a Sunday since. My revenue is up, my churn is down, and my code is better.</p>
<p><strong>The founder is the biggest single point of failure in the business.</strong> Protect your Sunday the way you protect your Stripe webhook, or it will page you later.</p>
<h2>The seven patterns, as a checklist</h2>
<ol>
<li><p>[ ] Webhook returns <code>200</code> within 2 seconds and is idempotent via a unique index on the provider's event ID</p>
</li>
<li><p>[ ] Retry cron, dead-letter status, and paging after N failed attempts</p>
</li>
<li><p>[ ] A pgTAP RLS fixture for every sensitive table, running in CI</p>
</li>
<li><p>[ ] A public reply within 24 hours to every public complaint, naming the bug and a date</p>
</li>
<li><p>[ ] Feature gate: three paying customers asked for it, in writing</p>
</li>
<li><p>[ ] Price raised until it feels uncomfortable</p>
</li>
<li><p>[ ] One weekly day off, non-negotiable</p>
</li>
</ol>
<h2>Where this stack lives</h2>
<p>I wouldn't have survived that week on a stack I'd built myself. The webhook pattern, the RLS tests, the deduplication table and the retry cron were all paid for in 2am support tickets, by me or by founders like me. That's why every one of them ships pre-wired in <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=lessons-from-the-worst-week-of-an-indie-saas-founder">Applighter's React Native + Expo + Supabase templates</a>. Each template comes with a <a href="https://www.applighter.com/legal/refund-policy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=lessons-from-the-worst-week-of-an-indie-saas-founder">7-day refund policy</a> if it doesn't fit what you're building.</p>
<p>If you're six months in and still writing auth boilerplate, take this as me on the kitchen floor telling you to stop.</p>
<p>The external docs I keep coming back to:</p>
<ul>
<li><p><a href="https://docs.stripe.com/webhooks/signatures">Stripe webhook signatures</a></p>
</li>
<li><p><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase row-level security</a></p>
</li>
<li><p><a href="https://docs.expo.dev/eas-update/introduction/">Expo EAS Update</a>, for shipping emergency fixes in hours instead of waiting on app-store review</p>
</li>
<li><p><a href="https://reactnative.dev/docs/performance">React Native performance</a></p>
</li>
</ul>
<hr />
<p>What's the worst production week you've had as a founder, and what pattern did it leave you with? Tell me in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Expo Push Notifications in React Native: The Production Guide the Quickstart Skips]]></title><description><![CDATA[The official quickstart gets a test notification onto your phone in about twenty minutes. Then you ship, and the problems start quietly: half your Android users never see the permission prompt, tokens]]></description><link>https://sarah-dyne.hashnode.dev/expo-push-notifications-in-react-native-the-production-guide-the-quickstart-skips</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/expo-push-notifications-in-react-native-the-production-guide-the-quickstart-skips</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[push notifications]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Thu, 08 Oct 2026 13:10:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/11890367-7482-478d-85f1-a707431c2d6f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The official quickstart gets a test notification onto your phone in about twenty minutes. Then you ship, and the problems start quietly: half your Android users never see the permission prompt, tokens pile up for devices that uninstalled months ago, a tap on a notification opens the home screen instead of the order it was about, and your server logs say every send was <code>ok</code>.</p>
<p>None of these failures throw an error. That's what makes push notifications hard: the happy path is easy, and every failure is silent.</p>
<p>This guide covers the parts that break in production. It uses <code>expo-notifications</code> and the Expo Push Service, with a Postgres/Supabase-style backend for token storage. The code is TypeScript throughout.</p>
<h2>What you'll build</h2>
<ul>
<li><p>A permission flow that asks at the right moment (and only once)</p>
</li>
<li><p>Token registration that survives offline launches, token rotation and shared devices</p>
</li>
<li><p>A sender that batches, respects rate limits and stores push tickets</p>
</li>
<li><p>A receipts job that prunes dead tokens automatically</p>
</li>
<li><p>Tap handling that deep links correctly, including from a cold start</p>
</li>
</ul>
<h2>How a push actually travels</h2>
<p>Before writing code, get the mental model right, because most production bugs come from misreading one of these hops:</p>
<pre><code class="language-text">App ──(1) getExpoPushTokenAsync──▶ Expo servers ──▶ returns ExponentPushToken[...]
App ──(2) save token──────────────▶ Your backend

Your backend ──(3) POST /push/send──▶ Expo Push Service ──▶ returns a TICKET
Expo Push Service ──(4)──▶ FCM (Android) / APNs (iOS) ──▶ device
Your backend ──(5) POST /push/getReceipts──▶ returns a RECEIPT
</code></pre>
<p>The most misunderstood part is the difference between a ticket and a receipt:</p>
<ul>
<li><p>A <strong>ticket</strong> with <code>status: "ok"</code> means Expo accepted your message. Nothing more.</p>
</li>
<li><p>A <strong>receipt</strong> tells you whether FCM or APNs accepted it. This is where errors like <code>DeviceNotRegistered</code> and <code>InvalidCredentials</code> show up.</p>
</li>
</ul>
<p>Even an <code>ok</code> receipt doesn't guarantee the device displayed the notification, since a phone that's switched off won't receive it. But if you only look at tickets, you're blind to most failures.</p>
<h2>Before you start: Expo Go won't work</h2>
<p>Since SDK 53, remote push notifications don't work in Expo Go on Android. You need a <a href="https://docs.expo.dev/develop/development-builds/introduction/">development build</a>. Local (scheduled) notifications still work in Expo Go, which confuses a lot of people into thinking push "half works."</p>
<p>You also need credentials:</p>
<ul>
<li><p><strong>Android:</strong> an FCM V1 service account key uploaded to your EAS project</p>
</li>
<li><p><strong>iOS:</strong> an APNs key, which <code>eas build</code> will offer to generate the first time you build with push enabled (or run <code>eas credentials</code> manually)</p>
</li>
</ul>
<p>Push works on physical devices, Android emulators with Google Play services, and iOS simulators on Xcode 14+.</p>
<h2>Step 1: Install and configure</h2>
<pre><code class="language-bash">npx expo install expo-notifications expo-constants
</code></pre>
<p>Add the config plugin. The <code>icon</code> and <code>color</code> only apply to Android, and they're build-time settings, so changing them means a new build:</p>
<pre><code class="language-json">{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#4F46E5",
          "defaultChannel": "default"
        }
      ]
    ]
  }
}
</code></pre>
<p>The Android notification icon must be an all-white PNG with a transparent background (96×96). A full-colour app icon renders as a solid white square in the status bar, one of the most common "why does my notification look broken" issues.</p>
<h2>Step 2: Design your Android channels first</h2>
<p>Every notification on Android 8+ belongs to a channel, and users can mute channels individually. That's a feature, not a chore: if your promotional pushes and your chat messages share one channel, a user annoyed by promos mutes everything, including the messages they actually want.</p>
<p>Create channels by intent, not by convenience:</p>
<pre><code class="language-typescript">// lib/notifications/channels.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

export const CHANNELS = {
  default: 'default',
  messages: 'messages',
  orders: 'orders',
  promotions: 'promotions',
} as const;

export async function ensureAndroidChannels() {
  if (Platform.OS !== 'android') return;

  await Notifications.setNotificationChannelAsync(CHANNELS.default, {
    name: 'General',
    importance: Notifications.AndroidImportance.DEFAULT,
  });
  await Notifications.setNotificationChannelAsync(CHANNELS.messages, {
    name: 'Messages',
    importance: Notifications.AndroidImportance.HIGH,
  });
  await Notifications.setNotificationChannelAsync(CHANNELS.orders, {
    name: 'Order updates',
    importance: Notifications.AndroidImportance.HIGH,
  });
  await Notifications.setNotificationChannelAsync(CHANNELS.promotions, {
    name: 'Offers &amp; news',
    importance: Notifications.AndroidImportance.LOW,
  });
}
</code></pre>
<p>Two rules that bite people:</p>
<ol>
<li><p><strong>Create channels before requesting a token.</strong> On Android 13+, the OS permission prompt doesn't appear until at least one channel exists.</p>
</li>
<li><p><strong>Never send to a channel the device hasn't created.</strong> If your payload sets <code>channelId: "orders"</code> and that channel doesn't exist on the device, the notification is silently not shown. Ship channel changes in the app <em>before</em> your backend starts using them.</p>
</li>
</ol>
<p>Also note that after a channel is created, Android only lets you change its name and description. Pick importance levels carefully the first time.</p>
<h2>Step 3: Ask for permission at the right moment</h2>
<p>On iOS, you effectively get one shot at the system prompt. If the user taps "Don't Allow," your app can't show it again; the user has to go to Settings. Asking on first launch, before the user knows why your app would ever notify them, burns that shot.</p>
<p>Use a soft prompt: your own screen that explains the value, shown at a moment when the value is obvious (after placing an order, after sending a first message). Only trigger the system dialog if they tap yes.</p>
<pre><code class="language-tsx">// components/NotificationSoftPrompt.tsx
import { Linking, View, Text, Pressable } from 'react-native';
import * as Notifications from 'expo-notifications';
import { registerPushToken } from '@/lib/notifications/register';

export function NotificationSoftPrompt({ onDone }: { onDone: () =&gt; void }) {
  async function enable() {
    const { status, canAskAgain } = await Notifications.getPermissionsAsync();

    if (status !== 'granted' &amp;&amp; !canAskAgain) {
      // The system prompt is gone for good; send them to Settings instead.
      await Linking.openSettings();
      return onDone();
    }

    await registerPushToken(); // triggers the system prompt if needed
    onDone();
  }

  return (
    &lt;View&gt;
      &lt;Text&gt;Know the moment your order ships&lt;/Text&gt;
      &lt;Text&gt;We only send order updates and replies to your messages.&lt;/Text&gt;
      &lt;Pressable onPress={enable}&gt;
        &lt;Text&gt;Turn on notifications&lt;/Text&gt;
      &lt;/Pressable&gt;
      &lt;Pressable onPress={onDone}&gt;
        &lt;Text&gt;Not now&lt;/Text&gt;
      &lt;/Pressable&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<p>"Not now" costs you nothing. You can show the soft prompt again later. A system-level "Don't Allow" is close to permanent.</p>
<blockquote>
<p>On iOS, permissions are more granular than a single yes/no. Check <code>ios.status</code> on the permission response rather than only the root <code>status</code>, especially if you use provisional authorization.</p>
</blockquote>
<h2>Step 4: Get the token (and keep it fresh)</h2>
<p><code>getExpoPushTokenAsync</code> makes a network request to Expo's servers, so it can fail on a flaky connection. The quickstart example alerts and gives up. In production, retry with backoff and return <code>null</code> instead of crashing the launch path:</p>
<pre><code class="language-typescript">// lib/notifications/register.ts
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase';
import { ensureAndroidChannels } from './channels';

async function getExpoPushToken(): Promise&lt;string | null&gt; {
  await ensureAndroidChannels(); // must come first on Android 13+

  let { status, canAskAgain } = await Notifications.getPermissionsAsync();
  if (status !== 'granted' &amp;&amp; canAskAgain) {
    ({ status, canAskAgain } = await Notifications.requestPermissionsAsync());
  }
  if (status !== 'granted') return null;

  const projectId =
    Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
  if (!projectId) throw new Error('EAS projectId is missing from app config');

  for (let attempt = 0; attempt &lt; 3; attempt++) {
    try {
      const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
      return data; // "ExponentPushToken[...]"
    } catch {
      await new Promise((r) =&gt; setTimeout(r, 2 ** attempt * 1000));
    }
  }
  return null; // try again on next app start
}

export async function registerPushToken() {
  const token = await getExpoPushToken();
  if (!token) return null;

  await supabase.rpc('register_push_token', {
    p_token: token,
    p_platform: Platform.OS,
  });
  return token;
}
</code></pre>
<p>Call <code>registerPushToken()</code> on every app start once the user is signed in and has already granted permission. It's cheap, and it keeps <code>last_seen_at</code> honest.</p>
<p>Tokens can also rotate while the app is running. Subscribe once near your root so a rotated token reaches your backend immediately:</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const sub = Notifications.addPushTokenListener(() =&gt; {
    registerPushToken();
  });
  return () =&gt; sub.remove();
}, []);
</code></pre>
<h2>Step 5: Store tokens like they belong to devices, not users</h2>
<p>The most common schema mistake is a single <code>push_token</code> column on the <code>users</code> table. Users have phones <em>and</em> tablets. They reinstall. They share devices. One user maps to many tokens, and one token can change owners.</p>
<pre><code class="language-sql">create table public.push_tokens (
  id            uuid primary key default gen_random_uuid(),
  user_id       uuid not null references auth.users (id) on delete cascade,
  token         text not null unique,
  platform      text not null check (platform in ('ios', 'android')),
  is_active     boolean not null default true,
  last_seen_at  timestamptz not null default now(),
  created_at    timestamptz not null default now()
);

create index on public.push_tokens (user_id) where is_active;

alter table public.push_tokens enable row level security;

create policy "Users can read their own tokens"
  on public.push_tokens for select
  using (auth.uid() = user_id);
</code></pre>
<p>Notice there's no insert or update policy. When user B signs in on a phone that used to belong to user A, the token has to <em>move</em> to user B. A plain upsert would fail under RLS because the existing row belongs to someone else. A <code>security definer</code> function handles the handover safely, always attributing the token to whoever is calling:</p>
<pre><code class="language-sql">create or replace function public.register_push_token(p_token text, p_platform text)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
  if auth.uid() is null then
    raise exception 'not authenticated';
  end if;

  insert into public.push_tokens (user_id, token, platform)
  values (auth.uid(), p_token, p_platform)
  on conflict (token) do update
    set user_id      = excluded.user_id,
        platform     = excluded.platform,
        is_active    = true,
        last_seen_at = now();
end;
$$;
</code></pre>
<p>And on sign-out, deactivate the device's token <em>before</em> clearing the session, or user A keeps receiving user B's notifications.</p>
<p>If you want to iterate on this schema, RLS policy and RPC locally without running the full Supabase Docker stack, <a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=expo-push-notifications-production-guide">tinbase</a> runs a Supabase-compatible backend (real Postgres with RLS, auth, edge functions, database webhooks and cron) as a single process, and the official <code>supabase-js</code> client works against it unchanged. It's still in alpha, so treat it as a local-dev and prototyping tool, and push the same migrations to hosted Supabase for production.</p>
<p>You'll also want a table for tickets, which the receipts job reads later:</p>
<pre><code class="language-sql">create table public.push_tickets (
  ticket_id   text primary key,
  token       text not null,
  created_at  timestamptz not null default now(),
  checked_at  timestamptz
);
</code></pre>
<h2>Step 6: Send from your server, never from the app</h2>
<p>The quickstart sends a push straight from the device with <code>fetch</code>. Fine for a demo, wrong for production. Sending belongs on your server, where you can batch, throttle, log and protect it.</p>
<p>The official Node SDK handles chunking, gzip and rate limiting for you:</p>
<pre><code class="language-bash">npm install expo-server-sdk
</code></pre>
<pre><code class="language-typescript">// server/push/send.ts
import { Expo, type ExpoPushMessage } from 'expo-server-sdk';
import { createClient } from '@supabase/supabase-js';

const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

type PushInput = {
  title: string;
  body: string;
  url?: string;          // in-app route to open on tap, e.g. "/orders/123"
  channelId?: string;    // must already exist on the device
};

export async function sendToUser(userId: string, input: PushInput) {
  const { data: rows } = await db
    .from('push_tokens')
    .select('token')
    .eq('user_id', userId)
    .eq('is_active', true);

  const messages: ExpoPushMessage[] = (rows ?? [])
    .map((r) =&gt; r.token)
    .filter((token) =&gt; Expo.isExpoPushToken(token))
    .map((to) =&gt; ({
      to,
      title: input.title,
      body: input.body,
      data: input.url ? { url: input.url } : {},
      channelId: input.channelId ?? 'default',
      sound: 'default',
    }));

  // Max 100 messages per request; the SDK chunks for you.
  for (const chunk of expo.chunkPushNotifications(messages)) {
    const tickets = await expo.sendPushNotificationsAsync(chunk);

    await Promise.all(
      tickets.map(async (ticket, i) =&gt; {
        const token = chunk[i].to as string;

        if (ticket.status === 'ok') {
          await db.from('push_tickets').insert({ ticket_id: ticket.id, token });
        } else if (ticket.details?.error === 'DeviceNotRegistered') {
          await db.from('push_tokens').update({ is_active: false }).eq('token', token);
        } else {
          console.error('Push ticket error', ticket.message, ticket.details);
        }
      }),
    );
  }
}
</code></pre>
<p>Three production details in there:</p>
<ul>
<li><p><strong>Keep the payload small.</strong> The total payload must be at most 4,096 bytes. Send an ID and a route, then fetch the full content in the app. Don't stuff the order object into <code>data</code>.</p>
</li>
<li><p><strong>Pass an access token.</strong> By default, anyone holding a user's Expo push token can send to it. Turn on enhanced push security in your EAS dashboard and pass <code>accessToken</code> to the SDK (v3.6.0+). Requests without it then fail with <code>UNAUTHORIZED</code>.</p>
</li>
<li><p><strong>Keep that token server-side.</strong> The access token and the service role key belong in an edge function or server environment, never in the app bundle.</p>
</li>
</ul>
<p>That last rule is one of the defaults baked into <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=expo-push-notifications-production-guide">AppLighter</a>'s full-stack React Native + Expo templates: every secret lives in a Supabase edge function, with RLS on every table. If you're starting a new app, a sender like the one above slots into that structure instead of a project you have to secure from scratch.</p>
<h2>Step 7: Check receipts and prune dead tokens</h2>
<p>This is the step almost every tutorial skips, and it's the one that keeps your sends healthy over time. Expo recommends checking receipts about 15 minutes after sending. Receipts are cleared after 24 hours, so a job that runs every 15 minutes is a good fit:</p>
<pre><code class="language-typescript">// server/push/receipts.ts — run every 15 minutes (cron)
import { Expo } from 'expo-server-sdk';
import { createClient } from '@supabase/supabase-js';

const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

export async function processReceipts() {
  const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000).toISOString();

  const { data: pending } = await db
    .from('push_tickets')
    .select('ticket_id, token')
    .is('checked_at', null)
    .lt('created_at', fifteenMinutesAgo)
    .limit(5000);

  if (!pending?.length) return;
  const tokenByTicket = new Map(pending.map((p) =&gt; [p.ticket_id, p.token]));

  // Max 1,000 receipt IDs per request; the SDK chunks for you.
  for (const ids of expo.chunkPushNotificationReceiptIds([...tokenByTicket.keys()])) {
    const receipts = await expo.getPushNotificationReceiptsAsync(ids);

    for (const [ticketId, receipt] of Object.entries(receipts)) {
      if (receipt.status === 'error') {
        const error = receipt.details?.error;

        if (error === 'DeviceNotRegistered') {
          await db
            .from('push_tokens')
            .update({ is_active: false })
            .eq('token', tokenByTicket.get(ticketId)!);
        } else {
          console.error(`Receipt ${ticketId}: ${error}`, receipt.message);
        }
      }
    }

    await db
      .from('push_tickets')
      .update({ checked_at: new Date().toISOString() })
      .in('ticket_id', ids);
  }
}
</code></pre>
<p>Here's what each error actually means and what to do about it:</p>
<table>
<thead>
<tr>
<th>Error</th>
<th>Shows up in</th>
<th>What it means</th>
<th>What to do</th>
</tr>
</thead>
<tbody><tr>
<td><code>DeviceNotRegistered</code></td>
<td>Ticket or receipt</td>
<td>App uninstalled or permission revoked</td>
<td>Deactivate the token; stop sending</td>
</tr>
<tr>
<td><code>MessageTooBig</code></td>
<td>Receipt</td>
<td>Payload over 4,096 bytes</td>
<td>Send an ID, fetch details in-app</td>
</tr>
<tr>
<td><code>MessageRateExceeded</code></td>
<td>Receipt</td>
<td>Too many pushes to one device</td>
<td>Back off exponentially for that device</td>
</tr>
<tr>
<td><code>InvalidCredentials</code></td>
<td>Receipt</td>
<td>FCM key or APNs key revoked or wrong</td>
<td>Re-upload FCM V1 key / regenerate via <code>eas credentials</code></td>
</tr>
<tr>
<td><code>MismatchSenderId</code></td>
<td>Receipt</td>
<td>FCM key and <code>google-services.json</code> from different projects</td>
<td>Make both come from the same Firebase project</td>
</tr>
<tr>
<td><code>TOO_MANY_REQUESTS</code></td>
<td>Whole request</td>
<td>Over 600 notifications/sec for the project</td>
<td>Throttle (the Node SDK does this for you)</td>
</tr>
</tbody></table>
<p><code>InvalidCredentials</code> is the scary one: it means <em>every</em> push to that platform is failing. Alert on it, don't just log it.</p>
<p>A practical note: <code>DeviceNotRegistered</code> arrives whenever Apple or Google decide the device is gone, which can take an undefined amount of time. You can't reliably test it by uninstalling the app and sending a push a minute later.</p>
<h2>Step 8: Handle foreground notifications and taps</h2>
<h3>Foreground: decide per notification</h3>
<p>By default, a notification that arrives while your app is open is <strong>not shown</strong>. You opt in with a handler, and the handler must respond within 3 seconds or the notification is discarded.</p>
<p>Don't just return <code>true</code> for everything. If the user is already looking at the chat a message belongs to, a banner on top of it is noise:</p>
<pre><code class="language-typescript">// app/_layout.tsx (module scope)
import * as Notifications from 'expo-notifications';
import { getActiveRoute } from '@/lib/navigation/active-route';

Notifications.setNotificationHandler({
  handleNotification: async (notification) =&gt; {
    const url = notification.request.content.data?.url;
    const alreadyThere = typeof url === 'string' &amp;&amp; url === getActiveRoute();

    return {
      shouldShowBanner: !alreadyThere,
      shouldShowList: true,
      shouldPlaySound: !alreadyThere,
      shouldSetBadge: false,
    };
  },
});
</code></pre>
<p><code>shouldShowAlert</code> is deprecated; use <code>shouldShowBanner</code> and <code>shouldShowList</code>.</p>
<h3>Taps: cover the cold start</h3>
<p>A tap can open your app from three states: foreground, background, or fully killed. The response listener alone misses the killed case, because the listener isn't registered yet when the tap happens. Read the last response on mount <em>and</em> subscribe for new ones:</p>
<pre><code class="language-tsx">// app/_layout.tsx
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { Slot, router, type Href } from 'expo-router';

function useNotificationObserver() {
  const handledId = useRef&lt;string | null&gt;(null);

  useEffect(() =&gt; {
    function open(response: Notifications.NotificationResponse) {
      const id = response.notification.request.identifier;
      if (handledId.current === id) return; // avoid double navigation
      handledId.current = id;

      const url = response.notification.request.content.data?.url;
      // Only follow internal routes; never navigate to arbitrary URLs from a payload.
      if (typeof url === 'string' &amp;&amp; url.startsWith('/')) {
        router.push(url as Href);
      }
    }

    const last = Notifications.getLastNotificationResponse();
    if (last) open(last); // app was launched by a tap

    const sub = Notifications.addNotificationResponseReceivedListener(open);
    return () =&gt; sub.remove();
  }, []);
}

export default function RootLayout() {
  useNotificationObserver();
  return &lt;Slot /&gt;;
}
</code></pre>
<p>Two things worth knowing when you test this:</p>
<ul>
<li><p>The <code>startsWith('/')</code> check matters. Your payload is data from a server, and a compromised or buggy sender shouldn't be able to push users to an external URL.</p>
</li>
<li><p>On Android debug builds, launching from a notification can glitch the splash screen. It's a known debug-only issue, so test cold-start taps in a release build (<code>npx expo run:android --variant release</code>) before you decide something is broken.</p>
</li>
</ul>
<h2>Limits cheat sheet</h2>
<table>
<thead>
<tr>
<th>Limit</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Messages per send request</td>
<td>100</td>
</tr>
<tr>
<td>Receipt IDs per receipts request</td>
<td>1,000</td>
</tr>
<tr>
<td>Send rate per project</td>
<td>600 notifications / second</td>
</tr>
<tr>
<td>Total payload size</td>
<td>4,096 bytes</td>
</tr>
<tr>
<td>When to check receipts</td>
<td>~15 minutes after sending</td>
</tr>
<tr>
<td>How long receipts are kept</td>
<td>24 hours</td>
</tr>
<tr>
<td>Foreground handler deadline</td>
<td>3 seconds</td>
</tr>
<tr>
<td>Default TTL (if omitted)</td>
<td>Provider default (~4 weeks)</td>
</tr>
</tbody></table>
<p>Two delivery settings worth understanding: <code>priority</code> defaults to normal on Android and high on iOS, and normal-priority messages can be delayed on sleeping Android devices. Setting <code>ttl</code> very low (like <code>0</code>) can mean a notification never reaches an Android device in Doze mode. Use high priority and a sensible TTL only for genuinely time-sensitive messages.</p>
<h2>Shipping it</h2>
<p>Push notifications are the feature most likely to work in your development build and fail in the store build, because production uses different credentials and a different APNs environment. Before release, send a real push to a TestFlight or internal-testing build, not just your dev client.</p>
<p>Also read the store rules before you plan your notification strategy. Apple's App Review Guidelines (4.5.4) say push notifications can't be required for the app to work, and can't be used for promotions or direct marketing unless the user has explicitly opted in through consent language in your app, with a way to opt out. That's another reason the separate <code>promotions</code> channel and an in-app notification settings screen are worth building from day one.</p>
<p>If store submission is the part standing between you and launch (certificates, provisioning, privacy labels, Google Play's closed-testing requirement for new personal accounts), <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=expo-push-notifications-production-guide">RapidNative Deploy</a> handles the full App Store and Play Store submission for React Native and Expo apps, including fixes and resubmissions until the app is approved.</p>
<h2>Production checklist</h2>
<ul>
<li><p>[ ] Testing on a development build, not Expo Go</p>
</li>
<li><p>[ ] FCM V1 and APNs credentials configured in EAS</p>
</li>
<li><p>[ ] White, transparent Android notification icon</p>
</li>
<li><p>[ ] Android channels created by intent, before requesting a token</p>
</li>
<li><p>[ ] Soft prompt shown at a moment of value; Settings fallback when <code>canAskAgain</code> is false</p>
</li>
<li><p>[ ] Token fetch retries on failure and never blocks app launch</p>
</li>
<li><p>[ ] Tokens stored per device, with ownership handover on shared devices</p>
</li>
<li><p>[ ] Token deactivated on sign-out</p>
</li>
<li><p>[ ] <code>addPushTokenListener</code> re-registers rotated tokens</p>
</li>
<li><p>[ ] Sending happens server-side, with enhanced push security enabled</p>
</li>
<li><p>[ ] Payloads carry IDs and routes, not full objects</p>
</li>
<li><p>[ ] Tickets stored; receipts checked ~15 minutes later</p>
</li>
<li><p>[ ] <code>DeviceNotRegistered</code> tokens deactivated automatically</p>
</li>
<li><p>[ ] Alerting on <code>InvalidCredentials</code></p>
</li>
<li><p>[ ] Foreground handler suppresses banners for the screen the user is on</p>
</li>
<li><p>[ ] Cold-start taps handled and tested in a release build</p>
</li>
<li><p>[ ] Marketing pushes opt-in only, on their own channel</p>
</li>
</ul>
<h2>Wrapping up</h2>
<p>The quickstart proves that push <em>can</em> work. Production is about making sure you find out when it doesn't: tickets tell you Expo got the message, receipts tell you whether Apple and Google did, and a pruned token table keeps every future send honest.</p>
<p>If you're still at the stage of building the app around these notifications, <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=expo-push-notifications-production-guide">RapidNative</a> generates React Native + Expo screens from a plain-English prompt, so you can get to the order-tracking or chat screens your pushes deep link into much faster.</p>
<p>What's the push notification bug that cost you the most time? Drop it in the comments. I'm collecting the weird ones for a follow-up on background (headless) notifications.</p>
]]></content:encoded></item><item><title><![CDATA[The indie SaaS stack behind Applighter in 2026]]></title><description><![CDATA[TL;DR — Applighter runs on Next.js 16, Supabase, Stripe, Resend, Listmonk, Rewardful, Slack, Outrank, Meta CAPI, Cloudflare Stream, and a bash script that calls Claude. ~$235/mo fixed. No PostHog, no ]]></description><link>https://sarah-dyne.hashnode.dev/the-indie-saas-stack-behind-applighter-in-2026</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/the-indie-saas-stack-behind-applighter-in-2026</guid><category><![CDATA[React Native]]></category><category><![CDATA[supabase]]></category><category><![CDATA[stripe]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Wed, 07 Oct 2026 10:55:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/9ff532cd-5562-44d7-aaf8-a0cdd9d1f80e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR</strong> — Applighter runs on Next.js 16, Supabase, Stripe, Resend, Listmonk, Rewardful, Slack, Outrank, Meta CAPI, Cloudflare Stream, and a bash script that calls Claude. ~$235/mo fixed. No PostHog, no Sentry, no Inngest. Here's the file-by-file walkthrough.</p>
</blockquote>
<p>Every indie SaaS stack post names the same four tools — Next.js, Supabase, Stripe, Resend — and calls it done. That's the easy half. The hard half is what sits around them: the ops glue, the webhooks, the sync scripts, the cron jobs that quietly keep the business running.</p>
<p>This is the full picture of what runs <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-indie-saas-stack-behind-applighter-in-2026">Applighter</a>.</p>
<h2>Part 1: What ships code</h2>
<p><strong>Framework:</strong> Next.js 16.1.1 with React 19.2.3. App Router. Server Components. No <code>pages/</code> directory. Tailwind CSS 4 through <code>@tailwindcss/postcss</code>. TypeScript 5.</p>
<p><strong>UI primitives:</strong> Radix for behavior (dialog, dropdown, accordion, navigation menu), <code>class-variance-authority</code> + <code>clsx</code> for variants, <code>lucide-react</code> for icons, <code>sonner</code> for toasts. All wrapped into a small internal component library. No shadcn copy-paste — we fork what we need and version it inside the repo.</p>
<p><strong>Markdown:</strong> <code>next-mdx-remote</code> for the docs site, <code>react-markdown</code> + <code>remark-gfm</code> for blog bodies, <code>rehype-pretty-code</code> + <code>shiki</code> for syntax highlighting. Shiki runs at build time so there's no client-side highlighter payload.</p>
<p><strong>Hosting:</strong> Vercel. <code>@next/third-parties</code> loads Google Tag Manager without tanking Core Web Vitals. <code>@vercel/blob</code> for file storage. Cloudflare Stream for template demo videos.</p>
<p>Security headers in <code>next.config.ts</code>:</p>
<pre><code class="language-ts">{
  'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
  'X-Frame-Options': 'SAMEORIGIN',
  'X-Content-Type-Options': 'nosniff',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'Content-Security-Policy': '...'
}
</code></pre>
<p>Plus about 32 explicit 301 redirects for retired blog URLs. SEO hygiene matters more than any individual post — this config is the first thing to check if you're running a <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-indie-saas-stack-behind-applighter-in-2026">React Native template business</a>.</p>
<h2>Part 2: Data and auth</h2>
<p><strong>Database:</strong> Supabase Postgres through <code>@supabase/supabase-js</code> 2.90.</p>
<pre><code class="language-plaintext">modules/db/supabaseServer.ts   // service role, bypasses RLS, used in API routes
modules/db/supabaseClient.ts   // anon key, respects RLS, used in client components
</code></pre>
<p>No ORM. The Supabase client is thin enough that writing the SQL directly is simpler than wrangling a type-level DSL. 23 migrations live in <code>supabase/migrations/</code> with ISO timestamps.</p>
<p>Core schema:</p>
<table>
<thead>
<tr>
<th>Table</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>users</code></td>
<td>NextAuth mirror</td>
</tr>
<tr>
<td><code>products</code></td>
<td>Template catalog</td>
</tr>
<tr>
<td><code>product_licenses</code></td>
<td>Personal vs. team license tiers per product</td>
</tr>
<tr>
<td><code>transactions</code></td>
<td>Stripe session history</td>
</tr>
<tr>
<td><code>user_product</code></td>
<td>License grant join table</td>
</tr>
<tr>
<td><code>blog_posts</code></td>
<td>Where this article lives</td>
</tr>
<tr>
<td><code>newsletter_subscribers</code></td>
<td>Listmonk mirror</td>
</tr>
<tr>
<td><code>support_requests</code></td>
<td>With RLS enforced</td>
</tr>
<tr>
<td><code>template_requests</code></td>
<td>User-submitted "please build X"</td>
</tr>
<tr>
<td><code>template_screens</code></td>
<td>Screenshot and video URLs</td>
</tr>
<tr>
<td><code>magic_link_tokens</code></td>
<td>Email auth state</td>
</tr>
</tbody></table>
<p><strong>Auth:</strong> NextAuth 5.0.0-beta. Two providers: Google OAuth and a custom Credentials provider wired to <code>magic_link_tokens</code>. The magic-link email is a React Email component rendered by <code>@react-email/render</code> and sent through Resend.</p>
<blockquote>
<p><strong>Note on NextAuth beta:</strong> If I were starting today, I'd evaluate Better Auth before locking in. Betaware in the auth layer is nerve-wracking. That said, v5-beta has been stable in production for us for six months.</p>
</blockquote>
<h2>Part 3: The Stripe webhook (where business happens)</h2>
<p>The single most important file in the repo is <code>app/api/webhook/stripe/route.ts</code>. On <code>checkout.session.completed</code>, seven things happen in order:</p>
<pre><code class="language-ts">export async function POST(req: Request) {
  // 1. Verify signature
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')
  const event = stripe.webhooks.constructEvent(body, sig, SECRET)

  if (event.type !== 'checkout.session.completed') {
    return new Response(null, { status: 200 })
  }

  // 2. Pull product slug and license tier from session metadata
  const session = event.data.object
  const { user_id, product_slug, license_tier } = session.metadata

  // 3. Record transaction
  await supabaseServer.from('transactions').insert({
    stripe_session_id: session.id,
    user_id,
    amount: session.amount_total,
    currency: session.currency,
  })

  // 4. Grant license
  await supabaseServer.from('user_product').insert({
    user_id,
    product_slug,
    license_tier,
    granted_at: new Date().toISOString(),
  })

  // 5. Send download email
  await EmailService.sendPurchaseConfirmation({
    to: session.customer_details.email,
    product_slug,
    license_tier,
    download_url: await signedDownloadUrl(product_slug),
  })

  // 6. Slack notify
  await SlackService.notifyPurchase({
    product: product_slug,
    tier: license_tier,
    amount: session.amount_total,
  })

  // 7. Meta CAPI (server-side dedupe)
  await metaCAPI.sendPurchase({
    event_id: `purchase_${session.id}`,
    value: session.amount_total / 100,
    currency: session.currency,
    email: session.customer_details.email,
  })

  return new Response(null, { status: 200 })
}
</code></pre>
<p>All synchronous. Under 400ms end-to-end. No queue. No retry layer beyond Stripe's own webhook replay. The customer never sees a "pending" state because the license grant is atomic with the payment confirmation.</p>
<h2>Part 4: The support stack</h2>
<p><strong>Transactional email:</strong> Resend 6.7 through <code>modules/services/EmailService.ts</code>. Every email is a React component rendered by <code>@react-email/render</code> 2.0.</p>
<pre><code class="language-tsx">// modules/emails/PurchaseConfirmation.tsx
export function PurchaseConfirmation({ product, downloadUrl }) {
  return (
    &lt;Html&gt;
      &lt;Section&gt;
        &lt;Text&gt;Thanks for buying {product.name}!&lt;/Text&gt;
        &lt;Button href={downloadUrl}&gt;Download source&lt;/Button&gt;
      &lt;/Section&gt;
    &lt;/Html&gt;
  )
}
</code></pre>
<p>Why React Email? Because <code>&lt;table border="0"&gt;</code> is a bad time, Gmail and Outlook render HTML inconsistently, and the only sane way to debug an email template in 2026 is to render it as a component and preview it in a browser.</p>
<p><strong>Newsletter:</strong> Listmonk, self-hosted on a $12 DigitalOcean droplet. <code>modules/services/ListmonkService.ts</code> wraps the HTTP API. Signup forms POST a server action that calls Listmonk and mirrors the subscriber into Supabase's <code>newsletter_subscribers</code> table.</p>
<blockquote>
<p><strong>Why Listmonk over Beehiiv?</strong> API control. The Rewardful affiliate webhook needs to add a confirmed affiliate to a specific list and trigger a welcome email in the same request. Beehiiv's growth tools are better; Listmonk's integration surface is better.</p>
</blockquote>
<p><strong>Affiliates:</strong> Rewardful handles tracking and payouts. <code>/api/webhook/rewardful/route.ts</code> catches confirmation events:</p>
<pre><code class="language-ts">// Verify HMAC-SHA256
const hmac = crypto
  .createHmac('sha256', REWARDFUL_SECRET)
  .update(rawBody)
  .digest('hex')

if (hmac !== req.headers.get('x-rewardful-signature')) {
  return new Response(null, { status: 401 })
}

// Sync to Listmonk
await ListmonkService.addToList(
  affiliate.email,
  LISTMONK_AFFILIATES_LIST_ID
)

// Welcome email
await EmailService.sendAffiliateWelcome(affiliate)
</code></pre>
<p><strong>Internal ops:</strong> Slack webhooks through <code>modules/services/SlackService.ts</code>. Purchase alerts, failed-purchase alerts, support-ticket pings. Block-kit formatted with action buttons. I know within three seconds whenever someone buys something — more importantly, I know when a purchase <em>fails</em> before the support email arrives.</p>
<h2>Part 5: The marketing stack (where most posts quit)</h2>
<p><strong>Blog content</strong> comes from two pipes.</p>
<p><strong>Pipe 1 — Outrank:</strong> external AI content tool posts long-tail SEO articles to the <a href="https://www.applighter.com/blog?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-indie-saas-stack-behind-applighter-in-2026">Applighter blog</a> through <code>/api/outrank/webhook/route.ts</code>. Flow:</p>
<pre><code class="language-ts">// 1. Bearer token auth
if (req.headers.get('authorization') !== `Bearer ${OUTRANK_TOKEN}`) {
  return new Response(null, { status: 401 })
}

// 2. Parse article
const article = await OutrankArticleService.parse(await req.json())

// 3. Upsert
await supabaseServer
  .from('blog_posts')
  .upsert({ ...article }, { onConflict: 'slug' })

// 4. Invalidate cache
revalidateTag('blog-posts')
</code></pre>
<p><strong>Pipe 2 — Claude CLI:</strong> <code>scripts/blog-automation/generate-blog.sh</code>. One cron, one script, one AI:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
TOPIC=$(next_topic_from_tracker)

claude code --skill /write-blog --topic "$TOPIC" --max-turns 25 \
  &gt; "$OUTPUT_DIR/$TIMESTAMP.md"

parse_delimited_sections "$OUTPUT_DIR/$TIMESTAMP.md"

if [[ "$MODE" == "internal" ]]; then
  publish_to_outrank_webhook "$PARSED_DIR/canonical.md"
elif [[ "$MODE" == "external" ]]; then
  stage_slack_thread "$PARSED_DIR/medium.md" \
                    "$PARSED_DIR/devto.md" \
                    "$PARSED_DIR/hashnode.md"
fi
</code></pre>
<p>Queue and stats in <code>scripts/blog-automation/blog-tracker.md</code>. Config in <code>config.json</code>: 1x/day internal, 2x/day external, 30-day retention on raw output.</p>
<p><strong>Analytics dual-track:</strong></p>
<p>The problem: iOS 14+ tracking prevention eats client-side Meta Pixel fires. Attribution math stops working.</p>
<p>The fix: fire the same <code>purchase</code> event from the Stripe webhook, server-side, with the same <code>event_id</code> the pixel uses. Meta dedupes by event ID.</p>
<pre><code class="language-ts">// lib/meta-pixel.ts (client)
fbq('track', 'Purchase', { value, currency }, {
  eventID: `purchase_${sessionId}`,
})

// lib/meta-capi.ts (server, from Stripe webhook)
await fetch(`https://graph.facebook.com/v21.0/${PIXEL_ID}/events`, {
  method: 'POST',
  body: JSON.stringify({
    data: [{
      event_name: 'Purchase',
      event_id: `purchase_${sessionId}`,
      user_data: {
        em: sha256(email.toLowerCase()),
        ph: sha256(phone),
      },
      custom_data: { value, currency },
    }],
    access_token: FB_ACCESS_TOKEN,
  }),
})
</code></pre>
<p>Server-side CAPI catches 100% of real purchases. Client-side pixel catches maybe 60%. Both carry the same event ID, so Meta deduplicates automatically.</p>
<p><strong>SEO infrastructure:</strong></p>
<ul>
<li><p><code>app/sitemap.ts</code> — dynamic sitemap with blog, docs, and template routes. Wrapped in <code>unstable_cache</code> with a 1-hour revalidation window.</p>
</li>
<li><p><code>app/robots.ts</code> — explicit allow for GPTBot, ChatGPT-User, Claude-Web, PerplexityBot, Applebot-Extended. Explicit disallow for <code>/admin</code>, <code>/dashboard</code>, <code>/api</code>.</p>
</li>
<li><p><code>components/json-ld.tsx</code> — structured data: Organization, Website, Article, Breadcrumb, VideoObject.</p>
</li>
</ul>
<h2>Part 6: Comparison</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Generic 2026 stack</th>
<th>Applighter</th>
</tr>
</thead>
<tbody><tr>
<td>Framework</td>
<td>Next.js 15 or 16</td>
<td>Next.js 16.1.1</td>
</tr>
<tr>
<td>Auth</td>
<td>Clerk, Better Auth</td>
<td>NextAuth 5-beta + Supabase magic links</td>
</tr>
<tr>
<td>DB</td>
<td>Supabase + Drizzle</td>
<td>Supabase, no ORM</td>
</tr>
<tr>
<td>Payments</td>
<td>Stripe + Lemon Squeezy</td>
<td>Stripe only</td>
</tr>
<tr>
<td>Transactional email</td>
<td>Resend</td>
<td>Resend + React Email</td>
</tr>
<tr>
<td>Marketing email</td>
<td>Beehiiv</td>
<td>Listmonk self-hosted</td>
</tr>
<tr>
<td>Affiliate</td>
<td>Rewardful</td>
<td>Rewardful + Listmonk sync</td>
</tr>
<tr>
<td>Analytics</td>
<td>PostHog</td>
<td>GTM + Meta CAPI</td>
</tr>
<tr>
<td>Error tracking</td>
<td>Sentry</td>
<td>Not yet</td>
</tr>
<tr>
<td>Jobs</td>
<td>Inngest</td>
<td>Not needed</td>
</tr>
</tbody></table>
<h2>Part 7: What's deliberately missing</h2>
<ul>
<li><p><strong>No PostHog.</strong> GTM + Meta CAPI answers the funnel questions that drive decisions. Session replay doubles the analytics bill for info that doesn't change what we ship.</p>
</li>
<li><p><strong>No Sentry.</strong> Vercel error dashboard + Supabase SQL logs are enough at current scale.</p>
</li>
<li><p><strong>No Inngest.</strong> No background jobs exist. Every webhook finishes synchronously.</p>
</li>
<li><p><strong>No Drizzle / Prisma.</strong> The Supabase JS client is thin enough that writing SQL directly beats wrangling a DSL.</p>
</li>
</ul>
<p>Each omission is a decision, not a gap. The moment one of them starts hurting, we'll adopt the fix the same day. Not before.</p>
<h2>Cost</h2>
<pre><code class="language-plaintext">Vercel Pro          $20/mo
Supabase Pro        $25/mo
Resend              $20/mo
Listmonk droplet    $12/mo
Rewardful           $49/mo
Outrank             $99/mo
Cloudflare Stream  ~$10/mo
──────────────────────────
Fixed total        $235/mo
</code></pre>
<p>Stripe fees are variable (2.9% + 30¢). Meta Pixel/CAPI free. GTM/GA free. Slack free tier. React Email, NextAuth, Shiki, Radix — all free npm packages.</p>
<p>For a one-person operation, this is cheap. For a team of three, it's a rounding error.</p>
<h2>Takeaway</h2>
<p>The best "indie SaaS stack 2026" article is the one you write yourself after a year of actually shipping. Everything above was added when a specific pain made the case for it. Nothing was added because a conference talk recommended it.</p>
<p>Pick four or five tools from the list that solve a pain you have today. Ignore the rest. Add them when they earn their spot.</p>
<p>If you'd rather start from a codebase that already has this pattern wired in, every <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-indie-saas-stack-behind-applighter-in-2026">React Native template in the Applighter catalog</a> ships with Supabase auth and Stripe set up out of the box.</p>
<p>Full canonical version with FAQ: <a href="https://www.applighter.com/blog/the-indie-saas-stack-behind-applighter-in-2026?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-indie-saas-stack-behind-applighter-in-2026">The indie SaaS stack behind Applighter in 2026</a></p>
]]></content:encoded></item><item><title><![CDATA[Build vs Buy a Boilerplate: What Your React Native Starter Actually Costs]]></title><description><![CDATA[Every React Native project starts with the same decision: build your own boilerplate, or begin from one that already exists. This guide walks through what a production-ready Expo boilerplate contains,]]></description><link>https://sarah-dyne.hashnode.dev/build-vs-buy-a-boilerplate-what-your-react-native-starter-actually-costs</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/build-vs-buy-a-boilerplate-what-your-react-native-starter-actually-costs</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Tue, 06 Oct 2026 12:28:45 GMT</pubDate><content:encoded><![CDATA[<p>Every React Native project starts with the same decision: build your own boilerplate, or begin from one that already exists. This guide walks through what a production-ready Expo boilerplate contains, what it costs to build and maintain, and how to choose between building, buying, and generating one.</p>
<h2>What you will learn</h2>
<ul>
<li><p>The four core files every React Native boilerplate needs, with working code</p>
</li>
<li><p>How to estimate the real cost of building your own, including upkeep</p>
</li>
<li><p>The three ways to "buy" a boilerplate today and what each trades away</p>
</li>
<li><p>A simple decision rule, and a checklist for auditing code you did not write</p>
</li>
</ul>
<h2>Scaffold vs boilerplate</h2>
<p>A scaffold is what the CLI gives you:</p>
<pre><code class="language-bash">npx create-expo-app@latest my-app
cd my-app
npx expo install expo-router expo-secure-store
npm i zod @tanstack/react-query
</code></pre>
<p>That takes about ten minutes. A boilerplate is the layer you add on top: authentication, environment handling, an API client, navigation guards, and CI. That layer typically takes weeks, and it has to be maintained afterwards.</p>
<h2>The four core files</h2>
<h3>1. Environment validation</h3>
<p>Validate environment variables once at startup. Without this, a misconfigured build can ship pointing at <code>undefined/api/users</code>.</p>
<pre><code class="language-typescript">// src/env.ts
import { z } from 'zod';

const schema = z.object({
  EXPO_PUBLIC_API_URL: z.string().url(),
  EXPO_PUBLIC_ENV: z.enum(['development', 'staging', 'production']),
});

// Expo inlines these at build time, so reference them statically
export const env = schema.parse({
  EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
  EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
});
</code></pre>
<h3>2. Session storage</h3>
<p>The session provider loads the token from secure storage on launch and exposes sign-in and sign-out to the rest of the app.</p>
<pre><code class="language-tsx">// src/session.tsx
import * as SecureStore from 'expo-secure-store';
import { createContext, useContext, useEffect, useState } from 'react';

type Session = {
  token: string | null;
  loading: boolean;
  signIn: (token: string) =&gt; Promise&lt;void&gt;;
  signOut: () =&gt; Promise&lt;void&gt;;
};

const Ctx = createContext&lt;Session | null&gt;(null);
const KEY = 'session_token';

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState&lt;string | null&gt;(null);
  const [loading, setLoading] = useState(true);

  useEffect(() =&gt; {
    SecureStore.getItemAsync(KEY)
      .then(setToken)
      .finally(() =&gt; setLoading(false));
  }, []);

  const signIn = async (t: string) =&gt; {
    await SecureStore.setItemAsync(KEY, t);
    setToken(t);
  };

  const signOut = async () =&gt; {
    await SecureStore.deleteItemAsync(KEY);
    setToken(null);
  };

  return (
    &lt;Ctx.Provider value={{ token, loading, signIn, signOut }}&gt;
      {children}
    &lt;/Ctx.Provider&gt;
  );
}

export const useSession = () =&gt; {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error('useSession must be used inside SessionProvider');
  return ctx;
};
</code></pre>
<h3>3. Route guard</h3>
<p>With Expo Router, a layout file protects every screen in its group.</p>
<pre><code class="language-tsx">// app/(app)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useSession } from '@/src/session';

export default function AppLayout() {
  const { token, loading } = useSession();

  if (loading) return null; // keep the splash screen up
  if (!token) return &lt;Redirect href="/sign-in" /&gt;;

  return &lt;Stack /&gt;;
}
</code></pre>
<h3>4. API client with token refresh</h3>
<p>This is the piece most often underestimated. The client attaches the token, and on a 401 it refreshes and retries. The shared <code>refreshing</code> promise ensures that ten parallel failures trigger a single refresh.</p>
<pre><code class="language-typescript">// src/api.ts
import { env } from './env';

let refreshing: Promise&lt;string | null&gt; | null = null;

export function createApi(
  getToken: () =&gt; string | null,
  refresh: () =&gt; Promise&lt;string | null&gt;,
) {
  return async function api&lt;T&gt;(path: string, init: RequestInit = {}): Promise&lt;T&gt; {
    const call = (token: string | null) =&gt;
      fetch(`${env.EXPO_PUBLIC_API_URL}${path}`, {
        ...init,
        headers: {
          'Content-Type': 'application/json',
          ...(token ? { Authorization: `Bearer ${token}` } : {}),
          ...init.headers,
        },
      });

    let res = await call(getToken());

    if (res.status === 401) {
      // de-dupe: ten parallel requests should trigger one refresh
      refreshing ??= refresh().finally(() =&gt; (refreshing = null));
      const fresh = await refreshing;
      if (fresh) res = await call(fresh);
    }

    if (!res.ok) throw new Error(`${res.status} ${path}`);
    return res.json() as Promise&lt;T&gt;;
  };
}
</code></pre>
<p>These four files are only the core. Push notifications, deep links, payments, analytics, error reporting, EAS build profiles, and CI still remain.</p>
<h2>Estimating the cost of building</h2>
<p>The estimator below uses illustrative numbers. Replace them with your own.</p>
<pre><code class="language-typescript">type Item = { name: string; days: number };

const build: Item[] = [
  { name: 'auth + session + guards', days: 3 },
  { name: 'api client + query setup', days: 2 },
  { name: 'env, build profiles, CI', days: 3 },
  { name: 'theming + base components', days: 4 },
  { name: 'push, deep links', days: 3 },
  { name: 'payments', days: 4 },
];

const dayRate = 400; // your cost, not your price
const buildDays = build.reduce((sum, i) =&gt; sum + i.days, 0);
const upkeepDaysPerYear = 8; // SDK upgrades, dependency churn

console.log({
  buildCost: buildDays * dayRate,                         // 7600
  yearOneCost: (buildDays + upkeepDaysPerYear) * dayRate, // 10800
});
</code></pre>
<p>The line most estimates omit is <code>upkeepDaysPerYear</code>. Expo ships new SDK versions regularly, and a boilerplate that is not upgraded becomes a liability within a year.</p>
<h2>The three ways to buy</h2>
<table>
<thead>
<tr>
<th>Option</th>
<th>You get</th>
<th>You give up</th>
</tr>
</thead>
<tbody><tr>
<td>Free OSS template</td>
<td>A starting point, community fixes</td>
<td>Guarantees, consistency</td>
</tr>
<tr>
<td>Paid starter kit</td>
<td>Integrated auth, payments, support</td>
<td>Freedom from the author's stack choices</td>
</tr>
<tr>
<td>AI-generated starter</td>
<td>Screens and code shaped to your idea</td>
<td>Some cleanup and review time</td>
</tr>
</tbody></table>
<p>The third option is the newest. Instead of adapting a generic starter, tools like <a href="https://www.rapidnative.com/template/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-boilerplate">RapidNative</a> generate React Native and Expo code from a prompt, so the starting point already resembles your app rather than a todo-list demo. You still own the code and should review it as carefully as a purchased kit.</p>
<h2>A decision rule</h2>
<pre><code class="language-typescript">type Context = {
  appsPerYear: number;
  needsToShipInWeeks: number;
  stackIsUnusual: boolean;   // custom native modules, odd backend
  teamKnowsTheStack: boolean;
};

function decide(c: Context): 'build' | 'buy' {
  if (c.stackIsUnusual) return 'build';            // no kit will fit
  if (c.appsPerYear &gt;= 3 &amp;&amp; c.teamKnowsTheStack) return 'build'; // it amortizes
  if (c.needsToShipInWeeks &lt;= 6) return 'buy';     // time is the constraint
  if (!c.teamKnowsTheStack) return 'buy';          // learn from working code
  return 'build';
}
</code></pre>
<p>The rule is deliberately simple: building pays off through reuse, and buying pays off through speed. If the function returns <code>'buy'</code> because time is your constraint, a generated starting point from a tool such as <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-boilerplate">RapidNative</a> is the fastest route to something you can put in front of users.</p>
<h2>Auditing a boilerplate you did not write</h2>
<p>Run these checks before committing to any kit or generated codebase:</p>
<pre><code class="language-bash"># How stale is it?
npx expo-doctor
npm outdated

# How much is there to own?
npx cloc src app --exclude-dir=node_modules

# Does it typecheck cleanly?
npx tsc --noEmit
</code></pre>
<p>Then read the four core files: env, session, route guard, and API client. If those are clean, the rest usually is. If token refresh is missing, or environment variables are read ad hoc across the app, look elsewhere. The same checklist applies whether the code came from a marketplace, an open-source repo, or a <a href="https://www.rapidnative.com/template/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-boilerplate">generated template</a>.</p>
<h2>Conclusion</h2>
<p>Build a boilerplate once, deliberately, so you understand what goes into one. After that, your time is better spent on the product. Users do not care who wrote your auth provider, and maintaining a starter is ongoing work that competes with shipping features.</p>
]]></content:encoded></item><item><title><![CDATA[AI-Generated Code Quality: What Breaks, Why, and How to Ship It Safely]]></title><description><![CDATA[AI coding assistants can produce a working screen, an API handler, or a test suite in seconds. That speed is real, and it's changing how teams build. But anyone who has merged a few hundred lines of g]]></description><link>https://sarah-dyne.hashnode.dev/ai-generated-code-quality-what-breaks-why-and-how-to-ship-it-safely</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/ai-generated-code-quality-what-breaks-why-and-how-to-ship-it-safely</guid><category><![CDATA[AI]]></category><category><![CDATA[Code Quality]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[React Native]]></category><category><![CDATA[best practices]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Thu, 01 Oct 2026 06:30:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/0aa95273-1940-41d2-9568-140b0b298e42.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>AI coding assistants can produce a working screen, an API handler, or a test suite in seconds. That speed is real, and it's changing how teams build. But anyone who has merged a few hundred lines of generated code knows the catch: <strong>AI-generated code is optimized to look correct, not to be correct.</strong></p>
<p>The code compiles. The demo works. Then a user goes offline, an API returns a 500, or a list re-renders with stale data, and the cracks show.</p>
<p>This guide covers the most common quality failures in AI-generated code, with real examples, and a practical workflow for catching them before they reach production.</p>
<h2>Why AI Code Looks Right But Isn't</h2>
<p>Large language models generate code by predicting what plausibly comes next, based on patterns in their training data. That leads to a few predictable traits:</p>
<ul>
<li><p><strong>They favour the happy path.</strong> Most example code online skips error handling, so generated code often does too.</p>
</li>
<li><p><strong>They blend versions.</strong> Training data spans many library versions, so you get APIs that were deprecated, renamed, or never existed.</p>
</li>
<li><p><strong>They lack your context.</strong> The model doesn't know your auth model, your data shapes, or the edge cases your users hit every day.</p>
</li>
<li><p><strong>They're confident regardless.</strong> Wrong code arrives with the same clean formatting and helpful comments as right code.</p>
</li>
</ul>
<p>None of this means you shouldn't use AI. It means you should treat generated code like a pull request from a fast, well-read junior developer: useful, but reviewed.</p>
<h2>Six Common Failure Modes (With Fixes)</h2>
<h3>1. Hallucinated or Outdated APIs</h3>
<p>A classic in the React Native world: importing AsyncStorage from core, where it hasn't lived for years.</p>
<pre><code class="language-typescript">// ❌ AI-generated — AsyncStorage was removed from react-native core
import { AsyncStorage } from 'react-native';

await AsyncStorage.setItem('token', token);
</code></pre>
<pre><code class="language-typescript">// ✅ Use the community package
import AsyncStorage from '@react-native-async-storage/async-storage';

await AsyncStorage.setItem('token', token);
</code></pre>
<p><strong>How to catch it:</strong> TypeScript in strict mode flags most of these immediately. If a generated import doesn't resolve or a method isn't on the type, don't "fix" it by casting to <code>any</code> — check the library's current docs.</p>
<h3>2. Happy-Path-Only Network Code</h3>
<p>This is probably the single most common issue in generated code.</p>
<pre><code class="language-typescript">// ❌ AI-generated — no status check, no timeout, untyped result
async function getUser(id: string) {
  const res = await fetch(`${API_URL}/users/${id}`);
  return res.json();
}
</code></pre>
<p>A 404 or 500 here doesn't throw — <code>fetch</code> only rejects on network failure. Your UI then tries to render an error body as a user object.</p>
<pre><code class="language-typescript">// ✅ Check status, add a timeout, and type the result
type User = { id: string; name: string; email: string };

async function getUser(id: string, timeoutMs = 10_000): Promise&lt;User&gt; {
  const controller = new AbortController();
  const timer = setTimeout(() =&gt; controller.abort(), timeoutMs);

  try {
    const res = await fetch(`${API_URL}/users/${encodeURIComponent(id)}`, {
      signal: controller.signal,
    });

    if (!res.ok) {
      throw new Error(`getUser failed: ${res.status} ${res.statusText}`);
    }

    return (await res.json()) as User;
  } finally {
    clearTimeout(timer);
  }
}
</code></pre>
<p>For production, go a step further and validate the response shape at runtime with a schema library like Zod, since a type assertion only tells the compiler what you <em>hope</em> the server returned.</p>
<h3>3. Security Issues Hidden in Plain Sight</h3>
<p>Generated code regularly puts secrets where they don't belong or builds queries by string concatenation.</p>
<pre><code class="language-typescript">// ❌ Secret shipped inside the app bundle
// EXPO_PUBLIC_ variables are inlined into client code — anyone can extract them
const apiKey = process.env.EXPO_PUBLIC_OPENAI_KEY;
</code></pre>
<pre><code class="language-typescript">// ❌ SQL injection waiting to happen
const rows = await db.query(
  `SELECT * FROM users WHERE email = '${email}'`
);
</code></pre>
<pre><code class="language-typescript">// ✅ Parameterized query
const rows = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
);
</code></pre>
<p>For third-party API keys, the fix is architectural: call your own backend (or a serverless function), and keep the key on the server. No amount of obfuscation makes a client-side secret safe.</p>
<h3>4. Stale State and Race Conditions in React</h3>
<p>AI tools frequently produce effects with incorrect dependency arrays, or with no protection against out-of-order responses.</p>
<pre><code class="language-tsx">// ❌ Runs once, ignores query changes, and can set state after unmount
useEffect(() =&gt; {
  searchProducts(query).then(setResults);
}, []);
</code></pre>
<pre><code class="language-tsx">// ✅ Re-runs on query change and ignores stale responses
useEffect(() =&gt; {
  let cancelled = false;

  searchProducts(query)
    .then((data) =&gt; {
      if (!cancelled) setResults(data);
    })
    .catch((err) =&gt; {
      if (!cancelled) setError(err);
    });

  return () =&gt; {
    cancelled = true;
  };
}, [query]);
</code></pre>
<p>If the user types "sho" then "shoes", the first request can resolve last and overwrite the correct results. The <code>cancelled</code> flag prevents that. Better still, use a data-fetching library such as TanStack Query, which handles caching, deduping, and cancellation for you.</p>
<h3>5. Duplication and Over-Abstraction</h3>
<p>Ask for three similar screens and you'll often get three near-identical copies of the same logic. Ask the model to "make it reusable" and you may get a generic factory with five type parameters for something used twice.</p>
<p>Both hurt maintainability. A good rule: <strong>extract shared logic on the third repetition, not the first.</strong> When reviewing, look for copy-pasted validation, formatting helpers, and API clients that should live in one place.</p>
<h3>6. Tests That Don't Test Anything</h3>
<p>Generated tests often pass because they mock the very thing they're supposed to verify.</p>
<pre><code class="language-typescript">// ❌ This test can never fail
jest.mock('./pricing', () =&gt; ({
  calculateTotal: jest.fn(() =&gt; 100),
}));

import { calculateTotal } from './pricing';

test('calculates total', () =&gt; {
  expect(calculateTotal([{ price: 50, qty: 2 }])).toBe(100);
});
</code></pre>
<pre><code class="language-typescript">// ✅ Test real behaviour, including edge cases
import { calculateTotal } from './pricing';

describe('calculateTotal', () =&gt; {
  it('sums price × quantity', () =&gt; {
    expect(calculateTotal([{ price: 50, qty: 2 }])).toBe(100);
  });

  it('returns 0 for an empty cart', () =&gt; {
    expect(calculateTotal([])).toBe(0);
  });

  it('rejects negative quantities', () =&gt; {
    expect(() =&gt; calculateTotal([{ price: 10, qty: -1 }])).toThrow();
  });
});
</code></pre>
<p>A quick sanity check for any generated test: break the implementation on purpose and confirm the test fails.</p>
<h2>Build Guardrails, Not Just Reviews</h2>
<p>Manual review doesn't scale when code arrives this fast. The most effective teams let automated tooling catch the mechanical problems so humans can focus on logic and architecture.</p>
<h3>Turn on strict TypeScript</h3>
<pre><code class="language-json">{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}
</code></pre>
<p><code>noUncheckedIndexedAccess</code> is especially useful with generated code, which loves to assume <code>items[0]</code> always exists.</p>
<h3>Lint for the patterns AI gets wrong</h3>
<pre><code class="language-javascript">// eslint.config.js (excerpt)
export default [
  {
    rules: {
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'error',
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/no-explicit-any': 'error',
      'no-console': 'warn',
    },
  },
];
</code></pre>
<p>Note that <code>no-floating-promises</code> requires type-aware linting, so you'll need to point the TypeScript ESLint parser at your <code>tsconfig</code>. It's worth the setup: unawaited promises are a frequent source of silent failures in generated code.</p>
<h3>Enforce it in CI</h3>
<pre><code class="language-yaml"># .github/workflows/quality.yml
name: Quality checks
on: [pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npx eslint .
      - run: npm test -- --coverage
</code></pre>
<p>If generated code can't pass type checks, lint, and tests, it doesn't merge. Simple.</p>
<h2>Prompting for Better Output</h2>
<p>You'll get noticeably better code if you tell the model what "good" means for your project. A few prompt additions that consistently help:</p>
<ul>
<li><p>"Handle network errors, non-2xx responses, and timeouts."</p>
</li>
<li><p>"Use TypeScript with no <code>any</code>."</p>
</li>
<li><p>"Target Expo SDK [version] and React Native [version]." (Pinning versions cuts down on hallucinated APIs.)</p>
</li>
<li><p>"List any assumptions you made about the data shape."</p>
</li>
<li><p>"Write tests that would fail if the implementation were wrong."</p>
</li>
</ul>
<p>That last one sounds obvious, but explicitly asking for it changes the tests you get back.</p>
<p>Purpose-built tools also help. When you're generating mobile UI, a tool like <a href="https://rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=ai-generated-code-quality">RapidNative</a> is aimed specifically at React Native output, rather than a general-purpose assistant guessing at your stack. You still review what comes out, but you start closer to the target.</p>
<h2>A Review Checklist for AI-Generated Code</h2>
<p>Keep this handy for every pull request that contains generated code:</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>What to check</th>
</tr>
</thead>
<tbody><tr>
<td>APIs &amp; imports</td>
<td>Do all imports exist in your installed versions? Any deprecated methods?</td>
</tr>
<tr>
<td>Error handling</td>
<td>Are non-2xx responses, timeouts, and offline states handled?</td>
</tr>
<tr>
<td>Security</td>
<td>Any secrets in client code? Any string-built queries or unsanitized input?</td>
</tr>
<tr>
<td>Types</td>
<td>Any <code>any</code>, unchecked casts, or assumed non-null values?</td>
</tr>
<tr>
<td>React state</td>
<td>Correct effect dependencies? Race conditions? Cleanup on unmount?</td>
</tr>
<tr>
<td>Duplication</td>
<td>Is logic copied that should be shared? Is abstraction justified?</td>
</tr>
<tr>
<td>Tests</td>
<td>Do tests fail when the code is broken? Are edge cases covered?</td>
</tr>
<tr>
<td>Performance</td>
<td>Unnecessary re-renders, unbounded lists, or work on every keystroke?</td>
</tr>
</tbody></table>
<h2>Start From a Solid Foundation</h2>
<p>A lot of quality problems trace back to the project setup, not the generated code itself. If your app has no consistent structure, no strict types, and no shared patterns for auth or data fetching, every AI-generated file invents its own conventions.</p>
<p>Starting from a well-structured base, such as <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=ai-generated-code-quality">AppLighter</a>, gives generated code existing patterns to follow. Models are good at imitation: show them clean code and they tend to produce more of it.</p>
<p>The same applies at the end of the pipeline. Shipping is where untested assumptions finally meet real devices, so a repeatable release process matters as much as the code. If deployment is the bottleneck for your mobile app, <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=ai-generated-code-quality">RapidNative Deploy</a> is worth a look.</p>
<h2>Wrapping Up</h2>
<p>AI-generated code isn't low quality by default, but it's <em>unverified</em> by default. The patterns that cause problems are consistent and predictable: missing error handling, outdated APIs, client-side secrets, stale state, and tests that can't fail.</p>
<p>The fix isn't to stop using AI. It's to:</p>
<ol>
<li><p><strong>Prompt with your standards</strong> so the first draft is closer to right.</p>
</li>
<li><p><strong>Automate the mechanical checks</strong> with strict types, linting, and CI.</p>
</li>
<li><p><strong>Review for logic and context</strong>, which is the part only you can do.</p>
</li>
</ol>
<p>Do that, and you keep the speed while losing most of the risk.</p>
<p>What's the worst bug you've caught in AI-generated code? Share it in the comments — the stranger, the better.</p>
]]></content:encoded></item><item><title><![CDATA[The Roadmap Rewrite 47 Customer Calls Forced]]></title><description><![CDATA[The scene: a planning offsite that never happened
I sell React Native templates for a living. My company is Applighter, and we ship production-ready Expo + Supabase apps that indie devs and small team]]></description><link>https://sarah-dyne.hashnode.dev/the-roadmap-rewrite-47-customer-calls-forced</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/the-roadmap-rewrite-47-customer-calls-forced</guid><category><![CDATA[Expo]]></category><category><![CDATA[React Native]]></category><category><![CDATA[supabase]]></category><category><![CDATA[indie-hacker]]></category><category><![CDATA[Product Management]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Wed, 30 Sep 2026 07:21:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/ffface22-c51e-46f4-b46e-5416dd26f086.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The scene: a planning offsite that never happened</h2>
<p>I sell React Native templates for a living. My company is <a href="https://www.applighter.com?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-customer-interviews-that-rewrote-our-roadmap">Applighter</a>, and we ship production-ready Expo + Supabase apps that indie devs and small teams use to skip months of setup.</p>
<p>Six months in, we had seven templates, a 34-item roadmap, and a head of product who blocked our next planning offsite on the grounds that the roadmap had almost no traceability to actual buyer conversations. She was right. We rescheduled the offsite for two weeks later and used the intervening time to talk to 40 customers. We ended up with 47.</p>
<p>By the end of those two weeks, half the roadmap was gone and a completely different half had taken its place. This is the long-form debrief.</p>
<h2>Cohort design (do this before you dial)</h2>
<p>Interviewing 47 random buyers gives you 47 shrugs. We split the list three ways so that each cohort was answering a different question.</p>
<blockquote>
<p><strong>Recent buyers (n=22):</strong> why they bought, what they expected, what surprised them</p>
<p><strong>Refund requesters (n=8):</strong> where the gap between promise and reality actually lived</p>
<p><strong>Abandoned checkout (n=17):</strong> what stopped them at the last mile</p>
</blockquote>
<p>Each call was 25 minutes on a calendar link. No slides. No demos. No pitching upcoming features. We recorded with consent, dropped transcripts into a shared doc, and tagged themes as we went.</p>
<p>Total founder time: about 20 hours across 12 days. It reshaped the roadmap more than the previous six months of engineering had.</p>
<h2>The five signals that changed everything</h2>
<h3>Signal 1 — The First Hour is the Moment of Truth</h3>
<p>Twelve of 22 recent buyers described some version of this evening:</p>
<blockquote>
<p>"Bought around 10pm. Wanted to see it running on my phone before bed. Either got there, or gave up."</p>
</blockquote>
<p>Nothing on the roadmap addressed that moment. We were leading with catalog breadth. Buyers didn't care.</p>
<p><strong>What we shipped:</strong> a <code>first-hour</code> workstream. A <code>npx applighter-bootstrap</code> command that walks a new buyer from clone to running app on their own device, with a doctor that validates every <code>.env</code> key, bootstraps a Supabase project, applies the migration set, and runs Expo prebuild — with fail-loud errors that name the specific missing thing rather than dumping a stack trace.</p>
<pre><code class="language-bash">npx applighter-bootstrap my-app

✓ Node version &gt;= 20
✓ Bun installed
✓ EAS CLI available
✗ Missing EXPO_PUBLIC_SUPABASE_URL
  → Get this from your Supabase project settings
  → https://app.supabase.com/project/_/settings/api
</code></pre>
<h3>Signal 2 — Agent-Readiness is a Purchase Blocker</h3>
<p>Nine of 17 abandoned-checkout buyers told us, unprompted, that the first thing they planned to do was open the codebase in Claude Code, Cursor, or Codex.</p>
<p>Nobody in 2026 buys a template to hand-write every screen. They buy expecting to extend it with an <a href="https://www.applighter.com/blog/ai-assisted-coding?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-customer-interviews-that-rewrote-our-roadmap">AI coding agent</a>.</p>
<p><strong>What we shipped:</strong></p>
<ol>
<li><p>A <code>CLAUDE.md</code> at the root of every template that explains the mental model to an agent on turn one.</p>
</li>
<li><p>A CI check that enforces "agent-ready" invariants: no orphaned exports, respected module boundaries, JSDoc on public APIs, naming consistency.</p>
</li>
<li><p>Marketing that says so out loud.</p>
</li>
</ol>
<p>Here's the shape of the CI check:</p>
<pre><code class="language-yaml"># .github/workflows/agent-ready.yml
name: agent-readiness
on: [pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun run agent:lint
</code></pre>
<p>And a slice of what <code>agent:lint</code> actually checks:</p>
<pre><code class="language-ts">// scripts/agent-lint.ts
import { readFile } from 'node:fs/promises'
import { glob } from 'glob'

async function assertClaudeMd() {
  await readFile('CLAUDE.md', 'utf8')
    .catch(() =&gt; { throw new Error('CLAUDE.md missing at repo root') })
}

async function assertNoOrphanedExports() {
  // knip or ts-prune wrapper; fail on any unused public export
}

async function assertModuleBoundaries() {
  // dependency-cruiser wrapper; enforce app/ → modules/ → lib/ direction
}

await Promise.all([
  assertClaudeMd(),
  assertNoOrphanedExports(),
  assertModuleBoundaries(),
])
</code></pre>
<p>Within a month of shipping, buyers were mentioning it in support tickets as a reason they'd chosen us.</p>
<h3>Signal 3 — "Supabase lock-in" is Code Shape, Not Database Choice</h3>
<p>Three of 8 refund conversations cited some version of "I don't want to be locked into Supabase." When we probed, none of them were actually complaining about Postgres. They were afraid the frontend was so entangled with the backend that swapping would mean a rewrite.</p>
<p><strong>What we shipped:</strong> a repository interface in front of every table.</p>
<pre><code class="language-ts">// modules/data/notes.repository.ts
export interface NotesRepository {
  list(userId: string): Promise&lt;Note[]&gt;
  get(id: string): Promise&lt;Note | null&gt;
  create(input: NewNote): Promise&lt;Note&gt;
  update(id: string, patch: Partial&lt;Note&gt;): Promise&lt;Note&gt;
  delete(id: string): Promise&lt;void&gt;
}
</code></pre>
<p>The Supabase implementation lives next to it:</p>
<pre><code class="language-ts">// modules/data/notes.supabase.ts
import { supabase } from '@/modules/db/supabaseClient'

export const supabaseNotes: NotesRepository = {
  async list(userId) {
    const { data, error } = await supabase
      .from('notes')
      .select('*')
      .eq('user_id', userId)
      .order('created_at', { ascending: false })
    if (error) throw error
    return data
  },
  async get(id) {
    const { data, error } = await supabase
      .from('notes')
      .select('*')
      .eq('id', id)
      .maybeSingle()
    if (error) throw error
    return data
  },
  async create(input) {
    const { data, error } = await supabase
      .from('notes')
      .insert(input)
      .select()
      .single()
    if (error) throw error
    return data
  },
  async update(id, patch) {
    const { data, error } = await supabase
      .from('notes')
      .update(patch)
      .eq('id', id)
      .select()
      .single()
    if (error) throw error
    return data
  },
  async delete(id) {
    const { error } = await supabase.from('notes').delete().eq('id', id)
    if (error) throw error
  },
}
</code></pre>
<p>Every screen imports <code>NotesRepository</code>, never <code>supabase</code> directly. Swap the impl for Drizzle + Neon, Firestore, or an in-memory mock in a single file. That refactor had been on the roadmap as "cleanup" for months. It was a sales objection wearing a technical costume.</p>
<h3>Signal 4 — The Admin Dashboard Nobody Wanted</h3>
<p>We'd been quietly building a customer-facing admin dashboard for four months. Zero of 47 mentioned it unprompted. When asked directly, 41 said some variant of "I already have a way to do that." Two would pay for it. One would use it if free.</p>
<p>That's not a product. That's a hobby.</p>
<p><strong>What we shipped:</strong> the delete key. The engineer moved to Signal 2 and shipped the agent-readiness system.</p>
<p>This was the single hardest call and the single most obviously correct one. If you cannot delete things from your roadmap after an interview program, the program has failed.</p>
<h3>Signal 5 — Bundles</h3>
<p>Six recent buyers had bought a second template within two weeks of the first. Two abandoned-checkout buyers said they were waiting for a bundle price.</p>
<p><strong>What we shipped:</strong> bundle SKUs, live in three weeks. The Stripe side is a one-time price with a metadata list of included template IDs; the entitlement side is a Supabase edge function that expands the metadata into row-level grants.</p>
<pre><code class="language-ts">// supabase/functions/grant-bundle/index.ts
import { serve } from 'https://deno.land/std/http/server.ts'
import Stripe from 'https://esm.sh/stripe@14'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)

serve(async (req) =&gt; {
  const { userId, sessionId } = await req.json()
  const session = await stripe.checkout.sessions.retrieve(sessionId)
  const templates = session.metadata?.templates?.split(',') ?? []

  const { error } = await supabase.from('license_grants').insert(
    templates.map((templateId) =&gt; ({
      user_id: userId,
      template_id: templateId,
      source: 'bundle',
      stripe_session_id: sessionId,
    })),
  )
  if (error) return new Response(error.message, { status: 500 })
  return new Response('ok')
})
</code></pre>
<h2>The roadmap diff</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Before interviews</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Admin dashboard</td>
<td>Being built</td>
<td>Killed</td>
</tr>
<tr>
<td>3 additional templates</td>
<td>Planned</td>
<td>2 shelved, 1 absorbed</td>
</tr>
<tr>
<td>Repository refactor</td>
<td>Someday</td>
<td>Next release</td>
</tr>
<tr>
<td>Bundle pricing</td>
<td>Q3</td>
<td>Live in 3 weeks</td>
</tr>
<tr>
<td>Marketing site refresh</td>
<td>Q2</td>
<td>Deferred</td>
</tr>
<tr>
<td>Agent-readiness CI</td>
<td>—</td>
<td>New workstream</td>
</tr>
<tr>
<td>First-hour setup doctor</td>
<td>—</td>
<td>New workstream</td>
</tr>
<tr>
<td>Discord community</td>
<td>Q2</td>
<td>Deferred</td>
</tr>
<tr>
<td>Video course upsell</td>
<td>Considered</td>
<td>Killed</td>
</tr>
</tbody></table>
<p>Half the items are new. Half the old items are gone. The roadmap is smaller than it was six months ago, and it's the first time it has felt like the right size.</p>
<h2>The pattern behind every change</h2>
<p>Every roadmap change traces back to the same insight: <strong>buyers are hiring the product to de-risk their next 90 days of shipping, not to admire it</strong>.</p>
<ul>
<li><p>First-hour experience matters because a broken first hour is a 90-day risk.</p>
</li>
<li><p>Agent-readiness matters because the buyer is planning to use an agent for the <em>rest</em> of those 90 days.</p>
</li>
<li><p>Backend swap-ability matters because lock-in gets worse as the code grows.</p>
</li>
<li><p>Bundles matter because a buyer with two templates has twice the 90-day surface area.</p>
</li>
<li><p>The admin dashboard didn't matter because it wasn't on anyone's 90-day critical path.</p>
</li>
</ul>
<p>If a roadmap item doesn't reduce a real risk on a real buyer's real 90 days, it's optional. That heuristic alone deleted eight items from our doc.</p>
<h2>The new operating rhythm</h2>
<ul>
<li><p><strong>15 interviews minimum per quarter.</strong> No planning cycle starts until they're done.</p>
</li>
<li><p><strong>Themes are published internally</strong> so every engineer reads the top five before picking a task.</p>
</li>
<li><p><strong>Refund requests get a $50 credit for a 20-minute call.</strong> Refund is the only real churn signal in a one-time-purchase business.</p>
</li>
<li><p><strong>The backlog tab is deleted.</strong> If it's not in the current or next cycle, it doesn't exist.</p>
</li>
<li><p><strong>Bundles, agent-readiness, and setup DX have permanent owners.</strong> They're pillars, not features.</p>
</li>
</ul>
<h2>Templates the interviews built</h2>
<ul>
<li><p><a href="https://www.applighter.com/apps/ai-voice-notes?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-customer-interviews-that-rewrote-our-roadmap">AI Voice Notes</a> — the first template with the provider-abstracted AI service layer</p>
</li>
<li><p><a href="https://www.applighter.com/apps/chat-with-pdf?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-customer-interviews-that-rewrote-our-roadmap">Chat with PDF</a> — with the pgvector extension recipe first-class in docs</p>
</li>
<li><p><a href="https://www.applighter.com/apps/ai-calorie-tracker?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-customer-interviews-that-rewrote-our-roadmap">AI Calorie Tracker</a> — first template with the setup doctor baked in</p>
</li>
</ul>
<p>Full catalog: <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=the-customer-interviews-that-rewrote-our-roadmap">applighter.com/apps</a>. Refund policy still 7 days — because refunds are how we hear the truth.</p>
<h2>Further reading</h2>
<ul>
<li><p><a href="https://docs.expo.dev/">Expo docs</a></p>
</li>
<li><p><a href="https://supabase.com/docs/guides/auth/row-level-security">Supabase RLS guide</a></p>
</li>
<li><p>Rob Fitzpatrick, <em>The Mom Test</em> — the definitive interview-questions book</p>
</li>
</ul>
<p>If you've bought an Applighter template and want to be in the next round of interviews, my calendar link is on the site.</p>
]]></content:encoded></item><item><title><![CDATA[PGlite: Running Real Postgres in the Browser (and Why You Should Care)]]></title><description><![CDATA[For years, "Postgres in the browser" meant one of two things: a hosted database behind an API, or a SQLite-flavoured stand-in that behaved almost like Postgres until it didn't. PGlite changes that. It]]></description><link>https://sarah-dyne.hashnode.dev/pglite-running-real-postgres-in-the-browser-and-why-you-should-care</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/pglite-running-real-postgres-in-the-browser-and-why-you-should-care</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[database]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[webdevelopment]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Tue, 29 Sep 2026 09:33:49 GMT</pubDate><content:encoded><![CDATA[<p>For years, "Postgres in the browser" meant one of two things: a hosted database behind an API, or a SQLite-flavoured stand-in that behaved <em>almost</em> like Postgres until it didn't. PGlite changes that. It is Postgres itself, compiled to WebAssembly, packaged as a TypeScript library, and small enough to ship as a dependency.</p>
<p>In this tutorial we'll cover what PGlite is, how it works under the hood, how to use it in the browser and in Node, and where it genuinely earns a place in your stack.</p>
<h2>What PGlite actually is</h2>
<p>PGlite is a WASM build of PostgreSQL maintained by the Electric team. Instead of emulating a Linux VM and booting a Postgres server inside it, PGlite compiles Postgres in single-user mode directly to WebAssembly. The result:</p>
<ul>
<li><p>Runs in the browser, Node.js, Bun and Deno with zero external dependencies</p>
</li>
<li><p>Roughly 3 MB gzipped</p>
</li>
<li><p>Supports a growing list of extensions, including <code>pgvector</code>, PostGIS and <code>pg_trgm</code></p>
</li>
<li><p>Persists to IndexedDB (browser), OPFS, or the filesystem (server runtimes), or stays purely in-memory</p>
</li>
</ul>
<p>Because it is real Postgres, the SQL you write against PGlite is the SQL you deploy to production. JSONB, CTEs, window functions, triggers, <code>RETURNING</code>, PL/pgSQL — it all works.</p>
<h2>Installation</h2>
<pre><code class="language-bash">npm install @electric-sql/pglite
</code></pre>
<p>Or load it straight from a CDN in a plain HTML page:</p>
<pre><code class="language-javascript">import { PGlite } from "https://cdn.jsdelivr.net/npm/@electric-sql/pglite/dist/index.js";
</code></pre>
<h2>Your first query</h2>
<pre><code class="language-javascript">import { PGlite } from "@electric-sql/pglite";

const db = new PGlite(); // in-memory

await db.exec(`
  CREATE TABLE todos (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    done BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT now()
  );
`);

await db.query("INSERT INTO todos (title) VALUES ($1), ($2)", [
  "Read the PGlite docs",
  "Ship something local-first",
]);

const { rows } = await db.query("SELECT * FROM todos ORDER BY id");
console.log(rows);
// [{ id: 1, title: 'Read the PGlite docs', done: false, created_at: ... }, ...]
</code></pre>
<p>Two things worth noticing:</p>
<ol>
<li><p><code>exec</code> runs multiple statements; <code>query</code> runs one statement with parameters and returns typed rows.</p>
</li>
<li><p>Parameters use Postgres-style <code>$1, $2</code> placeholders — not <code>?</code>. If you've been on SQLite, that's the first habit to unlearn.</p>
</li>
</ol>
<h2>Persistence options</h2>
<p>An in-memory database disappears on reload. For anything real, pick a storage backend:</p>
<pre><code class="language-javascript">// Browser: IndexedDB
const db = new PGlite("idb://my-app");

// Browser: OPFS (faster, requires a Web Worker)
const db = new PGlite("opfs-ahp://my-app");

// Node / Bun / Deno: filesystem
const db = new PGlite("./data/pgdata");
</code></pre>
<table>
<thead>
<tr>
<th>Backend</th>
<th>Environment</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Memory</td>
<td>All</td>
<td>Fastest; lost on reload</td>
</tr>
<tr>
<td>IndexedDB</td>
<td>Browser</td>
<td>Works on the main thread; widest support</td>
</tr>
<tr>
<td>OPFS</td>
<td>Browser</td>
<td>Better write performance; worker-only</td>
</tr>
<tr>
<td>Filesystem</td>
<td>Node/Bun/Deno</td>
<td>Standard <code>PGDATA</code> directory</td>
</tr>
</tbody></table>
<h2>Running PGlite in a Web Worker</h2>
<p>Postgres is synchronous internally, so for a smooth UI you'll usually want it off the main thread. PGlite ships a worker wrapper that handles this, and it also lets multiple tabs share one database.</p>
<p><strong>worker.js</strong></p>
<pre><code class="language-javascript">import { PGlite } from "@electric-sql/pglite";
import { worker } from "@electric-sql/pglite/worker";

worker({
  async init() {
    return new PGlite("idb://my-app");
  },
});
</code></pre>
<p><strong>main.js</strong></p>
<pre><code class="language-javascript">import { PGliteWorker } from "@electric-sql/pglite/worker";

const db = new PGliteWorker(
  new Worker(new URL("./worker.js", import.meta.url), { type: "module" })
);

const { rows } = await db.query("SELECT 1 + 1 AS two");
</code></pre>
<p>The <code>PGliteWorker</code> instance exposes the same API as <code>PGlite</code>, so the rest of your code doesn't change.</p>
<h2>Transactions</h2>
<pre><code class="language-javascript">await db.transaction(async (tx) =&gt; {
  await tx.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [50, 1]);
  await tx.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [50, 2]);
});
</code></pre>
<p>If anything throws inside the callback, the transaction rolls back. Real ACID semantics, in a browser tab.</p>
<h2>Live queries: the reactive layer</h2>
<p>The <code>live</code> extension is the feature that turns PGlite from "a database" into "a state layer". A live query re-emits whenever its underlying rows change.</p>
<pre><code class="language-javascript">import { PGlite } from "@electric-sql/pglite";
import { live } from "@electric-sql/pglite/live";

const db = await PGlite.create({ extensions: { live } });

const sub = await db.live.query(
  "SELECT * FROM todos WHERE done = false ORDER BY created_at DESC",
  [],
  (result) =&gt; {
    renderTodos(result.rows);
  }
);

// Later:
await db.query("UPDATE todos SET done = true WHERE id = $1", [1]);
// -&gt; callback fires with the updated rows

sub.unsubscribe();
</code></pre>
<p>With React, the official <code>@electric-sql/pglite-react</code> package wraps this in a hook:</p>
<pre><code class="language-jsx">import { useLiveQuery } from "@electric-sql/pglite-react";

function TodoList() {
  const result = useLiveQuery("SELECT * FROM todos ORDER BY id", []);
  return (
    &lt;ul&gt;
      {result?.rows.map((t) =&gt; (
        &lt;li key={t.id}&gt;{t.title}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p>No reducers, no cache invalidation. The database <em>is</em> the store.</p>
<h2>Extensions: pgvector in the browser</h2>
<p>Extensions are opt-in and tree-shakeable.</p>
<pre><code class="language-javascript">import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";

const db = await PGlite.create({ extensions: { vector } });

await db.exec(`
  CREATE EXTENSION IF NOT EXISTS vector;
  CREATE TABLE docs (id SERIAL PRIMARY KEY, body TEXT, embedding vector(384));
`);

const { rows } = await db.query(
  "SELECT body FROM docs ORDER BY embedding &lt;-&gt; $1 LIMIT 5",
  [JSON.stringify(queryEmbedding)]
);
</code></pre>
<p>Fully client-side semantic search over a user's own data, with no embeddings ever leaving the device. That's a genuinely new category of app.</p>
<h2>Using PGlite with an ORM</h2>
<p>Drizzle has a first-class driver:</p>
<pre><code class="language-javascript">import { drizzle } from "drizzle-orm/pglite";
import { PGlite } from "@electric-sql/pglite";

const client = new PGlite("idb://my-app");
const db = drizzle(client);

const todos = await db.select().from(todosTable);
</code></pre>
<p>Because the dialect is plain Postgres, the same Drizzle schema and migrations work against PGlite locally and a hosted Postgres in production.</p>
<h2>Where PGlite shines</h2>
<p><strong>Local-first apps.</strong> Keep a full relational database on the device, sync deltas to a server. Electric's own sync engine is built to stream Postgres shapes into PGlite.</p>
<p><strong>Testing.</strong> Spin up a throwaway Postgres in your test runner in milliseconds. No Docker, no Testcontainers, no CI flakiness from port conflicts.</p>
<pre><code class="language-javascript">import { PGlite } from "@electric-sql/pglite";
import { beforeEach, test, expect } from "vitest";

let db;
beforeEach(async () =&gt; {
  db = new PGlite();
  await db.exec(schemaSql);
});

test("inserts a user", async () =&gt; {
  await db.query("INSERT INTO users (email) VALUES ($1)", ["a@b.co"]);
  const { rows } = await db.query("SELECT count(*)::int AS n FROM users");
  expect(rows[0].n).toBe(1);
});
</code></pre>
<p><strong>Whole backends in a tab.</strong> Once the database runs in the browser, the rest of the stack can follow. <a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=pglite-postgres-in-the-browser">tinbase</a> is a good example of where this leads: a single-process, Supabase-compatible backend on real Postgres that can run entirely in-browser — REST, auth, storage and realtime with no Docker stack. If you've ever wanted a working <code>supabase-js</code> target inside a sandbox or a demo page, that's the pattern.</p>
<p><strong>Playgrounds, docs, and tutorials.</strong> Interactive SQL examples that run without a server. The Postgres docs could ship with a live REPL.</p>
<p><strong>AI agents and sandboxes.</strong> Give an agent a scratch database it can create, query and throw away with no infrastructure.</p>
<h2>Limitations to know before you commit</h2>
<ul>
<li><p><strong>Single connection.</strong> PGlite runs Postgres in single-user mode. One process, one connection, no concurrent writers. The worker wrapper multiplexes tabs, but it's still one Postgres.</p>
</li>
<li><p><strong>Performance is good, not native.</strong> WASM Postgres is slower than native Postgres and, for many workloads, a bit slower than WASM SQLite. For local-first apps this rarely matters; for analytics over millions of rows, benchmark first.</p>
</li>
<li><p><strong>Bundle size.</strong> ~3 MB gzipped is small for Postgres and large for a landing page. Lazy-load it.</p>
</li>
<li><p><strong>Storage quotas.</strong> Browser storage is subject to eviction and quota rules. Call <code>navigator.storage.persist()</code> for anything users would be upset to lose.</p>
</li>
<li><p><strong>Extension coverage.</strong> Many extensions are available; not all are. Check the list before designing around one.</p>
</li>
</ul>
<h2>Mobile: the same idea, different runtime</h2>
<p>If you're building for iOS or Android rather than the browser, the local-first story is the same but the runtime is different — you're typically looking at Expo with a native SQLite or Postgres-compatible layer plus a sync engine. Tools like <a href="https://rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=pglite-postgres-in-the-browser">RapidNative</a> let you prototype the React Native side of that stack quickly, and once the app is ready you can push it to the stores from the same workflow with <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=pglite-postgres-in-the-browser">RapidNative Deploy</a>. Pair it with a browser-based backend like <a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=pglite-postgres-in-the-browser">tinbase</a> and you can demo the full loop before provisioning a single server.</p>
<h2>Wrapping up</h2>
<p>PGlite removes the last excuse for not using Postgres everywhere. Same SQL, same extensions, same mental model — in the browser, in tests, in edge runtimes, and on the server. Start with an in-memory instance in your test suite; that alone will pay for the afternoon it takes to learn the API. From there, live queries and IndexedDB persistence are a short step to a real local-first app.</p>
<p>The full docs live at <a href="https://pglite.dev">pglite.dev</a>, and the repo is <code>electric-sql/pglite</code> on GitHub.</p>
<p>If you've built something on PGlite — or hit a wall with it — I'd like to hear about it in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[What React Native Developers Actually Struggle With: A Year of Support Tickets, Ranked]]></title><description><![CDATA[Applighter sells React Native app templates, which means it fields support tickets — a lot of them. Over 12 months the team tagged every ticket that wasn't a refund question or a typo, and the result ]]></description><link>https://sarah-dyne.hashnode.dev/what-react-native-developers-actually-struggle-with-a-year-of-support-tickets-ranked</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/what-react-native-developers-actually-struggle-with-a-year-of-support-tickets-ranked</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Mon, 28 Sep 2026 09:27:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/529e85e0-4329-4773-808f-c27d9ea047a1.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Applighter sells React Native app templates, which means it fields support tickets — a lot of them. Over 12 months the team tagged every ticket that wasn't a refund question or a typo, and the result is a rare dataset: a ranked view of what actually breaks when developers take a working React Native starting point and try to ship it.</p>
<p>The headline finding is simple. The framework isn't the problem. The perimeter around the framework is.</p>
<p>This post walks through the top ten problems, what causes each one, and how to fix it.</p>
<h2>How the tickets were classified</h2>
<p>Every ticket received three labels:</p>
<pre><code class="language-plaintext">platform: ios | android | web
layer:    build | backend | ui | auth | payment | ai
severity: blocker | degraded | question
</code></pre>
<p>The data was aggregated across seven templates (Weather, Fitness, E-Learning, Taxi Booking, AI Calorie Tracker, AI Voice Notes, Chat with PDF) and every Expo SDK from 50 through 53. Feature requests, refund questions, and anything that turned out to be a typo were excluded. What remains is reproducible, teachable friction.</p>
<h2>The top 10 problems</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Problem</th>
<th>Share of tickets</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>EAS build fails on first run</td>
<td>18.4%</td>
</tr>
<tr>
<td>2</td>
<td>Supabase RLS returns empty arrays</td>
<td>12.1%</td>
</tr>
<tr>
<td>3</td>
<td>Env vars not reaching production binary</td>
<td>9.7%</td>
</tr>
<tr>
<td>4</td>
<td>Metro cache poisoning after dependency bump</td>
<td>7.8%</td>
</tr>
<tr>
<td>5</td>
<td>iOS Info.plist permission strings missing</td>
<td>6.9%</td>
</tr>
<tr>
<td>6</td>
<td>Android package rename breaks build</td>
<td>6.2%</td>
</tr>
<tr>
<td>7</td>
<td>Android push notifications silently dropping</td>
<td>5.8%</td>
</tr>
<tr>
<td>8</td>
<td>AI streaming stalls / dropped tokens</td>
<td>5.1%</td>
</tr>
<tr>
<td>9</td>
<td>Stripe → Supabase Edge Function webhook fails</td>
<td>4.4%</td>
</tr>
<tr>
<td>10</td>
<td>Voice transcription latency on mid-range Android</td>
<td>3.9%</td>
</tr>
</tbody></table>
<h2>1. EAS build fails on first run</h2>
<p>The single largest category. In nearly every case it comes down to one of three causes.</p>
<h3>Version mismatch</h3>
<pre><code class="language-bash">bunx expo doctor
</code></pre>
<p>Run this before every EAS build. According to the ticket data, roughly half the reports in this category are resolved by acting on the doctor's output alone.</p>
<h3>Missing token in CI</h3>
<pre><code class="language-bash">export EXPO_TOKEN=xxxx
</code></pre>
<p>If the build runs in CI, <code>EXPO_TOKEN</code> must be set in the environment. Without it, EAS fails before it starts.</p>
<h3>Wrong build profile</h3>
<pre><code class="language-bash">eas build --profile development --platform ios
# ^ Targets a physical device by default. For the simulator:
eas build --profile development --platform ios --local
</code></pre>
<p>The official <a href="https://docs.expo.dev/build-reference/troubleshooting/">Expo build troubleshooting page</a> covers most of the specific error strings.</p>
<h2>2. Supabase RLS returns empty arrays</h2>
<p>The symptom is confusing because nothing errors:</p>
<pre><code class="language-typescript">const { data, error } = await supabase.from('projects').select('*');
// data: []   error: null
</code></pre>
<p>The user is signed in. The row exists. The client returns nothing.</p>
<h3>Debug ladder</h3>
<pre><code class="language-typescript">// 1. Confirm the session is real
const { data: { session } } = await supabase.auth.getSession();
console.log('user id:', session?.user?.id);

// 2. Log the access token as it changes
supabase.auth.onAuthStateChange((_, session) =&gt; {
  console.log('access token:', session?.access_token?.slice(0, 20));
});

// 3. Query with a hardcoded filter
await supabase.from('projects').select('*').eq('user_id', session.user.id);
</code></pre>
<p>If step 3 returns rows but the unfiltered query didn't, the RLS policy isn't reading <code>auth.uid()</code>. Check the policy definition:</p>
<pre><code class="language-sql">create policy "users see own projects"
on projects for select
using (auth.uid() = user_id);
</code></pre>
<p>The most common mistake is writing the policy correctly but forgetting to enable RLS on the table itself. Policies then don't apply at all, and <em>everything</em> is returned for anon-key requests.</p>
<p>The <a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS documentation</a> is the canonical reference, but the mental-model shift — policies are <code>WHERE</code> clauses that always run — is the hard part. For the auth layer specifically (session persistence, OAuth, Apple Sign In), Applighter's <a href="https://www.applighter.com/blog/the-complete-guide-to-supabase-auth-in-react-native-email-o-auth-apple-sign?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">complete guide to Supabase auth in React Native</a> covers the full pattern.</p>
<h2>3. Environment variables not reaching the production binary</h2>
<p>Expo does <strong>not</strong> automatically pull <code>.env</code> into a production build. Two rules apply:</p>
<ol>
<li><p>Any variable read at runtime in the JS bundle must be prefixed <code>EXPO_PUBLIC_</code>.</p>
</li>
<li><p>Secrets must be uploaded to <a href="https://docs.expo.dev/eas/environment-variables/">EAS Secrets</a> or declared in <code>eas.json</code>.</p>
</li>
</ol>
<pre><code class="language-json">{
  "build": {
    "production": {
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.example.com"
      }
    }
  }
}
</code></pre>
<p>The tell-tale symptom: <code>console.log(process.env.API_URL)</code> prints correctly in development and prints <code>undefined</code> after <code>eas build --profile production</code>. Anything without the <code>EXPO_PUBLIC_</code> prefix is dropped from the client bundle at build time.</p>
<h2>4. Metro cache poisoning after a dependency bump</h2>
<p>Every project should ship a reset script:</p>
<pre><code class="language-json">{
  "scripts": {
    "reset": "watchman watch-del-all &amp;&amp; rm -rf node_modules ios/Pods .expo &amp;&amp; bun install &amp;&amp; bunx expo prebuild --clean"
  }
}
</code></pre>
<p>A large share of Metro tickets close the moment the developer runs <code>bun reset</code> rather than typing the sequence by hand. Metro's cache invalidation is aggressive and occasionally wrong — <a href="https://facebook.github.io/metro/docs/troubleshooting">Metro's own troubleshooting docs</a> acknowledge as much.</p>
<h2>5. iOS Info.plist permission strings missing</h2>
<p>Add <code>NSCameraUsageDescription</code>, <code>NSMicrophoneUsageDescription</code>, and any other required usage strings in <code>app.json</code> under <code>ios.infoPlist</code>:</p>
<pre><code class="language-json">{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "Used to scan meals for the calorie tracker.",
        "NSMicrophoneUsageDescription": "Used to record voice notes for transcription."
      }
    }
  }
}
</code></pre>
<p>Apple's review team rejects vague strings. Each description should say <em>why</em> the permission is needed, not just that it is.</p>
<h2>6. Android package rename breaks the build</h2>
<p>Change <code>android.package</code> in <code>app.json</code>, then run <code>expo prebuild --clean</code>. Don't hand-edit <code>AndroidManifest.xml</code> — the safest approach on managed Expo is to delete the <code>android/</code> folder, change the config, and let the tooling regenerate it. Applighter's <a href="https://www.applighter.com/blog/how-to-change-app-name?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">app renaming guide</a> lists every location the name appears.</p>
<h2>7. Android push notifications silently dropping</h2>
<p>Three things to check:</p>
<ul>
<li><p>FCM credentials are configured in the Expo dashboard.</p>
</li>
<li><p>Notification channels are created in code (required on Android 8+).</p>
</li>
<li><p>Battery optimisation on Xiaomi and Samsung devices kills background delivery. This can't be fixed in code; document it for end users.</p>
</li>
</ul>
<p>The full stack — credentials, channels, token registration, and testing on both platforms — is covered in Applighter's <a href="https://www.applighter.com/blog/the-definitive-guide-to-push-notifications-in-expo-i-os-android-in-production?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">definitive guide to push notifications in Expo</a>.</p>
<h2>8. AI streaming stalls or drops tokens</h2>
<p>Use <code>expo/fetch</code> (SDK 52+), not the global <code>fetch</code>, for <code>ReadableStream</code> support. React Native's fetch polyfill doesn't stream reliably.</p>
<pre><code class="language-typescript">import { fetch } from 'expo/fetch';

const res = await fetch(url, { method: 'POST', body });
const reader = res.body?.getReader();
</code></pre>
<p>Applighter's <a href="https://www.applighter.com/blog/streaming-ai-responses-to-react-native-the-complete-guide?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">guide to streaming AI responses to React Native</a> walks through the full pattern, including a Server-Sent Events fallback.</p>
<h2>9. Stripe → Supabase Edge Function webhook fails</h2>
<ul>
<li><p>Verify the signing secret is set: <code>supabase secrets set STRIPE_WEBHOOK_SECRET=...</code></p>
</li>
<li><p>Mark the Edge Function as public so Stripe can reach it without a JWT.</p>
</li>
<li><p>Keep a <code>test-webhook.sh</code> that fires a dummy <code>checkout.session.completed</code> event, so the integration can be tested without a real purchase.</p>
</li>
</ul>
<h2>10. Voice transcription latency on mid-range Android</h2>
<ul>
<li><p>Chunk audio at roughly 4-second intervals rather than sending whole recordings.</p>
</li>
<li><p>Fall back to a smaller model on lower-end devices.</p>
</li>
<li><p>Show a token-level typing indicator so the interface doesn't feel dead while waiting.</p>
</li>
</ul>
<h2>The meta observation</h2>
<p>Nine of the top ten problems live at the seam between React Native and its environment. Not one is a React Native re-render, hook, or bundler internal.</p>
<p>The framework has stabilised. The perimeter — build tooling, backend policies, native permissions, AI providers, payment webhooks — is where the bleeding happens.</p>
<p>For anyone evaluating a starter kit, the practical takeaway is to pick one whose vendor sees this kind of ticket data and iterates on it. A template that hasn't had a commit in six months isn't stable; it's abandoned.</p>
]]></content:encoded></item><item><title><![CDATA[We Ranked a Year of React Native Support Tickets]]></title><description><![CDATA[The premise
For twelve months I've been running Applighter's support inbox and tagging every incoming ticket by platform, layer, and severity. Every ticket. Not just the interesting ones. Every "the b]]></description><link>https://sarah-dyne.hashnode.dev/we-ranked-a-year-of-react-native-support-tickets</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/we-ranked-a-year-of-react-native-support-tickets</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Fri, 25 Sep 2026 13:38:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/a3f7d121-6861-49aa-9d10-7c7707572793.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The premise</h2>
<p>For twelve months I've been running Applighter's support inbox and tagging every incoming ticket by platform, layer, and severity. Every ticket. Not just the interesting ones. Every "the build won't run" ticket, every "the RLS policy is doing something weird" ticket, every "why is my white screen white" ticket. By the end of the year the tags had shape. Support tickets aren't just noise — they're the closest thing to a research instrument that a template vendor gets.</p>
<p>This is what those tickets told me about what React Native developers actually struggle with in production.</p>
<h2>Methodology</h2>
<p>Every ticket got three tags:</p>
<ul>
<li><p><code>platform:</code> — one of <code>ios</code>, <code>android</code>, <code>web</code></p>
</li>
<li><p><code>layer:</code> — one of <code>build</code>, <code>backend</code>, <code>ui</code>, <code>auth</code>, <code>payment</code>, <code>ai</code></p>
</li>
<li><p><code>severity:</code> — one of <code>blocker</code>, <code>degraded</code>, <code>question</code></p>
</li>
</ul>
<p>We threw out feature requests, refund questions, and "when will X ship" tickets. What remained: reproducible, teachable problems where a developer got stuck.</p>
<p>The data spans:</p>
<ul>
<li><p>7 Applighter templates: <code>weather-app</code>, <code>fitness-app</code>, <code>e-learning-app</code>, <code>taxi-booking-app</code>, <code>ai-calorie-tracker</code>, <code>ai-voice-notes</code>, <code>chat-with-pdf</code></p>
</li>
<li><p>Expo SDK 50 through SDK 53</p>
</li>
<li><p>iOS 16 through iOS 18</p>
</li>
<li><p>Android 12 through Android 15</p>
</li>
<li><p>Supabase versions across the year</p>
</li>
<li><p>A rotating cast of AI providers (OpenAI, Anthropic, Deepgram, Whisper)</p>
</li>
</ul>
<h2>The ranked results</h2>
<table>
<thead>
<tr>
<th>Rank</th>
<th>Problem</th>
<th>Share</th>
<th>Layer</th>
<th>Blocker rate</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>EAS Build fails on first run</td>
<td>18.4%</td>
<td>build</td>
<td>92%</td>
</tr>
<tr>
<td>2</td>
<td>Supabase RLS empty arrays</td>
<td>12.1%</td>
<td>backend</td>
<td>71%</td>
</tr>
<tr>
<td>3</td>
<td>Env vars missing in prod binary</td>
<td>9.7%</td>
<td>build</td>
<td>88%</td>
</tr>
<tr>
<td>4</td>
<td>Metro cache poisoning</td>
<td>7.8%</td>
<td>build</td>
<td>40%</td>
</tr>
<tr>
<td>5</td>
<td>iOS Info.plist rejections</td>
<td>6.9%</td>
<td>ios</td>
<td>95%</td>
</tr>
<tr>
<td>6</td>
<td>Android package name rename</td>
<td>6.2%</td>
<td>android</td>
<td>65%</td>
</tr>
<tr>
<td>7</td>
<td>Android push silently dropping</td>
<td>5.8%</td>
<td>native</td>
<td>50%</td>
</tr>
<tr>
<td>8</td>
<td>AI streaming stalls</td>
<td>5.1%</td>
<td>ai</td>
<td>40%</td>
</tr>
<tr>
<td>9</td>
<td>Stripe → Supabase webhook</td>
<td>4.4%</td>
<td>payment</td>
<td>90%</td>
</tr>
<tr>
<td>10</td>
<td>Android STT latency</td>
<td>3.9%</td>
<td>ai</td>
<td>15%</td>
</tr>
</tbody></table>
<p>"Blocker rate" is the share of tickets in that category where the developer literally could not ship — i.e., no workaround.</p>
<h2>What each one actually is</h2>
<h3>1. EAS build fails on first run</h3>
<p>The single biggest source of pain in the ecosystem, and — this is the interesting part — none of it is React Native's fault.</p>
<pre><code class="language-bash">$ eas build --profile production --platform ios
✔ Compressing project files and uploading to EAS Build
✖ Install pods
   › Pod install failed
</code></pre>
<p>Three root causes account for 90% of these:</p>
<p><strong>Root cause A: Expo SDK / eas-cli version drift.</strong> The dev cloned an SDK 50 template into an SDK 53 CLI, or vice versa. Fix:</p>
<pre><code class="language-bash">bunx expo install --check
bunx expo doctor
</code></pre>
<p>Every red line in <code>expo doctor</code> is a candidate.</p>
<p><strong>Root cause B: iOS credentials never generated.</strong> EAS asks for these interactively on first build, but if you're in CI, the prompt vanishes and the build dies.</p>
<pre><code class="language-bash">eas credentials
</code></pre>
<p>Run this locally, verify the profile is generated in App Store Connect, then re-run the build.</p>
<p><strong>Root cause C: Wrong profile.</strong> If you're just trying to test in the simulator, you don't want <code>--profile production</code>. You want <code>--profile development --local</code>.</p>
<p>We added an <code>expo doctor</code> preflight to every template's <code>postinstall</code> after seeing this pattern for the tenth time. Ticket volume in this category dropped 42% the following quarter.</p>
<h3>2. Supabase RLS returns empty arrays</h3>
<p>This one has a specific mental model shift that has to happen for a developer before the problem clicks.</p>
<p>The mental model: <strong>RLS policies are</strong> <code>WHERE</code> <strong>clauses that always run</strong>. Every query, silently, gets a <code>WHERE</code> appended based on the policy. If the policy's <code>WHERE</code> filters out every row, the query returns <code>[]</code>. The client doesn't get an error — the query "succeeded," it just returned zero rows.</p>
<p>The debug ladder:</p>
<pre><code class="language-typescript">// Step 1: Verify session
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('No session — auth failed');

// Step 2: Verify JWT is being sent
// Enable network logs, look for Authorization: Bearer &lt;token&gt;
// on the request to /rest/v1/projects

// Step 3: Verify the policy matches the row
// In SQL editor:
// select auth.uid();  -- what does the JWT resolve to?
// select user_id from projects limit 5;
// If auth.uid() != user_id, your policy will always filter.
</code></pre>
<p>The most common bug we see is developers writing:</p>
<pre><code class="language-sql">create policy "own projects"
on projects for select
to authenticated
using (user_id = auth.uid());
</code></pre>
<p>...and then forgetting to actually enable RLS:</p>
<pre><code class="language-sql">alter table projects enable row level security;
</code></pre>
<p>Without the <code>enable</code> statement, the policy exists but is inert.</p>
<p>If you want the full auth setup — email, OAuth, Apple Sign-In, and the RLS policies that go with each — our <a href="https://www.applighter.com/blog/the-complete-guide-to-supabase-auth-in-react-native-email-o-auth-apple-sign?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">Supabase auth guide for React Native</a> walks through it end to end.</p>
<h3>3. Environment variables not in production binary</h3>
<p>The tell: works in <code>expo start</code>, breaks in the standalone binary.</p>
<p>Expo has three environment variable systems that developers routinely confuse:</p>
<ol>
<li><p><code>process.env.FOO</code> — only bundled if <code>FOO</code> starts with <code>EXPO_PUBLIC_</code></p>
</li>
<li><p><code>Constants.expoConfig.extra.foo</code> — bundled from <code>app.json</code>'s <code>extra</code> field</p>
</li>
<li><p><strong>EAS Secrets</strong> — server-side only, used during the build itself, never reach the client</p>
</li>
</ol>
<p>The rule: if the client JS needs to read it at runtime, it must be <code>EXPO_PUBLIC_*</code>.</p>
<pre><code class="language-typescript">// ✅ Works in all environments
const API_URL = process.env.EXPO_PUBLIC_API_URL;

// ❌ Undefined in the production binary
const API_URL = process.env.API_URL;
</code></pre>
<h3>4. Metro bundler cache poisoning</h3>
<p>React Native's Metro bundler caches everything and its invalidation logic is famously imperfect. When you bump a dependency, sometimes Metro serves you stale bytes and the app crashes with a cryptic <code>Cannot resolve module</code> error.</p>
<p>Ship this script and tell users to run it whenever anything breaks after a dependency change:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail
watchman watch-del-all
rm -rf node_modules
rm -rf ios/Pods
rm -rf .expo
rm -rf $TMPDIR/metro-*
bun install
bunx expo prebuild --clean
</code></pre>
<p>We call ours <code>bun reset</code>. It resolves ~80% of "it was working yesterday" tickets in the Metro category.</p>
<h3>5. iOS Info.plist rejections</h3>
<p>Apple rejects builds that touch camera/mic/photos/location without a properly-worded <code>NSXxxUsageDescription</code> string.</p>
<pre><code class="language-json">{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "This app scans documents to extract text.",
        "NSMicrophoneUsageDescription": "This app records voice notes.",
        "NSPhotoLibraryUsageDescription": "This app lets you attach images to notes.",
        "NSLocationWhenInUseUsageDescription": "This app shows nearby stores."
      }
    }
  }
}
</code></pre>
<p>Two rules Apple applies aggressively in 2026:</p>
<ul>
<li><p>The string must explain <strong>why</strong> the app needs access.</p>
</li>
<li><p>Generic strings ("This app uses the camera") get rejected.</p>
</li>
</ul>
<h3>6. Android package name rename</h3>
<p>The template ships with <code>com.applighter.starter</code>. The dev wants <code>com.acme.myapp</code>. They change it in <code>app.json</code> and the Android build breaks.</p>
<p>The correct sequence:</p>
<pre><code class="language-bash"># 1. Update app.json
# "android": { "package": "com.acme.myapp" }

# 2. Nuke the existing android/ folder
rm -rf android

# 3. Let expo regenerate everything
bunx expo prebuild --clean --platform android
</code></pre>
<p>Do <strong>not</strong> hand-edit <code>AndroidManifest.xml</code> or the Java package folders. Let the tooling regenerate them.</p>
<h3>7. Android push silently dropping</h3>
<p>Configuration checklist for Android push in Expo:</p>
<ol>
<li><p>Firebase project created, <code>google-services.json</code> uploaded to the Expo dashboard</p>
</li>
<li><p>Notification channel created in JS (Android 8+ requirement)</p>
</li>
<li><p>Battery optimization exemption requested if you need reliable delivery on Xiaomi/Samsung</p>
</li>
<li><p>Foreground handler set separately from background handler</p>
</li>
</ol>
<pre><code class="language-typescript">import * as Notifications from 'expo-notifications';

// Android needs an explicit channel
if (Platform.OS === 'android') {
  await Notifications.setNotificationChannelAsync('default', {
    name: 'default',
    importance: Notifications.AndroidImportance.MAX,
  });
}

// Foreground handler
Notifications.setNotificationHandler({
  handleNotification: async () =&gt; ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});
</code></pre>
<p>That checklist is the short version. The long version — FCM setup, APNs keys, background handlers, and the OEM battery quirks — is in our <a href="https://www.applighter.com/blog/the-definitive-guide-to-push-notifications-in-expo-i-os-android-in-production?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">push notifications guide for Expo</a>.</p>
<h3>8. AI streaming stalls</h3>
<p>React Native's global <code>fetch</code> polyfill doesn't support the streaming Response body the way the browser does. The workaround shipping in most of our AI templates:</p>
<pre><code class="language-typescript">import { fetch as expoFetch } from 'expo/fetch';

const response = await expoFetch(url, { method: 'POST', body: JSON.stringify(payload) });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  handleChunk(chunk);
}
</code></pre>
<p>There's more to it in practice — SSE parsing, cancellation, and backpressure on slow devices — which we cover in the <a href="https://www.applighter.com/blog/streaming-ai-responses-to-react-native-the-complete-guide?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">streaming AI responses guide</a>.</p>
<h3>9. Stripe → Supabase webhook</h3>
<p>Four things to verify, in order:</p>
<ol>
<li><p><code>STRIPE_WEBHOOK_SECRET</code> is set in Supabase secrets and matches the endpoint's secret in the Stripe dashboard.</p>
</li>
<li><p>The Edge Function is marked public in <code>supabase/config.toml</code>:</p>
<pre><code class="language-toml">[functions.stripe-webhook]
verify_jwt = false
</code></pre>
</li>
<li><p>The webhook URL in Stripe points at the production Supabase URL, not a preview.</p>
</li>
<li><p>You've enabled the specific events (<code>checkout.session.completed</code>, <code>customer.subscription.updated</code>) in the Stripe dashboard.</p>
</li>
</ol>
<h3>10. Android STT latency</h3>
<p>Not a bug — physics. Real-time speech-to-text on a mid-range Android device is slower than on an iPhone Pro. The fix is UX, not code:</p>
<ul>
<li><p>Chunk audio in 4-second windows instead of 10-second.</p>
</li>
<li><p>Show a token-level typing indicator while transcription runs.</p>
</li>
<li><p>Fall back to a smaller/cheaper model tier on lower-end devices.</p>
</li>
</ul>
<h2>The meta observation</h2>
<p>Zero of the top ten problems are React Native itself. All ten are at the seam between React Native and the rest of the stack.</p>
<p>That's the story of the ecosystem in 2026. The framework is stable. Everything around it — build tooling, native permissions, backend policies, push infrastructure, AI providers, payment webhooks, platform-specific device quirks — is where the friction lives.</p>
<p>Templates that just give you screens don't help. Templates that ship real backend policies, real EAS configs, real webhook setups, and real device-tier fallbacks do. That's the bar we hold <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">every Applighter template</a> to, and this dataset is why.</p>
<h2>Read more</h2>
<p>The <a href="https://www.applighter.com/blog/what-react-native-devs-actually-struggle-with-a-year-of-support-tickets?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets">full canonical post</a> on Applighter includes the "how we changed our templates" section — the preflight scripts, the baked-in RLS migrations, and what each change did to ticket volume.</p>
<p>If you've hit one of these ten in your own project, I'd like to hear which one and how you got past it — the comments are open.</p>
]]></content:encoded></item><item><title><![CDATA[Docker Alternatives in 2026: What to Use Instead (and When You Don't Need a Container at All)]]></title><description><![CDATA[Docker made containers mainstream, but "Docker" is no longer one thing. There's the open container standard (OCI), the low-level runtime (containerd, runc), the CLI, and the desktop app with the licen]]></description><link>https://sarah-dyne.hashnode.dev/docker-alternatives-in-2026-what-to-use-instead-and-when-you-don-t-need-a-container-at-all</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/docker-alternatives-in-2026-what-to-use-instead-and-when-you-don-t-need-a-container-at-all</guid><category><![CDATA[Docker]]></category><category><![CDATA[Devops]]></category><category><![CDATA[podman]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[containers]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Thu, 24 Sep 2026 10:18:39 GMT</pubDate><content:encoded><![CDATA[<p>Docker made containers mainstream, but "Docker" is no longer one thing. There's the open container standard (OCI), the low-level runtime (<code>containerd</code>, <code>runc</code>), the CLI, and the desktop app with the licensing terms that pushed a lot of teams to look elsewhere.</p>
<p>This guide walks through the alternatives that are actually worth your time, grouped by the problem they solve, with the commands you need to try each one. At the end, we'll cover a less obvious option: for some workloads, the best Docker alternative is not running a container stack at all.</p>
<h2>Why people look for Docker alternatives</h2>
<p>Before picking a replacement, it helps to name the specific pain. Most complaints fall into one of these buckets:</p>
<ul>
<li><p><strong>Licensing.</strong> Docker Desktop requires a paid subscription for commercial use at companies above roughly 250 employees or $10M in annual revenue. The engine itself is free; the desktop app is not.</p>
</li>
<li><p><strong>Resource usage on macOS and Windows.</strong> Docker Desktop runs a Linux VM. Idle memory in the 1–2 GB range and slow startup are common complaints, especially on laptops.</p>
</li>
<li><p><strong>Root daemon.</strong> The Docker daemon runs as root. For security-conscious teams, that's a large attack surface sitting on every developer machine.</p>
</li>
<li><p><strong>Heavy local stacks.</strong> Some projects (local Supabase is a well-known example) pull in a dozen containers and gigabytes of images just to run a backend on your laptop.</p>
</li>
</ul>
<p>Different alternatives fix different buckets, so keep your bucket in mind as you read.</p>
<h2>The mental model: engine vs. desktop app vs. build tool</h2>
<p>Almost every "Docker alternative" replaces only one layer of the stack:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What Docker gives you</th>
<th>Alternatives</th>
</tr>
</thead>
<tbody><tr>
<td>Desktop app (VM + GUI on macOS/Windows)</td>
<td>Docker Desktop</td>
<td>OrbStack, Colima, Rancher Desktop, Lima, Finch</td>
</tr>
<tr>
<td>Container engine / CLI</td>
<td><code>dockerd</code> + <code>docker</code></td>
<td>Podman, <code>nerdctl</code> + containerd</td>
</tr>
<tr>
<td>Image builder</td>
<td><code>docker build</code> / BuildKit</td>
<td>Buildah, Kaniko, <code>nerdctl build</code></td>
</tr>
<tr>
<td>Orchestration</td>
<td>Docker Compose, Swarm</td>
<td>Podman Compose, Kubernetes (k3s, kind)</td>
</tr>
<tr>
<td>The workload itself</td>
<td>A container</td>
<td>A single binary (see the last section)</td>
</tr>
</tbody></table>
<p>Because everything speaks OCI, your existing images and Dockerfiles work with all of these. What changes is the tooling around them.</p>
<h2>Podman: the daemonless drop-in</h2>
<p>Podman is the most direct replacement for the Docker engine. It runs containers as child processes of your user rather than through a root daemon, and it's fully OCI-compatible.</p>
<p>Install and alias:</p>
<pre><code class="language-bash"># macOS
brew install podman
podman machine init
podman machine start

# Fedora / RHEL
sudo dnf install podman

# Ubuntu / Debian
sudo apt install podman

# Make existing scripts work unchanged
alias docker=podman
</code></pre>
<p>Everyday commands are identical:</p>
<pre><code class="language-bash">podman pull nginx:alpine
podman run -d -p 8080:80 --name web nginx:alpine
podman ps
podman logs web
podman stop web &amp;&amp; podman rm web
</code></pre>
<p><strong>Rootless by default.</strong> No <code>sudo</code>, no daemon. Each container is a process owned by you, which means a container escape lands in your user account, not root.</p>
<p><strong>Pods.</strong> Podman has a native concept of pods (groups of containers sharing a network namespace), and can generate Kubernetes YAML from a running pod:</p>
<pre><code class="language-bash">podman pod create --name app -p 3000:3000
podman run -d --pod app --name api node:20 node server.js
podman run -d --pod app --name cache redis:7
podman generate kube app &gt; app.yaml
</code></pre>
<p><strong>Compose support.</strong> <code>podman compose</code> wraps Docker Compose or <code>podman-compose</code>, so most <code>docker-compose.yml</code> files work as-is. Expect occasional rough edges with less common Compose features.</p>
<p><strong>Best for:</strong> teams that want a free, open-source engine with a better security story, and Linux-first workflows. Podman Desktop adds a GUI if you want one.</p>
<h2>OrbStack: the fast option on macOS</h2>
<p>OrbStack is a macOS-only replacement for Docker Desktop. It ships its own lightweight VM layer and a Docker-compatible engine, so <code>docker</code> and <code>docker compose</code> commands work unchanged.</p>
<pre><code class="language-bash">brew install orbstack
# Then just use docker as normal
docker run -it --rm alpine sh
</code></pre>
<p>What you get over Docker Desktop:</p>
<ul>
<li><p>Startup in a second or two instead of tens of seconds</p>
</li>
<li><p>Noticeably lower idle memory</p>
</li>
<li><p>File sharing and port forwarding that "just work" without configuration</p>
</li>
<li><p>Lightweight Linux machines (<code>orb create ubuntu</code>) alongside containers</p>
</li>
<li><p>Optional local Kubernetes</p>
</li>
</ul>
<p>The catch: OrbStack is free for personal use but paid for commercial use. It's cheaper than Docker Desktop's business tier, but it's not open source, so it doesn't solve the "free at any company size" requirement.</p>
<p><strong>Best for:</strong> individual macOS developers who care about speed and battery life.</p>
<h2>Colima and Lima: minimal, terminal-first</h2>
<p>Lima runs Linux VMs on macOS (and Linux) with automatic file sharing and port forwarding. Colima is a thin wrapper around Lima that sets up a container runtime for you.</p>
<pre><code class="language-bash">brew install colima docker

# Default: dockerd inside a Lima VM
colima start

# Or use containerd + nerdctl
colima start --runtime containerd

# Tune resources
colima start --cpu 4 --memory 8 --disk 60

# With Kubernetes
colima start --kubernetes
</code></pre>
<p>Once it's running, the normal <code>docker</code> CLI points at Colima's socket and everything works. No GUI, no license, no surprises.</p>
<p>If you want more control, use Lima directly and write your own VM template. Lima also underpins several other tools on this list, including Rancher Desktop and Finch.</p>
<p><strong>Best for:</strong> developers who want the smallest free setup on a Mac and don't need a GUI.</p>
<h2>Rancher Desktop: containers plus local Kubernetes</h2>
<p>Rancher Desktop (from SUSE) bundles a container runtime, a GUI, and a local Kubernetes cluster (k3s). You can switch the runtime between <code>containerd</code> and <code>dockerd</code> from the settings panel.</p>
<pre><code class="language-bash">brew install --cask rancher

# With containerd runtime
nerdctl run -d -p 8080:80 nginx

# With dockerd runtime
docker run -d -p 8080:80 nginx

# Kubernetes is already there
kubectl get nodes
</code></pre>
<p>It's heavier than Colima or OrbStack because Kubernetes is always part of the package, but if your production target is Kubernetes, having a matching local cluster with zero extra setup is worth it.</p>
<p><strong>Best for:</strong> teams deploying to Kubernetes who want dev and prod to match.</p>
<h2>Finch and nerdctl: the containerd-native route</h2>
<p><code>containerd</code> is the runtime underneath Docker, Kubernetes, and most cloud platforms. <code>nerdctl</code> is a Docker-compatible CLI that talks to containerd directly, and Finch (from AWS) bundles Lima + containerd + nerdctl + BuildKit into one install.</p>
<pre><code class="language-bash">brew install --cask finch
finch vm init
finch vm start

finch run -d -p 8080:80 nginx
finch compose up
</code></pre>
<p>The appeal is simplicity of the stack: no Docker engine at all, just the runtime that everything else already standardizes on.</p>
<p><strong>Best for:</strong> teams that want to align local tooling with what runs in their Kubernetes clusters.</p>
<h2>Buildah and Kaniko: building images without a daemon</h2>
<p>If your problem is CI, not local development, you may not need a full engine at all.</p>
<p><strong>Buildah</strong> builds OCI images from Dockerfiles (or step by step from a script) with no daemon:</p>
<pre><code class="language-bash">buildah bud -t myapp:latest .
buildah push myapp:latest docker://registry.example.com/myapp:latest
</code></pre>
<p><strong>Kaniko</strong> builds images inside a Kubernetes pod or CI job with no privileged access:</p>
<pre><code class="language-yaml"># Example CI step
- name: Build with Kaniko
  image: gcr.io/kaniko-project/executor:latest
  args:
    - --dockerfile=Dockerfile
    - --context=.
    - --destination=registry.example.com/myapp:${CI_COMMIT_SHA}
</code></pre>
<p>Both let you drop Docker-in-Docker from your pipelines, which removes a privileged container from your build infrastructure.</p>
<p><strong>Best for:</strong> CI/CD pipelines and hardened build environments.</p>
<h2>Apple's native container tooling</h2>
<p>Since macOS 15.4, Apple ships a native <code>container</code> framework that runs Linux containers in lightweight VMs, one VM per container. It's still early and the ecosystem is thin compared to the tools above, but it's worth watching if you're on Apple Silicon and want the lowest possible overhead without third-party VM layers.</p>
<pre><code class="language-bash">container system start
container run -it alpine sh
</code></pre>
<p><strong>Best for:</strong> experimentation today; possibly the default on macOS in a couple of years.</p>
<h2>Sometimes the alternative to Docker is no container at all</h2>
<p>Everything above assumes the thing you're running <em>should</em> be a container. For a lot of local development, that's true. But there's a category of workloads where the container stack itself is the overhead, and the better fix is a tool that doesn't need one.</p>
<p>Local Supabase is the clearest example. <code>supabase start</code> pulls a 12-container Docker stack (Postgres, PostgREST, GoTrue, Storage, Realtime, and more) weighing over 2 GB in images, all so you can run your app against a database on your laptop. If you're on a constrained machine, in a locked-down corporate environment, or trying to run inside a browser, that stack is somewhere between painful and impossible.</p>
<p><a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=docker-alternatives">Tinbase</a> takes the opposite approach: it reimplements the Supabase wire protocols (PostgREST, GoTrue, Storage, Realtime) in a single process backed by an embedded Postgres 17. No Docker, no VM, one command:</p>
<pre><code class="language-bash">npx tinbase start
</code></pre>
<p>Your existing <code>supabase-js</code> code works unchanged, because the API surface is the same:</p>
<pre><code class="language-javascript">import { createClient } from '@supabase/supabase-js'

// Point at Tinbase locally, hosted Supabase in production
const supabase = createClient(
  process.env.SUPABASE_URL,   // e.g. http://localhost:54321
  process.env.SUPABASE_ANON_KEY
)

const { data, error } = await supabase
  .from('todos')
  .select('*')
  .order('created_at', { ascending: false })
</code></pre>
<p>It reads the same <code>supabase/migrations/*.sql</code> files as the Supabase CLI, so nothing is a dead end: when you're ready to deploy, push the same migrations to hosted Supabase. RLS policies, <code>auth.uid()</code>, triggers, and foreign keys behave like real Postgres because it <em>is</em> real Postgres.</p>
<p>A few honest notes:</p>
<ul>
<li><p>It's MIT-licensed and open source.</p>
</li>
<li><p>It's currently alpha, built for local dev, prototypes, and embedded use, not production.</p>
</li>
<li><p>It also offers WASM and in-memory engines, and the whole backend can run inside a browser tab, which is the origin story of the project.</p>
</li>
</ul>
<p>If your "Docker problem" is specifically "I don't want to run twelve containers to develop against Supabase," <a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=docker-alternatives">Tinbase</a> is the more direct fix than any container engine.</p>
<p>The general lesson applies beyond Supabase: before you swap Docker Desktop for a different VM manager, ask whether the workload needs a container at all. Embedded databases (SQLite, PGlite, embedded Postgres), single-binary services, and in-process runtimes often remove the problem instead of relocating it.</p>
<h2>Decision guide</h2>
<table>
<thead>
<tr>
<th>Your situation</th>
<th>Pick this</th>
</tr>
</thead>
<tbody><tr>
<td>Company is over the Docker Desktop license threshold, need free at any size</td>
<td>Podman (+ Podman Desktop for a GUI)</td>
</tr>
<tr>
<td>Individual macOS dev, want the fastest experience</td>
<td>OrbStack</td>
</tr>
<tr>
<td>Want the lightest free setup on a Mac, no GUI needed</td>
<td>Colima</td>
</tr>
<tr>
<td>Deploy to Kubernetes, want local parity</td>
<td>Rancher Desktop</td>
</tr>
<tr>
<td>Want to standardize on containerd everywhere</td>
<td>Finch / nerdctl</td>
</tr>
<tr>
<td>Need to build images in CI without privileged Docker</td>
<td>Buildah or Kaniko</td>
</tr>
<tr>
<td>Running local Supabase and the container stack is the problem</td>
<td><a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=docker-alternatives">Tinbase</a></td>
</tr>
</tbody></table>
<h2>Migration tips</h2>
<p>A few things that trip people up when switching:</p>
<ol>
<li><p><strong>Check the socket path.</strong> Tools like Testcontainers, VS Code Dev Containers, and some CLIs assume <code>/var/run/docker.sock</code>. Set <code>DOCKER_HOST</code> to the new socket (Colima and Podman both print it on startup).</p>
</li>
<li><p><strong>Volume mount performance differs.</strong> OrbStack and Colima use different file-sharing mechanisms than Docker Desktop. If a mounted <code>node_modules</code> suddenly feels slow, try excluding it or using a named volume.</p>
</li>
<li><p><strong>Compose quirks.</strong> Podman and nerdctl support the vast majority of Compose, but test your <code>depends_on</code> conditions and <code>healthcheck</code> blocks before assuming parity.</p>
</li>
<li><p><strong>Pick one per team.</strong> The savings from any of these tools are small compared to the cost of five engineers on five different local setups. Agree on a default and document it.</p>
</li>
</ol>
<h2>Wrapping up</h2>
<p>The container ecosystem is healthier than it was five years ago precisely because Docker stopped being the only option. Podman covers the security and licensing case, OrbStack and Colima cover the macOS performance case, Rancher Desktop and Finch cover the Kubernetes and containerd cases, and Buildah and Kaniko cover CI.</p>
<p>And for the growing set of workloads that were only ever containerized for convenience, tools like Tinbase show that the right answer can be a single process you run with <code>npx</code>.</p>
<p>If you've switched away from Docker Desktop, or you've found a workload that didn't need containers after all, I'd like to hear what you landed on.</p>
]]></content:encoded></item><item><title><![CDATA[Build vs Buy a React Native template - what founders miss]]></title><description><![CDATA[The wrong question, asked confidently
Every week another founder pings me with the same message. Two paragraphs about their MVP, then the ask: "Do we build our React Native app from scratch or buy a t]]></description><link>https://sarah-dyne.hashnode.dev/build-vs-buy-a-react-native-template-what-founders-miss</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/build-vs-buy-a-react-native-template-what-founders-miss</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[startup]]></category><category><![CDATA[supabase]]></category><category><![CDATA[mobile app development]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Wed, 23 Sep 2026 12:44:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/13449ce7-6349-4edd-8b89-4ffaa33ce82b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The wrong question, asked confidently</h2>
<p>Every week another founder pings me with the same message. Two paragraphs about their MVP, then the ask: <strong>"Do we build our React Native app from scratch or buy a template?"</strong></p>
<p>I tell all of them the same thing: that's the wrong question. The build-vs-buy debate treats two options as opposites when they're actually complements, and the founders who ship fastest are the ones who figure that out first.</p>
<p>The right question has three parts, and none of them are "build or buy":</p>
<ol>
<li><p>Where is my differentiator actually located?</p>
</li>
<li><p>What's the 80% of my app that's identical to every other app?</p>
</li>
<li><p>What's the cheapest legal way to skip building that 80% at production quality?</p>
</li>
</ol>
<p>Answer those three and "build vs. buy" answers itself. Skip them and you'll spend a month arguing about it in Notion.</p>
<p><strong>The short answer:</strong> buy the 80% every mobile app needs (auth, payments, navigation, Supabase schema, EAS builds, push notifications) and <em>build</em> the 20% that's actually your product. Founders lose weeks here because they frame it as a purity contest instead of an engineering allocation problem.</p>
<h2>The founders who ask "build vs buy" have already lost a week</h2>
<p>Founders don't usually ask this question because they need an answer. They ask because they want permission to keep procrastinating. "Should we build or buy?" is the last-mile version of "should we use TypeScript?" — a decision with an obvious right answer 90% of the time, dressed up as if it deserved a design doc.</p>
<p>Here's the pattern across dozens of pre-launch teams:</p>
<ul>
<li><p>A founder writes a Notion doc weighing "custom stack vs. template."</p>
</li>
<li><p>The doc grows a comparison matrix.</p>
</li>
<li><p>A co-founder pushes back on the matrix.</p>
</li>
<li><p>Two weeks pass. No code is shipped.</p>
</li>
<li><p>Someone eventually just picks one and gets on with it.</p>
</li>
</ul>
<p>That eventual pick is almost always the right one. The two weeks were pure sunk cost. If you're currently in week one of that doc, close it and read the next section instead.</p>
<h2>Why the binary is broken: two apps in a trench coat</h2>
<p>Every consumer or prosumer mobile app is two apps in a trench coat. There's the <em>generic</em> app and there's the <em>specific</em> app, which is the reason your customers open it in the first place.</p>
<h3>Layer 1 — table stakes (the 80%)</h3>
<p>The generic app is the same everywhere. Nobody has ever pitched a VC on their password reset flow. Nobody gave you a five-star review because your dark mode was bespoke. Concretely, Layer 1 is:</p>
<ul>
<li><p>Email + OAuth + Apple Sign-In flows</p>
</li>
<li><p>Password reset with deep-linked email tokens</p>
</li>
<li><p>Row-Level Security policies on every table</p>
</li>
<li><p>Push notifications with permission prompts and delivery receipts</p>
</li>
<li><p>In-app purchases and/or Stripe checkout</p>
</li>
<li><p>Offline-safe state management and cache invalidation</p>
</li>
<li><p>Error boundaries, loading skeletons, empty states</p>
</li>
<li><p>Accessibility labels and dynamic type</p>
</li>
<li><p>EAS Build pipeline, code signing, TestFlight, Play Console</p>
</li>
<li><p>OTA updates via Expo Updates</p>
</li>
<li><p>Legal screens (privacy policy, terms), refund flows</p>
</li>
</ul>
<h3>Layer 2 — the differentiator (the 20%)</h3>
<p>The AI pipeline, the recipe DB, the matchmaker, the workout planner, the marketplace mechanic — whatever your investor pitch spends 90% of the deck on. It's 20% of the code and 100% of the reason your product exists.</p>
<p>When founders ask "build vs. buy," they're implicitly asking whether to write Layer 1 themselves or use someone else's. They're not asking about Layer 2 at all — which is telling, because Layer 2 is the only interesting question.</p>
<h2>A concrete cost estimate for Layer 1</h2>
<p>Let's do the math founders never do. Assume a mid-market React Native engineer at $80/hour. Here's the breakdown for the 80% you'd otherwise get from a well-scoped <a href="https://www.applighter.com/apps?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-a-react-native-template-what-founders-miss">full-stack React Native template</a>:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Optimistic hours</th>
<th>Realistic hours</th>
<th>At $80/hr (realistic)</th>
</tr>
</thead>
<tbody><tr>
<td>Auth (email + OAuth + Apple + reset)</td>
<td>40</td>
<td>90</td>
<td>$7,200</td>
</tr>
<tr>
<td>Supabase schema + RLS + migrations</td>
<td>30</td>
<td>70</td>
<td>$5,600</td>
</tr>
<tr>
<td>Push notifications (iOS + Android)</td>
<td>25</td>
<td>60</td>
<td>$4,800</td>
</tr>
<tr>
<td>Stripe + license grants</td>
<td>30</td>
<td>80</td>
<td>$6,400</td>
</tr>
<tr>
<td>Navigation + deep links + tab bar patterns</td>
<td>20</td>
<td>50</td>
<td>$4,000</td>
</tr>
<tr>
<td>EAS Build + submission automation</td>
<td>15</td>
<td>40</td>
<td>$3,200</td>
</tr>
<tr>
<td>Design system + dark mode + a11y</td>
<td>40</td>
<td>100</td>
<td>$8,000</td>
</tr>
<tr>
<td><strong>Total for Layer 1</strong></td>
<td><strong>200</strong></td>
<td><strong>490</strong></td>
<td><strong>~$39,200</strong></td>
</tr>
</tbody></table>
<p>Forty thousand dollars, before you write your first line of Layer 2. And "realistic" is generous — it assumes a senior engineer who has shipped push notifications on production Android before. If you don't have one, you're looking at the harder version of the same problem: <a href="https://www.applighter.com/blog/how-long-does-it-actually-take-to-ship-a-react-native-app-we-tracked-30-indie-d?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-a-react-native-template-what-founders-miss">how long it actually takes to ship a React Native app</a>. Spoiler: longer than you think.</p>
<p>Then there's the tail. Apple deprecates old push cert formats. Supabase changes its auth SDK's error shape. React Native ships a new architecture. You own all of it forever.</p>
<p>Now compare that to a modern full-stack template that actually ships Layer 1 — real Postgres schema, working auth, EAS pre-configured — at $79 to $299 one-time. The gap is not a rounding error. The gap is your seed round.</p>
<img src="https://images.unsplash.com/photo-1522542550221-31fd19575a2d" alt="Mobile developer debugging on laptop" style="display:block;margin:0 auto" />

<h2>Why "build from scratch" feels safer than it is</h2>
<p>Three reasons I hear, none of which survive contact with reality.</p>
<p><strong>"We need to control the code."</strong> You control code you write from scratch, sure. You also control the code you get with source-available templates. "Control" is not the same as "wrote from scratch."</p>
<p><strong>"Templates lock you in."</strong> Only if you buy the wrong template. A modular template — one where the AI provider, the backend, and the auth layer are cleanly separated — lets you swap Supabase for Firebase, or GPT-4o for Claude, without rewriting the app. Ask before you buy.</p>
<p><strong>"We'll build it exactly how we want it."</strong> You will build it exactly the wrong way once, then rewrite it. Every senior React Native engineer I know has shipped auth wrong at least once, and re-shipped push notifications the second time because Apple deprecated something. Templates carry that scar tissue for you.</p>
<p>The honest reason many founders build from scratch is that it feels more like being a real engineer. Great reason for a hackathon. Terrible reason for a company.</p>
<h2>Why "buy a template" fails when it fails</h2>
<p>The mirror trap is easier to spot but harder to admit to your co-founder. You bought a $59 template on a marketplace, threw it into a repo, and three weeks later you're stripping half of it out because:</p>
<p><strong>1. It was UI-only.</strong> The marketing copy said "full-stack," but what shipped was 20 screens of hardcoded mock data with no backend, no auth, no data layer. This is the most common failure mode in the sub-$60 tier.</p>
<p><strong>2. The license was booby-trapped.</strong> \(49 for a single-app license, then \)199 for multi-app, then $499 for an "extended" license that lets you ship in a paid app at all. Read licenses before you buy.</p>
<p><strong>3. The code was copy-paste soup.</strong> Three state-management libraries in one repo. Two navigation patterns. Class components mixed with function components. This is unmaintainable <em>and</em> it's what turns your AI agent into a paperweight — Cursor and Claude Code extend well-organized code and give up on soup.</p>
<p><strong>4. The vendor was dead.</strong> No commits in a year. React Native two majors behind. You'd have to do the upgrade before you started your product.</p>
<p><strong>5. The AI/backend layer was locked in.</strong> Hardcoded to a specific model or backend, no abstraction. When the model gets deprecated (roughly quarterly right now), you're doing a full migration.</p>
<p>That's what "template" means at the bottom of the market. It's why so many founders end up in the "just build it yourself" camp — not because building is smart, but because their first template was so bad that any custom code felt better.</p>
<p>The fix isn't to give up on templates. The fix is to only buy templates that don't have those failure modes.</p>
<h2>The comparison founders should actually be running</h2>
<p>Ignore the "custom vs. template" axis for a moment and compare <em>what you actually get in the repo on day one</em>:</p>
<table>
<thead>
<tr>
<th></th>
<th>Build from scratch</th>
<th>Cheap UI template</th>
<th>Marketplace template</th>
<th>Applighter template</th>
</tr>
</thead>
<tbody><tr>
<td>Time to first running app</td>
<td>2–6 weeks</td>
<td>3–7 days</td>
<td>3–7 days</td>
<td>Same afternoon</td>
</tr>
<tr>
<td>Time to MVP (real backend + auth)</td>
<td>8–16 weeks</td>
<td>6–10 weeks</td>
<td>5–9 weeks</td>
<td>1–3 weeks</td>
</tr>
<tr>
<td>Backend included</td>
<td>You build it</td>
<td>❌</td>
<td>Occasionally</td>
<td>✅ Supabase (Postgres + RLS + edge functions)</td>
</tr>
<tr>
<td>Auth flows shipped</td>
<td>You build them</td>
<td>Screens only</td>
<td>Screens only</td>
<td>✅ Real flows w/ OAuth + Apple</td>
</tr>
<tr>
<td>AI features pre-wired</td>
<td>❌</td>
<td>❌</td>
<td>❌</td>
<td>✅ Where relevant (voice, PDF, calorie)</td>
</tr>
<tr>
<td>Commercial license</td>
<td>You own it</td>
<td>Ambiguous</td>
<td>Per-app fees</td>
<td>✅ One-time, upgradeable</td>
</tr>
<tr>
<td>Agent-friendly code</td>
<td>Depends on your team</td>
<td>Rarely</td>
<td>Rarely</td>
<td>✅ Designed for Claude/Cursor</td>
</tr>
<tr>
<td>Lifetime updates</td>
<td>Your team maintains</td>
<td>❌</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Refund window</td>
<td>N/A</td>
<td>Rare</td>
<td>14 days</td>
<td>7 days (no questions)</td>
</tr>
</tbody></table>
<p>The interesting axis isn't "custom vs. template." It's "does this template ship a real product or a mood board?"</p>
<h2>The framework that actually works</h2>
<p>Ask one question, honestly:</p>
<blockquote>
<p>Where is my differentiator actually located?</p>
</blockquote>
<h3>Case A — Product-shaped</h3>
<p>Your differentiator is in the product logic: the AI pipeline, the marketplace matcher, the recipe DB, the workout planner, the custom onboarding funnel. This is roughly 90% of consumer and prosumer apps.</p>
<p><strong>Verdict:</strong> Buy a template. Skip Layer 1. Spend 100% of your engineering budget on Layer 2. This is not a compromise; this is the correct allocation. Building the 80% from scratch is a cost you're paying to feel more like a "real" engineer.</p>
<h3>Case B — Infrastructure-shaped</h3>
<p>Your differentiator is in the infrastructure: a novel native audio pipeline, a custom protocol, a privacy-preserving on-device ML model wired into the OS lifecycle. This is genuinely rare.</p>
<p><strong>Verdict:</strong> Build from scratch, or fork a template and rewrite the parts that matter. You're doing something the template author didn't imagine.</p>
<h3>Case C — Mixed</h3>
<p>Mostly product-shaped, but one or two infra pieces are genuinely custom. Your onboarding logic is bespoke, or your data model needs three tables the template doesn't have.</p>
<p><strong>Verdict:</strong> Buy a template and fork it aggressively. Rip out what you don't need and treat the rest as a scaffold. You still save the auth + Stripe + push weeks and keep the <a href="https://reactnative.dev/">React Native</a> navigation patterns, but you own the parts that matter. This is why the license structure of a template matters more than its price: a $49 template that legally lets you fork and ship is worth more than a "free" one with restrictive terms.</p>
<p>Most founders think they're in Case B. Most founders are in Case A. Be honest with yourself before you spend six weeks proving otherwise.</p>
<h2>Five signs you should NOT build from scratch</h2>
<ol>
<li><p><strong>No senior React Native engineer is committed full-time.</strong> Layer 1 requires production instincts you can't fake with docs.</p>
</li>
<li><p><strong>Your deadline is under eight weeks.</strong> You will not hit it. Templates buy back six of those weeks.</p>
</li>
<li><p><strong>Your product isn't infrastructure-shaped.</strong> If your pitch is a screen mock, not a systems diagram, you're a product company.</p>
</li>
<li><p><strong>You'd have to write auth, payments, and push anyway.</strong> These are 30% of the effort and 0% of your moat.</p>
</li>
<li><p><strong>Your AI agent is going to do most of the coding.</strong> Agents extend well-organized code with clear patterns. They flounder on greenfield sprawl.</p>
</li>
</ol>
<h2>Five signs you should NOT buy a template (or should return the one you bought)</h2>
<ol>
<li><p><strong>It's UI-only.</strong> No Postgres, no auth, no edge functions. That's a Figma export, not a product.</p>
</li>
<li><p><strong>The license is ambiguous or per-app.</strong> You'll pay more the moment you succeed.</p>
</li>
<li><p><strong>The code fights your agent.</strong> Inconsistent patterns, mixed state libraries, dead files.</p>
</li>
<li><p><strong>The vendor is dead.</strong> No commits in a year, no responses in Discord. You'll fork by month three.</p>
</li>
<li><p><strong>You can't swap the backend or AI provider.</strong> Vendor lock-in disguised as convenience.</p>
</li>
</ol>
<p>Applighter's <a href="https://www.applighter.com/#faq?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-a-react-native-template-what-founders-miss">FAQ</a> addresses the last point directly: templates should be modular, so you can swap GPT-4o for Claude or Whisper for Deepgram without rewriting the app. If the one you're evaluating fails that test, keep looking.</p>
<img src="https://images.unsplash.com/photo-1551288049-bebda4e38f71" alt="Team reviewing product roadmap" style="display:block;margin:0 auto" />

<h2>A worked example</h2>
<p>The <a href="https://www.applighter.com/apps/ai-voice-notes?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-a-react-native-template-what-founders-miss">AI Voice Notes template</a> at $79 is a useful example because it sits at the cheaper end of the "actually ships Layer 1" bracket. What you get in the repo:</p>
<ul>
<li><p>16+ production React Native + Expo screens, TypeScript everywhere.</p>
</li>
<li><p>A working Supabase backend: schema, RLS policies, edge functions, storage buckets.</p>
</li>
<li><p>The Layer 2 wiring for voice notes: recording, transcription, summarization.</p>
</li>
<li><p>EAS Build config for TestFlight and Play Console.</p>
</li>
<li><p>Single commercial license for one published app, upgradeable to multi-app.</p>
</li>
<li><p>Lifetime updates. No subscription.</p>
</li>
<li><p>A 7-day no-questions refund window.</p>
</li>
</ul>
<p>Median time from purchase to TestFlight for founders I've watched use it: two to three weeks. Compare that to the 8–16 week build-from-scratch estimate — that founder is still writing auth error handlers while the template founder is on the App Store. The Fitness App, ChatPDF, and AI Calorie Tracker templates follow the same architectural pattern if your vertical is different.</p>
<p>Applighter isn't the only vendor in this category — see our <a href="https://www.applighter.com/blog/best-react-native-boilerplates-in-2026-honest-comparison?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-vs-buy-a-react-native-template-what-founders-miss">honest boilerplate comparison</a> — but the axes are the same regardless: does it ship a real backend, does it hold up under an agent, and does the license let you succeed without punishing you?</p>
<h2>What to look for before you commit</h2>
<p>If you're evaluating a template — Applighter or otherwise — this is the checklist:</p>
<ol>
<li><p><strong>Full source, TypeScript-first.</strong> No obfuscation. No "pro-only" branches.</p>
</li>
<li><p><strong>Real backend included.</strong> Postgres schema, RLS, edge functions, migrations. Not just Firebase links in a README.</p>
</li>
<li><p><strong>One state management library, one navigation pattern.</strong> Consistency over cleverness. This is the "agent-friendly" test.</p>
</li>
<li><p><strong>Modular AI/backend layers.</strong> Swap providers without rewriting.</p>
</li>
<li><p><strong>Commercial license from day one.</strong> Ideally upgradeable if you succeed.</p>
</li>
<li><p><strong>Refund window that isn't hostile.</strong> A vendor confident in their product offers 7 days, no questions.</p>
</li>
<li><p><strong>Active vendor.</strong> Commits in the last 90 days. Responses in Discord/support within a business day.</p>
</li>
</ol>
<p>If a template fails any of the first four, it doesn't matter how nice the screens look — you'll rewrite the parts that matter within a month.</p>
<h2>FAQ</h2>
<p><strong>Is buying a React Native template always cheaper than building from scratch?</strong> For the 80% of functionality that's the same in every app, yes — usually by an order of magnitude. Templates that include a real backend (Supabase, auth, RLS, migrations) can save 200+ engineering hours. UI-only templates save less than you think, because you still have to build the hard parts.</p>
<p><strong>Can I customize a bought template after purchase?</strong> On a good template, yes — the code is yours to modify. On a bad template, the code is so tightly coupled that customization becomes a rewrite. This is why "full source, single commercial license" matters more than any feature list.</p>
<p><strong>How do I know if a template is agent-friendly?</strong> Check three things: (1) is TypeScript used consistently? (2) is state management centralized (TanStack Query, Zustand, or similar), not scattered? (3) does the file structure follow one convention across all screens? If yes to all three, Claude Code and Cursor can extend it. If not, they'll fight you.</p>
<p><strong>Do templates hurt my app's resale or acquisition value?</strong> No — acquirers care about metrics and code quality, not code origin. A working, well-organized codebase built on a template beats a scratch-built codebase with three abandoned rewrites.</p>
<p><strong>What if I need to swap the backend later?</strong> That's the question to ask <em>before</em> you buy. A modular template lets you swap Supabase for Firebase, or GPT-4o for Claude, without rewriting the app. A locked-in template forces a full migration. Ask the vendor before you pay.</p>
<h2>Reference reading</h2>
<ul>
<li><p><a href="https://docs.expo.dev/build/introduction/">Expo EAS Build documentation</a> — the build system you'll actually be using.</p>
</li>
<li><p><a href="https://supabase.com/docs/guides/auth/row-level-security">Supabase Row-Level Security</a> — the mental model you need before evaluating any template's data layer.</p>
</li>
<li><p><a href="https://reactnative.dev/">React Native New Architecture</a> — check whether your candidate template supports it.</p>
</li>
</ul>
<h2>Closing</h2>
<p>The build-vs-buy question is a symptom, not a decision. It's what founders reach for when they don't want to answer the harder question: <em>what am I actually building?</em> Answer that first. If your differentiator is product-shaped — and nine times out of ten it is — buy a template that ships a real Layer 1 and pour every remaining engineering hour into Layer 2. If your first template was bad, learn "this template was bad," not "all templates are bad."</p>
<p>If you can name your differentiator in one sentence, you already have your answer. And if you're mid-way through a Notion doc titled "Build vs. Buy Analysis," close the tab. Ship instead.</p>
]]></content:encoded></item><item><title><![CDATA[Single Binary Deploy: Ship Your Whole App as One File]]></title><description><![CDATA[Most deployment pain comes from the number of moving parts. A typical web service ships as a runtime, a dependency tree, a set of static assets, a config folder, and a container image that wraps all o]]></description><link>https://sarah-dyne.hashnode.dev/single-binary-deploy-ship-your-whole-app-as-one-file</link><guid isPermaLink="true">https://sarah-dyne.hashnode.dev/single-binary-deploy-ship-your-whole-app-as-one-file</guid><category><![CDATA[Go Language]]></category><category><![CDATA[Devops]]></category><category><![CDATA[deployment]]></category><category><![CDATA[Backend Development]]></category><category><![CDATA[Rust]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Sarah]]></dc:creator><pubDate>Tue, 22 Sep 2026 10:25:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad0e5d7565adab77e8b085/552c3467-17bd-4a76-8351-5181939c261d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most deployment pain comes from the number of moving parts. A typical web service ships as a runtime, a dependency tree, a set of static assets, a config folder, and a container image that wraps all of it. Every piece is one more thing that can drift between your laptop and production.</p>
<p>A single-binary deploy flips that model. You compile your entire application — server, templates, static files, migrations, even the database engine — into one executable. Deploying becomes <code>scp</code> and <code>restart</code>. Rolling back becomes <code>mv</code>.</p>
<p>This guide walks through how to build one, how to ship it, and where the approach breaks down.</p>
<h2>Why single binaries are back</h2>
<p>Single-binary deployment isn't new — it's how software shipped before containers. What changed is that modern toolchains made it practical for web services again:</p>
<ul>
<li><p><strong>Static compilation is the default</strong> in Go and Rust. No shared library surprises on the target machine.</p>
</li>
<li><p><strong>Embedded filesystems</strong> let you bake HTML, CSS, JS, and migrations into the executable.</p>
</li>
<li><p><strong>Embedded databases</strong> like SQLite (and LiteFS/Litestream for replication) remove the "separate DB server" requirement for many workloads.</p>
</li>
<li><p><strong>Cross-compilation</strong> means you build a Linux ARM64 binary from a Mac with one environment variable.</p>
</li>
</ul>
<p>The result is an artifact with zero runtime dependencies. If the binary runs on your staging box, it runs the same way on production, because there is nothing else to install.</p>
<h2>What goes into the binary</h2>
<p>Here's the mental model. Everything on the left collapses into the single file on the right:</p>
<table>
<thead>
<tr>
<th>Traditional deploy</th>
<th>Single-binary deploy</th>
</tr>
</thead>
<tbody><tr>
<td>Runtime (Node, Python, JVM)</td>
<td>Compiled in</td>
</tr>
<tr>
<td><code>node_modules</code> / <code>site-packages</code></td>
<td>Compiled in</td>
</tr>
<tr>
<td><code>/static</code> and <code>/templates</code> folders</td>
<td>Embedded via <code>embed</code> / <code>include_bytes!</code></td>
</tr>
<tr>
<td>SQL migration files</td>
<td>Embedded</td>
</tr>
<tr>
<td>Config files</td>
<td>Flags + env vars, defaults compiled in</td>
</tr>
<tr>
<td>Database server</td>
<td>SQLite embedded (optional)</td>
</tr>
<tr>
<td>Dockerfile + image</td>
<td>Not required</td>
</tr>
</tbody></table>
<h2>Building one in Go</h2>
<p>Go is the easiest place to start because <code>embed</code> is in the standard library. Here's a minimal but complete example.</p>
<h3>Project layout</h3>
<pre><code class="language-plaintext">myapp/
├── main.go
├── migrations/
│   └── 001_init.sql
├── static/
│   ├── app.css
│   └── app.js
└── templates/
    └── index.html
</code></pre>
<h3>Embedding assets</h3>
<pre><code class="language-go">package main

import (
    "database/sql"
    "embed"
    "html/template"
    "io/fs"
    "log"
    "net/http"

    _ "modernc.org/sqlite" // pure-Go SQLite, no CGO required
)

//go:embed static/*
var staticFS embed.FS

//go:embed templates/*.html
var templateFS embed.FS

//go:embed migrations/*.sql
var migrationFS embed.FS

func main() {
    db, err := sql.Open("sqlite", "app.db")
    if err != nil {
        log.Fatal(err)
    }
    if err := runMigrations(db); err != nil {
        log.Fatal(err)
    }

    tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))

    staticSub, _ := fs.Sub(staticFS, "static")
    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        tmpl.ExecuteTemplate(w, "index.html", nil)
    })

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
</code></pre>
<h3>Running embedded migrations</h3>
<pre><code class="language-go">func runMigrations(db *sql.DB) error {
    entries, err := migrationFS.ReadDir("migrations")
    if err != nil {
        return err
    }
    for _, e := range entries {
        sqlBytes, err := migrationFS.ReadFile("migrations/" + e.Name())
        if err != nil {
            return err
        }
        if _, err := db.Exec(string(sqlBytes)); err != nil {
            return err
        }
    }
    return nil
}
</code></pre>
<p>For anything beyond a toy, use a proper migration library like <code>golang-migrate</code> with its <code>iofs</code> source driver — it reads straight from <code>embed.FS</code> and tracks applied versions.</p>
<h3>Compiling for production</h3>
<pre><code class="language-bash">CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w" -o myapp .
</code></pre>
<p>Flag by flag:</p>
<ul>
<li><p><code>CGO_ENABLED=0</code> — fully static binary, no libc dependency</p>
</li>
<li><p><code>GOOS</code> / <code>GOARCH</code> — cross-compile for the target server</p>
</li>
<li><p><code>-trimpath</code> — strip local file paths from the binary</p>
</li>
<li><p><code>-ldflags="-s -w"</code> — strip symbol table and debug info (typically 30–40% smaller)</p>
</li>
</ul>
<p>You now have one file, usually 10–20 MB, that contains your whole app.</p>
<h2>The same idea in Rust</h2>
<p>Rust gets you there with <code>include_bytes!</code> / <code>include_str!</code> or the <code>rust-embed</code> crate:</p>
<pre><code class="language-rust">use rust_embed::RustEmbed;
use axum::{Router, routing::get, response::Html};

#[derive(RustEmbed)]
#[folder = "static/"]
struct Assets;

async fn index() -&gt; Html&lt;&amp;'static str&gt; {
    Html(include_str!("../templates/index.html"))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(index));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
</code></pre>
<p>Build with the musl target for a fully static Linux binary:</p>
<pre><code class="language-bash">rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
</code></pre>
<h2>Deploying the binary</h2>
<p>This is where the approach pays off. No registry, no image pull, no orchestrator.</p>
<h3>1. Copy it to the server</h3>
<pre><code class="language-bash">scp ./myapp deploy@server:/opt/myapp/myapp.new
</code></pre>
<h3>2. Swap it atomically</h3>
<pre><code class="language-bash">ssh deploy@server '
  cd /opt/myapp &amp;&amp;
  mv myapp myapp.prev &amp;&amp;
  mv myapp.new myapp &amp;&amp;
  sudo systemctl restart myapp
'
</code></pre>
<p><code>mv</code> on the same filesystem is atomic, so there's never a moment where a half-written binary is on disk.</p>
<h3>3. Run it under systemd</h3>
<pre><code class="language-ini"># /etc/systemd/system/myapp.service
[Unit]
Description=myapp
After=network.target

[Service]
User=deploy
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
Restart=always
RestartSec=2
Environment=PORT=8080
Environment=DATABASE_PATH=/opt/myapp/data/app.db

[Install]
WantedBy=multi-user.target
</code></pre>
<h3>4. Roll back in one command</h3>
<pre><code class="language-bash">ssh deploy@server 'cd /opt/myapp &amp;&amp; mv myapp.prev myapp &amp;&amp; sudo systemctl restart myapp'
</code></pre>
<p>If you'd rather not manage the server, VPS, and systemd side yourself, hosted options like <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=single-binary-deploy">RapidNative Deploy</a> take a compiled artifact and handle the hosting layer, so the "copy and restart" step becomes a push.</p>
<h2>Handling configuration</h2>
<p>Since there's no config folder shipping alongside the binary, configuration comes from two places:</p>
<pre><code class="language-go">port := flag.String("port", envOr("PORT", "8080"), "listen port")
dbPath := flag.String("db", envOr("DATABASE_PATH", "app.db"), "sqlite path")
flag.Parse()
</code></pre>
<pre><code class="language-go">func envOr(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}
</code></pre>
<p>Precedence: <strong>flag → env var → compiled default</strong>. Everything has a working default so the binary runs with zero setup, and everything can be overridden in production without touching the file.</p>
<h2>Versioning the binary</h2>
<p>Bake the version in at build time so you always know what's running:</p>
<pre><code class="language-bash">go build -ldflags="-s -w -X main.version=$(git describe --tags --always)" -o myapp .
</code></pre>
<pre><code class="language-go">var version = "dev"

http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, `{"status":"ok","version":%q}`, version)
})
</code></pre>
<p>Now <code>curl server/healthz</code> tells you exactly which commit is live.</p>
<h2>Where the model breaks</h2>
<p>Single-binary deploy is not universal. Be honest about the trade-offs:</p>
<table>
<thead>
<tr>
<th>Constraint</th>
<th>Impact</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Horizontal scaling with SQLite</strong></td>
<td>Writes go to one node. Use Litestream/LiteFS, or switch to Postgres once you outgrow it.</td>
</tr>
<tr>
<td><strong>Large asset bundles</strong></td>
<td>Embedding a 200 MB frontend build inflates every deploy. Serve heavy assets from a CDN instead.</td>
</tr>
<tr>
<td><strong>Interpreted languages</strong></td>
<td>Python/Node need bundlers like PyInstaller or <code>pkg</code>/<code>bun build --compile</code>. Works, but less clean than Go/Rust.</td>
</tr>
<tr>
<td><strong>Long-running upgrades</strong></td>
<td>You still need graceful shutdown (<code>http.Server.Shutdown</code>) to avoid dropping in-flight requests during restart.</td>
</tr>
<tr>
<td><strong>Multi-service systems</strong></td>
<td>If you have 12 services, a container orchestrator's scheduling and networking still earn their keep.</td>
</tr>
</tbody></table>
<p>The sweet spot is a monolith or a small number of services, with traffic that one or a few boxes can handle — which describes most products, especially early on.</p>
<h2>Pairing a single binary with a managed backend</h2>
<p>One common hybrid: keep the binary as the application layer, but move persistence and auth out to a managed backend so the node stays stateless and trivially replaceable. Services like <a href="https://www.tinbase.dev/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=single-binary-deploy">Tinbase</a> fit that role — the binary handles routing and rendering, the backend handles data, and you can run N identical copies behind a load balancer without thinking about SQLite replication.</p>
<p>For mobile-first products where the "binary" is a React Native app calling into your API, builders like <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=single-binary-deploy">Applighter</a> follow the same philosophy: one artifact, one deploy path, minimal infrastructure in between.</p>
<h2>A minimal deploy script to copy</h2>
<p>Putting it together, here's a <code>deploy.sh</code> you can drop into any Go project today:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

APP=myapp
HOST=deploy@your-server
DIR=/opt/$APP
VERSION=$(git describe --tags --always)

echo "Building $APP@$VERSION..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w -X main.version=$VERSION" -o "$APP" .

echo "Uploading..."
scp "$APP" "$HOST:$DIR/$APP.new"

echo "Swapping and restarting..."
ssh "$HOST" "cd $DIR &amp;&amp; mv $APP $APP.prev &amp;&amp; mv $APP.new $APP &amp;&amp; sudo systemctl restart $APP"

echo "Verifying..."
sleep 2
ssh "$HOST" "curl -sf localhost:8080/healthz"
echo
echo "Deployed $VERSION"
</code></pre>
<p>Twenty lines, no Docker, no YAML, and a rollback that's one <code>mv</code> away.</p>
<h2>Summary</h2>
<ul>
<li><p>A single-binary deploy compiles code, assets, templates, migrations, and optionally the database into one static executable.</p>
</li>
<li><p>Go's <code>embed</code> and Rust's <code>rust-embed</code> make this a few lines of code.</p>
</li>
<li><p>Deploy is <code>scp</code> + atomic <code>mv</code> + <code>systemctl restart</code>; rollback is the reverse.</p>
</li>
<li><p>Configuration comes from flags and env vars with compiled-in defaults.</p>
</li>
<li><p>It shines for monoliths and small service counts; it struggles with heavy horizontal write scaling and very large asset bundles.</p>
</li>
</ul>
<p>If you've been reaching for Docker by default, try shipping your next small service as a single binary. You may find the container was never solving a problem you actually had.</p>
]]></content:encoded></item></channel></rss>