<?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[My Thoughts]]></title><description><![CDATA[My Thoughts]]></description><link>https://smithchloe.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>My Thoughts</title><link>https://smithchloe.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 03 Oct 2026 13:11:53 GMT</lastBuildDate><atom:link href="https://smithchloe.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What Is Boilerplate Code? (And When a Starter Kit Is Actually Worth It)
]]></title><description><![CDATA[Every new project starts the same way. You create a folder, run an init command, and then spend the next few hours (or days) wiring up things that have nothing to do with the idea you were excited abo]]></description><link>https://smithchloe.hashnode.dev/what-is-boilerplate-code-and-when-a-starter-kit-is-actually-worth-it</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/what-is-boilerplate-code-and-when-a-starter-kit-is-actually-worth-it</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[boilerplate]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Thu, 01 Oct 2026 05:53:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/699c4aa9-3c4d-4725-bd58-637473be2691.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every new project starts the same way. You create a folder, run an init command, and then spend the next few hours (or days) wiring up things that have nothing to do with the idea you were excited about: routing, auth, environment variables, linting, theming, error handling, build config.</p>
<p>That repetitive, necessary-but-uninteresting code has a name: <strong>boilerplate</strong>.</p>
<p>In this guide, we'll cover what boilerplate really is, where it shows up, the hidden cost of writing it by hand, and how to decide between rolling your own setup, using a generator, or starting from a production-ready boilerplate.</p>
<h2>Where the word "boilerplate" comes from</h2>
<p>The term predates software. In the early 1900s, newspaper syndicates distributed ready-made stories and ads to local papers as pre-cast metal printing plates. Because those plates looked like the rolled steel used to build boilers, printers called them "boilerplate." Local editors couldn't change the text; they just dropped it into the page.</p>
<p>Software borrowed the idea: <strong>boilerplate is code you include in many places with little or no change</strong>, because the project needs it to function, not because it expresses anything unique about your product.</p>
<h2>What boilerplate looks like in practice</h2>
<p>Boilerplate shows up at three levels.</p>
<h3>1. Language-level boilerplate</h3>
<p>Some languages require ceremony just to express simple ideas. The classic example is a Java data class before records existed:</p>
<pre><code class="language-java">public class User {
    private final String name;
    private final String email;

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }

    // ...plus equals(), hashCode(), and toString()
}
</code></pre>
<p>Modern Java (16+) collapses all of that into one line:</p>
<pre><code class="language-java">public record User(String name, String email) {}
</code></pre>
<p>Language designers spend years removing this kind of boilerplate because it adds noise without adding meaning.</p>
<h3>2. Project-level boilerplate</h3>
<p>This is the setup every app needs before it can do anything useful. In a React Native app built with Expo Router, a root layout often ends up looking like this:</p>
<pre><code class="language-tsx">// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from '../src/providers/auth-provider';
import { ThemeProvider } from '../src/providers/theme-provider';

const queryClient = new QueryClient();

export default function RootLayout() {
  return (
    &lt;SafeAreaProvider&gt;
      &lt;QueryClientProvider client={queryClient}&gt;
        &lt;AuthProvider&gt;
          &lt;ThemeProvider&gt;
            &lt;Stack screenOptions={{ headerShown: false }} /&gt;
          &lt;/ThemeProvider&gt;
        &lt;/AuthProvider&gt;
      &lt;/QueryClientProvider&gt;
    &lt;/SafeAreaProvider&gt;
  );
}
</code></pre>
<p>None of these providers are your product. But skip one, and something breaks three weeks later.</p>
<h3>3. Configuration boilerplate</h3>
<p>Environment handling, type-safe config, lint rules, CI pipelines, and build profiles all fall here. A small but important example is validating environment variables at startup so the app fails loudly instead of silently:</p>
<pre><code class="language-ts">// src/config/env.ts
import { z } from 'zod';

const schema = z.object({
  EXPO_PUBLIC_API_URL: z.string().url(),
  EXPO_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
});

// Expo only inlines EXPO_PUBLIC_* variables when they are
// referenced statically, so list each one explicitly.
export const env = schema.parse({
  EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
  EXPO_PUBLIC_SUPABASE_ANON_KEY: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
});
</code></pre>
<p>That comment alone is the kind of detail you learn by getting burned once. Good boilerplate encodes those lessons so you don't have to relearn them on every project.</p>
<h2>Boilerplate vs. template vs. starter kit vs. framework</h2>
<p>These terms get used interchangeably, but they mean slightly different things:</p>
<table>
<thead>
<tr>
<th>Term</th>
<th>What it is</th>
<th>How much it decides for you</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Boilerplate (code)</strong></td>
<td>Repeated setup code inside a project</td>
<td>Low — it's just the code</td>
<td>Providers, config files, getters/setters</td>
</tr>
<tr>
<td><strong>Template</strong></td>
<td>A minimal project scaffold you copy</td>
<td>Low to medium</td>
<td><code>npx create-expo-app --template</code></td>
</tr>
<tr>
<td><strong>Starter kit / boilerplate repo</strong></td>
<td>A pre-built project with common features already wired</td>
<td>Medium to high</td>
<td>Auth, payments, theming, navigation ready to go</td>
</tr>
<tr>
<td><strong>Framework</strong></td>
<td>A runtime and set of conventions your code lives inside</td>
<td>High</td>
<td>Expo, Next.js, Rails</td>
</tr>
</tbody></table>
<p>When developers say "I bought a boilerplate," they usually mean a <strong>starter kit</strong>: a working app with the boring 30–40% already done.</p>
<h2>The hidden cost of writing boilerplate yourself</h2>
<p>Writing setup code by hand feels productive. You're typing, things are compiling, the terminal is busy. But look at what a typical mobile app needs before the first real feature ships:</p>
<ul>
<li><p>Navigation structure and deep linking</p>
</li>
<li><p>Authentication flows (sign up, sign in, password reset, session refresh)</p>
</li>
<li><p>Theming with light and dark mode</p>
</li>
<li><p>Form handling and validation</p>
</li>
<li><p>API client, caching, and error states</p>
</li>
<li><p>Environment config per build profile</p>
</li>
<li><p>Linting, formatting, and type checking</p>
</li>
<li><p>Build and release configuration for iOS and Android</p>
</li>
</ul>
<p>Each item is "only a few hours." Together, they can eat the first week or two of a project, and every hour spent there is an hour not spent validating your idea with users.</p>
<p>There's a second cost too: <strong>consistency</strong>. Hand-rolled boilerplate drifts. Your third project's auth flow is different from your first, and fixing a bug in one doesn't fix it in the others.</p>
<h2>When you <em>should</em> use a boilerplate</h2>
<p>A starter kit makes sense when:</p>
<ul>
<li><p><strong>You're building an MVP</strong> and speed to first user matters more than architectural purity.</p>
</li>
<li><p><strong>The problem is solved.</strong> Auth, theming, and navigation are not where your product differentiates.</p>
</li>
<li><p><strong>You're working in a team</strong> and want everyone to start from the same conventions.</p>
</li>
<li><p><strong>You're shipping multiple apps</strong> and want a consistent base across all of them.</p>
</li>
</ul>
<p>If you're building in React Native, <a href="https://www.applighter.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-is-boilerplate-code">AppLighter</a> offers ready-to-use React Native templates, so you can skip the setup phase and start on the screens that actually matter to your users.</p>
<h2>When you <em>shouldn't</em></h2>
<p>Boilerplate isn't always the answer. Skip it when:</p>
<ul>
<li><p><strong>You're learning.</strong> Wiring up auth and navigation yourself once is the best way to understand them.</p>
</li>
<li><p><strong>Your architecture is genuinely unusual.</strong> If the starter kit's assumptions don't fit, you'll spend more time ripping things out than you saved.</p>
</li>
<li><p><strong>The kit is abandoned.</strong> An outdated boilerplate is technical debt you chose on day one.</p>
</li>
</ul>
<h2>How to evaluate a boilerplate before you commit</h2>
<p>Before adopting any starter kit, run through this checklist:</p>
<ol>
<li><p><strong>Is it actively maintained?</strong> Check the last commit date and whether it tracks recent SDK versions.</p>
</li>
<li><p><strong>Is the stack one you'd choose anyway?</strong> Don't adopt a state library you dislike just because it came bundled.</p>
</li>
<li><p><strong>Can you remove what you don't need?</strong> Good boilerplate is modular; bad boilerplate is tangled.</p>
</li>
<li><p><strong>Is it typed end to end?</strong> TypeScript coverage saves you from silent breakage when you start customizing.</p>
</li>
<li><p><strong>Does it include the boring-but-critical parts?</strong> Error boundaries, env validation, and loading states matter more than flashy UI.</p>
</li>
<li><p><strong>Is there documentation?</strong> If you can't understand the folder structure in 10 minutes, neither can your next teammate.</p>
</li>
</ol>
<h2>Going beyond boilerplate: generating and shipping the rest</h2>
<p>Boilerplate solves the <em>setup</em> problem. Two other stages still slow teams down: building the first screens and getting the app into the stores.</p>
<p>On the building side, AI tools have changed what "starting from scratch" means. With <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-is-boilerplate-code">RapidNative</a>, you can describe an app, upload a PRD, or drop in a screenshot and get working React Native code back, which is effectively boilerplate tailored to your specific idea instead of a generic one.</p>
<p>On the shipping side, store submission brings its own repetitive work: certificates, provisioning profiles, store listings, screenshots, and review back-and-forth. If that's where your project stalls, <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=what-is-boilerplate-code">RapidNative Deploy</a> handles the process of getting your app live on the App Store and Google Play.</p>
<p>Put together, the modern workflow looks like this:</p>
<pre><code class="language-text">Starter kit / template  →  AI-generated screens  →  Your custom features  →  Managed deployment
   (setup boilerplate)       (UI boilerplate)          (your actual product)     (release boilerplate)
</code></pre>
<p>Only one of those four stages is truly unique to your app. The goal is to spend most of your time there.</p>
<h2>Key takeaways</h2>
<ul>
<li><p><strong>Boilerplate</strong> is code that's necessary but not distinctive: it appears across projects with little or no change.</p>
</li>
<li><p>It exists at three levels: <strong>language</strong>, <strong>project setup</strong>, and <strong>configuration</strong>.</p>
</li>
<li><p>Writing it by hand has a real cost in time and long-term consistency.</p>
</li>
<li><p>Use a starter kit when the problem is already solved and speed matters; skip it when you're learning or your architecture is unusual.</p>
</li>
<li><p>Evaluate any boilerplate for <strong>maintenance, stack fit, modularity, types, and docs</strong> before committing.</p>
</li>
</ul>
<p>The best developers aren't the ones who write the most code. They're the ones who write the <em>right</em> code and let everything else be boilerplate.</p>
<hr />
<p><em>What's the piece of boilerplate you find yourself rewriting on every project? Share it in the comments.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Build a Property Listing App Like Zillow with React Native]]></title><description><![CDATA[Browsing homes on your phone looks simple. You pan a map covered in price tags, flick through listing photos without opening anything, tap Beds and Price filters, and play with a mortgage slider until]]></description><link>https://smithchloe.hashnode.dev/how-to-build-a-property-listing-app-like-zillow-with-react-native</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/how-to-build-a-property-listing-app-like-zillow-with-react-native</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[supabase]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Wed, 30 Sep 2026 07:29:19 GMT</pubDate><content:encoded><![CDATA[<p>Browsing homes on your phone looks simple. You pan a map covered in price tags, flick through listing photos without opening anything, tap Beds and Price filters, and play with a mortgage slider until the monthly number stops scaring you. Zillow made that flow feel effortless. Building it the traditional way took large teams several years.</p>
<p>In 2026 the tooling is much better. With React Native, Expo, and a Postgres backend, you can get a working Zillow-style property listing app running in an afternoon, provided you know which parts matter and which can wait.</p>
<p>This guide covers:</p>
<ul>
<li>the six features every property app needs</li>
<li>a stack chosen for image-heavy, map-heavy apps</li>
<li>the database schema, including the index that keeps map queries fast</li>
<li>the search, map, and detail screens, with code</li>
<li>the saved-search push loop that drives retention</li>
<li>how to generate the whole v1 with an AI app builder instead of writing every screen</li>
</ul>
<p>It is aimed at three kinds of readers. Brokers who want a branded app, founders going after a niche (student housing, short-term rentals, off-market deals), and developers who want to see how PropTech UX is put together in code.</p>
<p><img src="https://images.unsplash.com/photo-1636928297637-0810569e87f9?w=1200&amp;q=80&amp;auto=format&amp;fit=crop" alt="Person filming a home interior on a smartphone" />
<em>Photo by Nicolas Solerieu on Unsplash</em></p>
<h2>What a Property Listing App Must Get Right</h2>
<p>Since Zillow, almost every successful real estate app has used the same interaction model. Build these six pieces and the app will feel like a real product to users:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Feature</th>
<th>Why it matters</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><strong>Map + list dual view</strong></td>
<td>One query drives both views: price pins on the map and scrollable cards in the list</td>
</tr>
<tr>
<td>2</td>
<td><strong>Fast, persistent filters</strong></td>
<td>Price, beds, baths, home type, HOA and days on market stay one tap away</td>
</tr>
<tr>
<td>3</td>
<td><strong>Photo carousels on cards</strong></td>
<td>Users can see 10–30 photos without opening the listing</td>
</tr>
<tr>
<td>4</td>
<td><strong>Dense detail screen</strong></td>
<td>Reads like a listing sheet: photos, facts, description, mortgage calculator</td>
</tr>
<tr>
<td>5</td>
<td><strong>Saved searches + push</strong></td>
<td>The main retention driver. Users come back when a new match appears</td>
</tr>
<tr>
<td>6</td>
<td><strong>Contact-agent CTA</strong></td>
<td>On every listing. It is also your lead capture</td>
</tr>
</tbody></table>
<p>If any of these are missing, users compare you to Zillow and uninstall. The rest of this guide builds all six.</p>
<h2>The Tech Stack</h2>
<p>A property app has three performance problems. It downloads a lot of images, it runs geographic queries, and it re-renders map markers whenever a filter changes. Every choice below is made with those three in mind.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Choice</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td>Framework</td>
<td><strong>React Native + Expo (SDK 54+)</strong></td>
<td>One codebase for iOS and Android, and no Xcode needed for day-to-day development</td>
</tr>
<tr>
<td>Navigation</td>
<td><strong>Expo Router</strong></td>
<td>File-based routing: tabs and stacks come from your folder structure</td>
</tr>
<tr>
<td>Styling</td>
<td><strong>NativeWind</strong></td>
<td>Tailwind utility classes in React Native</td>
</tr>
<tr>
<td>Maps</td>
<td><strong>react-native-maps</strong></td>
<td>Native Apple and Google maps with custom marker views</td>
</tr>
<tr>
<td>Lists</td>
<td><strong>FlashList</strong></td>
<td>Recycles cells, which keeps long image-heavy feeds smooth</td>
</tr>
<tr>
<td>State</td>
<td><strong>Zustand</strong></td>
<td>Small and simple, which is enough for shared filter state</td>
</tr>
<tr>
<td>Backend</td>
<td><strong>Supabase</strong></td>
<td>Postgres, auth, storage, and edge functions from one SDK</td>
</tr>
<tr>
<td>Push</td>
<td><strong>Expo Notifications</strong></td>
<td>Sends to iOS and Android without setting up APNs and FCM yourself</td>
</tr>
<tr>
<td>Images</td>
<td><strong>expo-image</strong></td>
<td>Memory and disk caching for listing photos</td>
</tr>
</tbody></table>
<h2>Step 1: Model the Data</h2>
<p>Start with the schema, because the rest of the app is built around it. Three core tables cover most of what a v1 needs, plus <code>agents</code> and <code>profiles</code> for the relationships.</p>
<pre><code class="language-sql">-- Needed for fast geographic queries
create extension if not exists cube;
create extension if not exists earthdistance;

create table agents (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  phone text,
  email text,
  photo_url text
);

create table listings (
  id uuid primary key default gen_random_uuid(),
  address text not null,
  city text not null,
  state text not null,
  zip text not null,
  latitude double precision not null,
  longitude double precision not null,
  price integer not null,
  beds integer not null,
  baths numeric not null,
  sqft integer,
  home_type text check (home_type in ('house','condo','townhouse','apartment','land')),
  status text default 'for_sale' check (status in ('for_sale','pending','sold')),
  description text,
  photos text[] default '{}',
  agent_id uuid references agents(id),
  hoa_monthly integer,
  year_built integer,
  days_on_market integer default 0,
  created_at timestamptz default now()
);

create index listings_geo_idx on listings using gist (
  ll_to_earth(latitude, longitude)
);

create table profiles (
  id uuid primary key references auth.users(id),
  expo_push_token text
);

create table saved_searches (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references profiles(id) not null,
  min_price integer,
  max_price integer,
  min_beds integer,
  home_types text[],
  city text,
  created_at timestamptz default now()
);

create table favorites (
  user_id uuid references profiles(id) not null,
  listing_id uuid references listings(id) not null,
  primary key (user_id, listing_id)
);
</code></pre>
<p>Some notes on these choices:</p>
<ul>
<li><strong><code>photos text[]</code> keeps v1 simple.</strong> When you need photo ordering and captions, move photos into a separate <code>listing_photos</code> table.</li>
<li><strong>Coordinates are <code>double precision</code></strong> because that is what <code>ll_to_earth()</code> takes, so the index works without casts.</li>
<li><strong>The GiST index is the important part.</strong> Without it, "show listings on this part of the map" scans every row in the table, which gets slow quickly as listings grow. With it, the database only looks at nearby rows.</li>
<li><strong><code>profiles</code> holds the push token.</strong> Supabase does not expose <code>auth.users</code> through the client API, so user data you need to query goes in your own table.</li>
</ul>
<p>A small RPC puts the index to work for "listings near the map center":</p>
<pre><code class="language-sql">create or replace function listings_near(lat float8, lng float8, radius_m float8)
returns setof listings
language sql stable as $$
  select *
  from listings
  where earth_box(ll_to_earth(lat, lng), radius_m) @&gt; ll_to_earth(latitude, longitude)
    and earth_distance(ll_to_earth(lat, lng), ll_to_earth(latitude, longitude)) &lt;= radius_m;
$$;
</code></pre>
<h2>Step 2: The Search Screen (Map + List)</h2>
<p><img src="https://images.unsplash.com/photo-1768162125912-e721d0794b7d?w=1200&amp;q=80&amp;auto=format&amp;fit=crop" alt="Person holding a smartphone with a map app open" />
<em>Photo by Perry Merrity II on Unsplash</em></p>
<p>The map/list toggle is the core of the app. Both views read the same filters and the same results array. Only the rendering differs.</p>
<h3>Route structure</h3>
<pre><code class="language-text">app/
├── (tabs)/
│   ├── _layout.tsx      # Tab bar: Search, Saved, Messages, Profile
│   ├── search.tsx       # Map/list dual view
│   ├── saved.tsx        # Favorites + saved searches
│   ├── messages.tsx     # Agent conversations
│   └── profile.tsx      # Settings
├── listing/
│   └── [id].tsx         # Detail screen (pushed onto the stack)
└── filters.tsx          # Presented as a modal
</code></pre>
<h3>Shared filter state</h3>
<pre><code class="language-typescript">// store/filters.ts
import { create } from "zustand";

type Filters = {
  minPrice?: number;
  maxPrice?: number;
  minBeds?: number;
  homeTypes: string[];
};

type FilterStore = {
  filters: Filters;
  setFilters: (patch: Partial&lt;Filters&gt;) =&gt; void;
};

export const useFilters = create&lt;FilterStore&gt;((set) =&gt; ({
  filters: { homeTypes: [] },
  setFilters: (patch) =&gt;
    set((state) =&gt; ({ filters: { ...state.filters, ...patch } })),
}));
</code></pre>
<h3>Price pins on the map</h3>
<p>Zillow's price bubbles are the most recognizable part of its UI. A custom <code>Marker</code> child view is enough to reproduce them:</p>
<pre><code class="language-tsx">// components/PricePin.tsx
import { Marker } from "react-native-maps";
import { View, Text } from "react-native";
import { router } from "expo-router";

export const formatPrice = (p: number) =&gt;
  p &gt;= 1_000_000 ? `$${(p / 1_000_000).toFixed(1)}M` : `$${Math.round(p / 1000)}K`;

export function PricePin({ listing }: { listing: Listing }) {
  return (
    &lt;Marker
      coordinate={{ latitude: listing.latitude, longitude: listing.longitude }}
      onPress={() =&gt; router.push(`/listing/${listing.id}`)}
      tracksViewChanges={false}
    &gt;
      &lt;View className="rounded-full bg-blue-900 px-2 py-1"&gt;
        &lt;Text className="text-xs font-bold text-white"&gt;
          {formatPrice(listing.price)}
        &lt;/Text&gt;
      &lt;/View&gt;
    &lt;/Marker&gt;
  );
}
</code></pre>
<p><code>tracksViewChanges={false}</code> matters here. Without it, every custom marker keeps re-rendering, and a map with 100+ pins drops frames on Android.</p>
<h3>Re-query when the user stops panning</h3>
<pre><code class="language-tsx">// Inside app/(tabs)/search.tsx
const fetchForRegion = useMemo(
  () =&gt;
    debounce(async (region: Region) =&gt; {
      // Rough radius: half the visible latitude span, in meters
      const radius = (region.latitudeDelta * 111_000) / 2;
      const { data } = await supabase.rpc("listings_near", {
        lat: region.latitude,
        lng: region.longitude,
        radius_m: radius,
      });
      setListings(applyFilters(data ?? [], filters));
    }, 400),
  [filters]
);

&lt;MapView
  style={{ flex: 1 }}
  initialRegion={AUSTIN}
  onRegionChangeComplete={fetchForRegion}
&gt;
  {listings.map((l) =&gt; &lt;PricePin key={l.id} listing={l} /&gt;)}
&lt;/MapView&gt;
</code></pre>
<p>The list view renders the same <code>listings</code> array with <code>FlashList</code>. Each card is a <code>Pressable</code> that pushes to <code>/listing/[id]</code>. On FlashList v1, pass an <code>estimatedItemSize</code> close to your card height (about 340 for a photo, address, and facts row). FlashList v2 measures item sizes on its own.</p>
<h2>Step 3: The Listing Detail Screen</h2>
<p>This is where a user decides whether to save the home, share it, or contact the agent. Every listing uses the same template, and the screen packs in a lot of information. From top to bottom:</p>
<ol>
<li><strong>Photo carousel.</strong> Full width, swipeable, with a counter (<code>3 / 27</code>).</li>
<li><strong>Price, address, and status pill.</strong> Status is For Sale, Pending, or Sold.</li>
<li><strong>Key facts row.</strong> For example: 3 bd · 2 ba · 1,850 sqft · $391/sqft.</li>
<li><strong>Estimated value.</strong> For example: "Est. $728,000 (±$15K)".</li>
<li><strong>Description.</strong> Collapsed, with a "Read more" toggle.</li>
<li><strong>Facts and features.</strong> HOA, year built, days on market, parking, heating.</li>
<li><strong>Mortgage calculator.</strong> Inline, defaulting to 20% down on a 30-year fixed loan.</li>
<li><strong>Schools and walk score.</strong> Optional for v1; users will expect it in v2.</li>
<li><strong>Sticky Contact Agent button.</strong></li>
<li><strong>Similar homes.</strong> A horizontal list at the bottom.</li>
</ol>
<h3>The mortgage calculator</h3>
<p>Monthly principal and interest follows the standard amortization formula:</p>
<pre><code class="language-text">M = (P − D) × (r/12) / (1 − (1 + r/12)^−n)
</code></pre>
<p>Here P is price, D is down payment, r is the annual rate, and n is the term in months. In TypeScript, with property tax, insurance, and HOA added:</p>
<pre><code class="language-typescript">// lib/mortgage.ts
type MortgageInput = {
  price: number;
  downPayment: number;
  annualRate: number;      // e.g. 0.065 for 6.5%
  years?: number;          // default 30
  taxRate?: number;        // annual, default ~1.1% of price
  insuranceMonthly?: number;
  hoaMonthly?: number;
};

export function monthlyPayment({
  price,
  downPayment,
  annualRate,
  years = 30,
  taxRate = 0.011,
  insuranceMonthly = 100,
  hoaMonthly = 0,
}: MortgageInput) {
  const principal = price - downPayment;
  const r = annualRate / 12;
  const n = years * 12;

  const pi = r === 0 ? principal / n : (principal * r) / (1 - Math.pow(1 + r, -n));
  const tax = (price * taxRate) / 12;

  return {
    principalAndInterest: Math.round(pi),
    tax: Math.round(tax),
    insurance: insuranceMonthly,
    hoa: hoaMonthly,
    total: Math.round(pi + tax + insuranceMonthly + hoaMonthly),
  };
}
</code></pre>
<p>Connect it to two sliders, one for down payment and one for rate, and recompute on every change. Zillow shows a stacked breakdown chart. For v1, a single total that updates live is enough.</p>
<h2>Step 4: Saved Searches + Push Notifications</h2>
<p>This is the feature that brings users back. Someone who saves a search returns every time a new home matches it. Someone who doesn't save one usually stops opening the app.</p>
<p>The loop is a scheduled job, such as a Supabase Edge Function on an hourly cron. It finds new listings, matches them against saved searches, and sends pushes through Expo's push service:</p>
<pre><code class="language-typescript">// supabase/functions/saved-search-alerts/index.ts
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();

const { data: newListings } = await supabase
  .from("listings")
  .select("*")
  .gte("created_at", oneHourAgo);

const { data: searches } = await supabase
  .from("saved_searches")
  .select("*, profiles(expo_push_token)");

const messages = [];

for (const search of searches ?? []) {
  const token = search.profiles?.expo_push_token;
  if (!token) continue;

  const matches = (newListings ?? []).filter((l) =&gt; matchesSearch(l, search));
  if (!matches.length) continue;

  messages.push({
    to: token,
    title: `${matches.length} new ${search.city ?? ""} listings`.trim(),
    body: matches[0].address,
    data: { url: `/listing/${matches[0].id}` },
  });
}

if (messages.length) {
  await fetch("https://exp.host/--/api/v2/push/send", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(messages),
  });
}
</code></pre>
<pre><code class="language-typescript">function matchesSearch(listing: Listing, s: SavedSearch) {
  return (
    (!s.city || listing.city === s.city) &amp;&amp;
    (!s.min_price || listing.price &gt;= s.min_price) &amp;&amp;
    (!s.max_price || listing.price &lt;= s.max_price) &amp;&amp;
    (!s.min_beds || listing.beds &gt;= s.min_beds) &amp;&amp;
    (!s.home_types?.length || s.home_types.includes(listing.home_type))
  );
}
</code></pre>
<p>When the user taps the notification, read <code>data.url</code> in a notification-response listener and call <code>router.push(url)</code>. Expo Router then opens the matching detail screen.</p>
<h2>What This Costs to Build Traditionally</h2>
<p><img src="https://images.unsplash.com/photo-1656657121748-980acf6fef7d?w=1200&amp;q=80&amp;auto=format&amp;fit=crop" alt="Laptop and mobile phones laid out for app development" />
<em>Photo by Marios Gkortsilas on Unsplash</em></p>
<p>Here is a realistic estimate for a mid-level agency building everything above:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Time</th>
<th>Cost at $120/hr</th>
</tr>
</thead>
<tbody><tr>
<td>Discovery + wireframes</td>
<td>1 week</td>
<td>$4,800</td>
</tr>
<tr>
<td>Auth + profiles</td>
<td>3 days</td>
<td>$2,880</td>
</tr>
<tr>
<td>Search, filters, list view</td>
<td>1.5 weeks</td>
<td>$7,200</td>
</tr>
<tr>
<td>Map with custom pins</td>
<td>1 week</td>
<td>$4,800</td>
</tr>
<tr>
<td>Detail screen + carousel</td>
<td>1 week</td>
<td>$4,800</td>
</tr>
<tr>
<td>Mortgage calculator</td>
<td>2 days</td>
<td>$1,920</td>
</tr>
<tr>
<td>Saved searches + push</td>
<td>1 week</td>
<td>$4,800</td>
</tr>
<tr>
<td>Agent messaging</td>
<td>1 week</td>
<td>$4,800</td>
</tr>
<tr>
<td>QA, polish, store release</td>
<td>2 weeks</td>
<td>$9,600</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>~10 weeks</strong></td>
<td><strong>~$45,600</strong></td>
</tr>
</tbody></table>
<p>At that price, most local PropTech ideas never got tested. A founder who spotted a rental-market gap in their city usually didn't have $45K and three months to find out whether anyone wanted the app.</p>
<h2>Generating the App with an AI Builder</h2>
<p><a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-property-listing-app-like-zillow-react-native">RapidNative</a> is an AI mobile app builder. It turns plain-English prompts into React Native and Expo projects on the same stack described above: Expo Router, NativeWind, and a Supabase backend. You describe a screen, watch it render, and refine it by selecting an element in the preview and describing the change.</p>
<p>These five prompts, in order, build a working Zillow-style v1:</p>
<h3>Prompt 1: Scaffold</h3>
<pre><code class="language-text">Build a real estate app called HomeSearch with four bottom tabs: Search, Saved,
Messages, Profile. Use a dark blue accent. The Search tab has a search bar,
a row of filter chips (Beds, Baths, Price, Home Type), and a Map/List toggle.
</code></pre>
<h3>Prompt 2: Listings feed</h3>
<pre><code class="language-text">In the Search tab's List view, show a FlashList of property cards. Each card has
a swipeable photo carousel, a favorite heart in the top-right corner, the price in
bold, the address, and a beds/baths/sqft row. Use 12 sample listings in Austin, TX
with Unsplash photos.
</code></pre>
<h3>Prompt 3: Map view</h3>
<pre><code class="language-text">In Map view, center a map on Austin and show a price pin for each listing
(e.g. "$725K"). Tapping a pin opens a bottom card with the photo, price, address,
and a "View details" button.
</code></pre>
<h3>Prompt 4: Detail screen</h3>
<pre><code class="language-text">Tapping a listing opens a detail screen with a full-width photo carousel, price and
address, a facts row (beds, baths, sqft, $/sqft), an expandable description, and a
mortgage calculator with down-payment and rate sliders that update the monthly
payment live. Add a sticky "Contact Agent" button.
</code></pre>
<h3>Prompt 5: Saved + filters</h3>
<pre><code class="language-text">The Saved tab shows favorited listings and a "Saved Searches" section. Add a filters
modal from the Search tab with min/max price, min beds, min baths, a home-type
multi-select, and a "Save this search" button.
</code></pre>
<p>Each prompt renders in about a minute. For tweaks such as pin color, card spacing, or the default down payment, select the element in the preview and describe the change.</p>
<p>The output is an ordinary Expo project. You can export it, open it in your editor, and continue in git as usual.</p>
<h3>Starting from a sketch or a screenshot</h3>
<p>Prompts aren't the only way in. If you have already drawn the search screen on a whiteboard, <a href="https://www.rapidnative.com/whiteboard?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-property-listing-app-like-zillow-react-native">Sketch to App</a> turns the drawing into code. If you have a competitor's UI you want to use as a reference, <a href="https://www.rapidnative.com/image-to-app?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-property-listing-app-like-zillow-react-native">Screenshot to App</a> rebuilds it in React Native. A written product spec also works as input.</p>
<p>For a Zillow-style app, prompts are usually the fastest route. The pattern is well known, and AI models already know what a listings feed or a mortgage calculator should look like.</p>
<h2>FAQ</h2>
<h3>Can React Native handle a Zillow-scale app?</h3>
<p>Yes. In a property app, the bottlenecks are not in the framework:</p>
<ul>
<li><strong>Images</strong> are handled by <code>expo-image</code> with disk caching.</li>
<li><strong>Map rendering</strong> is handled by clustering once you pass roughly 100 pins.</li>
<li><strong>Viewport queries</strong> are handled by the Postgres GiST index from Step 1.</li>
</ul>
<h3>How much does a property listing app like Zillow cost to build?</h3>
<p>A traditional agency v1 with the six core features usually costs between $40K and $80K. With an AI builder, the upfront cost drops to a <a href="https://www.rapidnative.com/pricing?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-property-listing-app-like-zillow-react-native">subscription plan</a> and some of your time. Expect $50–$200/month in running costs (maps and geocoding APIs, hosting, store fees) for a small app.</p>
<h3>Where do real listings come from?</h3>
<p>For a prototype, use generated sample data. For production, you need MLS access, either through a broker relationship or a licensed feed provider such as Bridge or Trestle. If you want to avoid MLS entirely, pick a niche that doesn't depend on it: FSBO, off-market deals, student housing, or listings built from public county records.</p>
<h3>Google Maps, Apple Maps, or OpenStreetMap?</h3>
<p><code>react-native-maps</code> supports Apple Maps (the iOS default, and free) and Google Maps (a free usage tier, then pay-per-load). OSM-based options cost nothing but have weaker geocoding. Start with the defaults and switch only if your billing makes it necessary.</p>
<h2>Wrapping Up</h2>
<p>The old path was to hire a team, wait three months, spend around $50K, and hope the UX was right. Now you can write five prompts, spend an afternoon refining the result, export the code, and ship. Testing a PropTech idea no longer requires funding first.</p>
<p>If you want to try it, <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=build-property-listing-app-like-zillow-react-native">start building your property listing app free with RapidNative</a>. There are 20 free credits, no card needed, and no Mac required.</p>
<p>The next Zillow could come from someone who spots a gap in their local market and ships an app over a weekend.</p>
]]></content:encoded></item><item><title><![CDATA[Postgres Local Dev: A Setup That Survives Your Own Team]]></title><description><![CDATA[Most local Postgres setups work fine for one person on one laptop. They fall apart the moment a second developer joins, a migration goes out of order, or someone's brew install postgresql lands on a d]]></description><link>https://smithchloe.hashnode.dev/postgres-local-dev-a-setup-that-survives-your-own-team</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/postgres-local-dev-a-setup-that-survives-your-own-team</guid><category><![CDATA[Databases]]></category><category><![CDATA[Docker]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[migration]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Tue, 29 Sep 2026 09:11:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/2a38b9d0-a900-469e-8323-a748472797fc.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most local Postgres setups work fine for one person on one laptop. They fall apart the moment a second developer joins, a migration goes out of order, or someone's <code>brew install postgresql</code> lands on a different major version than production.</p>
<p>This is the setup I've converged on after enough of those mornings: Docker for the database, migrations as the only way schema changes happen, a seed script that's fast enough to run on every branch switch, and a couple of habits that keep local and production from drifting.</p>
<p>It assumes you're comfortable with a terminal and have Docker installed. Everything else is explained.</p>
<h2>Why not just install Postgres on the machine?</h2>
<p>You can. It's how I started. Three things eventually push you off it:</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>What it looks like</th>
</tr>
</thead>
<tbody><tr>
<td>Version drift</td>
<td>Your laptop has 16, prod has 15, a query using a 16-only function passes locally and fails in CI</td>
</tr>
<tr>
<td>Shared state</td>
<td>One database for every branch, so switching branches leaves you with a schema that matches neither</td>
</tr>
<tr>
<td>Onboarding</td>
<td>The README says "install Postgres" and the new hire loses a day to a <code>pg_hba.conf</code> error</td>
</tr>
</tbody></table>
<p>Docker fixes all three: the version is pinned in a file, the data lives in a named volume you can throw away, and setup is one command.</p>
<h2>Step 1: Pin the database in Docker Compose</h2>
<p>Create <code>docker-compose.yml</code> at the project root:</p>
<pre><code class="language-yaml">services:
  db:
    image: postgres:16.4
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app_dev
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "app", "-d", "app_dev"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:
</code></pre>
<p>Two things matter here:</p>
<ul>
<li><strong>Pin the exact tag</strong> (<code>postgres:16.4</code>), not <code>postgres:16</code> or <code>latest</code>. Check what production is running and match it. Minor versions rarely break anything, but "rarely" is the word that gets you at 2am.</li>
<li><strong>The healthcheck</strong> means anything that depends on the database (your app container, a migration step) can wait for it to actually accept connections rather than for the container to merely start.</li>
</ul>
<p>Start it:</p>
<pre><code class="language-bash">docker compose up -d db
</code></pre>
<p>Your connection string is now <code>postgres://app:app@localhost:5432/app_dev</code>. Put it in <code>.env.local</code>, not in the compose file's consumers' source.</p>
<h2>Step 2: Migrations are the only way the schema changes</h2>
<p>The rule that keeps a team sane: <strong>nobody runs <code>ALTER TABLE</code> by hand</strong>. Every schema change is a file in the repo, applied in order, tracked in a table so it never runs twice.</p>
<p>Which tool you use matters less than the rule. Pick one your stack already leans toward:</p>
<table>
<thead>
<tr>
<th>Stack</th>
<th>Tool</th>
<th>Migration format</th>
</tr>
</thead>
<tbody><tr>
<td>Node / TypeScript</td>
<td>Drizzle Kit, Prisma Migrate, Kysely</td>
<td>SQL or TS</td>
</tr>
<tr>
<td>Python</td>
<td>Alembic</td>
<td>Python</td>
</tr>
<tr>
<td>Go</td>
<td>golang-migrate, goose</td>
<td>SQL</td>
</tr>
<tr>
<td>Language-agnostic</td>
<td>dbmate, Flyway, sqitch</td>
<td>SQL</td>
</tr>
</tbody></table>
<p>I default to plain-SQL tools (dbmate, goose) because a migration you can read without knowing the ORM is a migration anyone can review. Here's what one looks like with dbmate:</p>
<pre><code class="language-sql">-- db/migrations/20260929120000_create_sessions.sql
-- migrate:up
CREATE TABLE sessions (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  started_at  timestamptz NOT NULL DEFAULT now(),
  ended_at    timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_started_idx ON sessions (user_id, started_at DESC);

-- migrate:down
DROP TABLE sessions;
</code></pre>
<pre><code class="language-bash">dbmate up        # apply pending migrations
dbmate rollback  # undo the last one
</code></pre>
<p>Always write the <code>down</code>. You'll rarely use it in production, but locally it's how you back out of a branch without nuking the volume.</p>
<h3>Timestamped filenames beat sequential numbers</h3>
<p><code>001_</code>, <code>002_</code>, <code>003_</code> collide the moment two branches each add a migration. Timestamps (<code>20260929120000_</code>) don't. Most tools generate them for you; use that.</p>
<h2>Step 3: A seed script you can run in under ten seconds</h2>
<p>A migration builds the schema. A seed fills it with enough data to actually develop against. Keep them separate — migrations run in production, seeds never do.</p>
<p>The seed should be:</p>
<ul>
<li><strong>Idempotent.</strong> Running it twice shouldn't double the data. Truncate first, or upsert.</li>
<li><strong>Fast.</strong> If it takes a minute, people stop running it and start working against stale data.</li>
<li><strong>Realistic in shape, small in size.</strong> Twenty users, a few hundred rows in the busy tables. Enough to hit pagination and empty states, not enough to wait for.</li>
</ul>
<p>A minimal SQL seed:</p>
<pre><code class="language-sql">-- db/seed.sql
TRUNCATE sessions, users RESTART IDENTITY CASCADE;

INSERT INTO users (id, email, name) VALUES
  ('11111111-1111-1111-1111-111111111111', 'ana@example.com', 'Ana'),
  ('22222222-2222-2222-2222-222222222222', 'ben@example.com', 'Ben');

INSERT INTO sessions (user_id, started_at, ended_at)
SELECT
  u.id,
  now() - (i || ' days')::interval,
  now() - (i || ' days')::interval + '45 minutes'
FROM users u, generate_series(1, 30) AS i;
</code></pre>
<pre><code class="language-bash">psql "$DATABASE_URL" -f db/seed.sql
</code></pre>
<p><code>generate_series</code> is the cheat code here — it produces sixty rows of plausible time-series data from two inserts.</p>
<p>Wrap the whole reset in one script so the README can say "run <code>make db-reset</code>":</p>
<pre><code class="language-makefile">db-reset:
	docker compose down -v db
	docker compose up -d --wait db
	dbmate up
	psql "$$DATABASE_URL" -f db/seed.sql
</code></pre>
<p><code>--wait</code> respects the healthcheck from Step 1, so <code>dbmate up</code> doesn't race the container.</p>
<h2>Step 4: Keep local honest with production</h2>
<h3>Match the extensions</h3>
<p>If production uses <code>pgcrypto</code>, <code>pg_trgm</code>, or <code>postgis</code>, your first migration should be <code>CREATE EXTENSION IF NOT EXISTS ...</code>. Discovering an extension is missing only when a query hits prod is a bad day. If you need PostGIS, switch the image to <code>postgis/postgis:16-3.4</code> — same pinning rule.</p>
<h3>Pull a scrubbed snapshot occasionally</h3>
<p>Seeds are good for shape, bad for surprises. Once in a while, load a sanitised production dump to catch the slow query that only shows up with real data distribution:</p>
<pre><code class="language-bash"># on a machine with prod access
pg_dump --no-owner --no-privileges -Fc "$PROD_URL" &gt; snapshot.dump

# locally
pg_restore --no-owner --clean --if-exists -d "$DATABASE_URL" snapshot.dump
</code></pre>
<p>Scrub PII before it leaves the prod environment. A <code>pg_dump</code> with <code>--exclude-table-data=users</code> plus a seed for users is the simplest version of that.</p>
<h3>Same tool, same config, in CI</h3>
<p>Your CI pipeline should spin up the <em>same</em> image tag and run the <em>same</em> <code>dbmate up</code> before tests. If the compose file and the CI config both read the version from one place (a <code>.env</code> or a Makefile variable), they can't drift.</p>
<h2>A few tools worth having</h2>
<ul>
<li><strong><code>pgcli</code></strong> — psql with autocomplete and syntax highlighting. <code>pip install pgcli</code>. The single biggest quality-of-life upgrade.</li>
<li><strong><code>EXPLAIN (ANALYZE, BUFFERS)</code></strong> — run it on any query you're unsure about, locally, against the snapshot. Reading a plan is a skill; get it early.</li>
<li><strong><code>\d+ tablename</code></strong> in psql — shows columns, indexes, and constraints. Faster than opening a GUI.</li>
<li><strong>A GUI when you want one</strong> — TablePlus, DBeaver, or the Postgres extension for VS Code. None are required.</li>
</ul>
<h2>Where this fits if you're building a mobile app</h2>
<p>If the Postgres you're targeting is Supabase — increasingly the default for React Native and Expo apps — the local story is the same idea with the Supabase CLI wrapping it: <code>supabase start</code> brings up Postgres plus auth and storage in Docker, and <code>supabase db push</code> / <code>supabase migration new</code> play the migration role.</p>
<p>The part people skip is designing the schema before wiring it into screens. I've had good results describing the app and letting <a href="https://rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=postgres-local-dev">RapidNative</a> generate the Expo project with a Supabase-backed data layer already wired, then treating that generated schema as migration zero and evolving it with the workflow above. The generated Postgres schema is a reasonable starting point, and it's easier to iterate a schema that already has screens depending on it than to guess at one in isolation.</p>
<p>When it's time to get that app onto devices, the <a href="https://www.rapidnative.com/deploy?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=postgres-local-dev">deploy flow</a> covers EAS builds and store submission, which is a separate rabbit hole from the database one.</p>
<h2>Checklist</h2>
<p>Paste this into your README and tick it off:</p>
<ul>
<li> <code>docker-compose.yml</code> with a pinned <code>postgres:X.Y</code> tag matching production</li>
<li> Healthcheck on the <code>db</code> service</li>
<li> Migration tool chosen; <code>up</code> and <code>down</code> for every migration; timestamped filenames</li>
<li> <code>db/seed.sql</code> that's idempotent and runs in seconds</li>
<li> One <code>make db-reset</code> (or equivalent) command that does everything</li>
<li> Extensions created in migration 0001</li>
<li> CI uses the same image tag and runs the same migrations</li>
<li> A documented way to load a scrubbed prod snapshot</li>
</ul>
<p>The goal is boring: a new developer clones the repo, runs one command, and has a database that matches what everyone else and production are running. Once that's true, most "works on my machine" database bugs stop existing.</p>
<p>If you've hit a local-Postgres failure mode this doesn't cover, I'd like to hear about it in the comments — that's how the checklist grows.</p>
]]></content:encoded></item><item><title><![CDATA[How to Build a Fitness Wearable App with React Native]]></title><description><![CDATA[Most "build a fitness app in React Native" tutorials stop at the easy part: a polished UI, a workout list, a chart. The engineering that actually matters — if you want to ship a companion app for a Wh]]></description><link>https://smithchloe.hashnode.dev/how-to-build-a-fitness-wearable-app-with-react-native</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/how-to-build-a-fitness-wearable-app-with-react-native</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[bluetooth]]></category><category><![CDATA[iot]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Mon, 28 Sep 2026 06:12:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/954936d1-3ec3-4a15-8d59-c76d7b21c0d3.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most "build a fitness app in React Native" tutorials stop at the easy part: a polished UI, a workout list, a chart. The engineering that actually matters — if you want to ship a companion app for a Whoop, a Garmin, a Peloton monitor, or an Apple Watch — begins once your phone has to talk to a device that isn't the phone.</p>
<p>This tutorial covers how to build a fitness app with React Native end-to-end when there's a real wearable behind it: what to build, in what order, and where you can skip weeks of scaffold work by generating it with an <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=how-to-build-fitness-wearable-companion-app-react-native">AI mobile app builder</a>.</p>
<p><img src="https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=1200" alt="Runner glancing at a smartwatch on a trail" />
<em>A fitness wearable companion app lives on the phone but is defined by what happens on the wrist — Photo by William Hook on Unsplash</em></p>
<h2>What a fitness wearable companion app actually is</h2>
<p>A fitness wearable companion app is a <strong>phone app</strong> whose job is to pair with a physical wearable (a watch, a chest strap, a ring, a bike sensor), read health data from it in real time, persist that data, and present it back to the user. Roughly 60–70% of the code lives on the phone in React Native; the rest is native permissions, Bluetooth adapters, and background delivery bridges to Apple HealthKit and Android Health Connect.</p>
<p>If you've ever wired a Polar H10 heart-rate strap into a running app, or streamed cycling power from an Assioma pedal into a Strava-style dashboard, that's the shape of app this guide is about. It's structurally different from a "log your workouts" fitness app, and confusing the two is the number one reason people underestimate the timeline.</p>
<h2>The tech stack</h2>
<p>Lock in the stack before writing any code. Every library below solves a problem you don't want to solve yourself, and swapping any of them later costs weeks.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Library</th>
<th>What it gives you</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Runtime</strong></td>
<td>Expo (SDK 52+) with the config-plugin workflow</td>
<td>Managed native config, EAS Build, one command to ship</td>
</tr>
<tr>
<td><strong>Router</strong></td>
<td>expo-router v4</td>
<td>File-based routing that mirrors your screen tree</td>
</tr>
<tr>
<td><strong>Health data (iOS)</strong></td>
<td>react-native-health</td>
<td>HealthKit read/write, background delivery</td>
</tr>
<tr>
<td><strong>Health data (Android)</strong></td>
<td>react-native-health-connect</td>
<td>Health Connect (replaced Google Fit in 2025)</td>
</tr>
<tr>
<td><strong>BLE</strong></td>
<td>react-native-ble-plx</td>
<td>GATT client for any Bluetooth Low Energy peripheral</td>
</tr>
<tr>
<td><strong>State</strong></td>
<td>Zustand or Redux Toolkit</td>
<td>A single store the whole app reads from</td>
</tr>
<tr>
<td><strong>Charts</strong></td>
<td>victory-native or react-native-svg-charts</td>
<td>Skia-backed charts that don't drop frames</td>
</tr>
<tr>
<td><strong>Backend (optional)</strong></td>
<td>Supabase</td>
<td>Postgres + Auth + realtime, zero server ops</td>
</tr>
<tr>
<td><strong>Auth</strong></td>
<td>expo-auth-session + Sign in with Apple</td>
<td>Required for the App Store if you offer any other login method</td>
</tr>
</tbody></table>
<p>Two points worth stressing:</p>
<ol>
<li><strong>Use Expo, not bare React Native.</strong> Every fitness wearable library above ships an Expo config plugin now; the friction that used to justify bare has largely disappeared. The trade-offs are covered in more depth in <a href="https://www.rapidnative.com/blogs/why-we-chose-expo-over-bare-react-native-for-ai-code-generation?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=how-to-build-fitness-wearable-companion-app-react-native">why we chose Expo over bare React Native</a>.</li>
<li><strong>Health Connect replaced Google Fit's developer API in 2025.</strong> If a tutorial tells you to install <code>react-native-google-fit</code>, close the tab.</li>
</ol>
<h2>Step 1: Design the screens before you write code</h2>
<p>The common mistake is starting with the BLE integration. It's the interesting part, which is why it will consume every hour you give it, and you'll end up with heart-rate data and no app to put it in. Design the screens first.</p>
<p>A minimum-viable fitness wearable companion app has six screens:</p>
<ol>
<li><strong>Onboarding</strong> — permission requests (Bluetooth, Location, HealthKit / Health Connect, Notifications)</li>
<li><strong>Device pairing</strong> — scan for nearby BLE devices, pair, remember</li>
<li><strong>Live session</strong> — active workout: real-time heart rate, elapsed time, distance, power</li>
<li><strong>Session summary</strong> — post-workout: charts, splits, HR zones, calories</li>
<li><strong>History</strong> — list of past sessions with quick stats</li>
<li><strong>Profile &amp; settings</strong> — units, connected devices, data export</li>
</ol>
<p>That's the scaffold. You can hand-code it, or <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=how-to-build-fitness-wearable-companion-app-react-native">describe it to RapidNative</a> and get all six screens, an Expo Router tree, a Zustand store, and a Supabase-backed data layer generated in one pass — leaving your time for the parts that can't be templated.</p>
<p><img src="https://images.unsplash.com/photo-1555099962-4199c345e5dd?w=1200" alt="React Native code and phone preview side by side" />
<em>Get the scaffold out of the way before the integration work begins — Photo by Christopher Gower on Unsplash</em></p>
<h2>Step 2: Wire up HealthKit and Health Connect</h2>
<p>Whether or not your wearable talks to Apple Watch or Wear OS directly, you almost certainly want to write to HealthKit and Health Connect. Users expect their workouts to appear in Apple Health and Google Fit / Samsung Health. Skipping this is a bug report you'll receive in week one.</p>
<h3>Install the libraries and add the config plugins</h3>
<pre><code class="language-bash">npx expo install react-native-health react-native-health-connect
</code></pre>
<p>In <code>app.json</code>, register the plugins and declare the HealthKit permissions you'll request:</p>
<pre><code class="language-json">{
  "expo": {
    "plugins": [
      [
        "react-native-health",
        {
          "isClinicalDataEnabled": false,
          "healthSharePermission": "Allow $(PRODUCT_NAME) to read your workout and heart rate data",
          "healthUpdatePermission": "Allow $(PRODUCT_NAME) to save your workouts"
        }
      ],
      "react-native-health-connect"
    ]
  }
}
</code></pre>
<h3>Read today's step count on both platforms</h3>
<pre><code class="language-typescript">import AppleHealthKit, { HealthValue } from 'react-native-health';
import { readRecords } from 'react-native-health-connect';
import { Platform } from 'react-native';

export async function getTodaySteps(): Promise&lt;number&gt; {
  const start = new Date(new Date().setHours(0, 0, 0, 0));
  const end = new Date();

  if (Platform.OS === 'ios') {
    return new Promise((resolve) =&gt; {
      AppleHealthKit.getStepCount(
        { date: end.toISOString() },
        (err, r: HealthValue) =&gt; resolve(err ? 0 : r.value),
      );
    });
  }

  const { records } = await readRecords('Steps', {
    timeRangeFilter: {
      operator: 'between',
      startTime: start.toISOString(),
      endTime: end.toISOString(),
    },
  });
  return records.reduce((sum, r) =&gt; sum + r.count, 0);
}
</code></pre>
<p>Two things this hides that will bite you later. <strong>Background delivery</strong> on iOS lets HealthKit wake your app when a new sample arrives — set it up once at boot, then treat it like a push. Android offers no equivalent background delivery from Health Connect; you poll on app resume instead, which is fine for most workouts but wrong for continuous monitoring.</p>
<h2>Step 3: Talk to a wearable over Bluetooth Low Energy</h2>
<p>This is where the real work is. Most consumer fitness wearables expose their data over BLE using either a standard GATT service (heart rate: <code>0x180D</code>, cycling power: <code>0x1818</code>, running speed and cadence: <code>0x1814</code>) or a vendor-specific protocol you'll have to reverse-engineer from their SDK docs.</p>
<h3>Install and configure react-native-ble-plx</h3>
<pre><code class="language-bash">npx expo install react-native-ble-plx
</code></pre>
<p>Add the plugin to <code>app.json</code> with the location and Bluetooth usage strings. iOS 13+ requires both; Android 12+ needs the <code>BLUETOOTH_SCAN</code> and <code>BLUETOOTH_CONNECT</code> runtime permissions:</p>
<pre><code class="language-json">[
  "react-native-ble-plx",
  {
    "isBackgroundEnabled": true,
    "modes": ["peripheral", "central"],
    "bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to your heart rate monitor"
  }
]
</code></pre>
<h3>Subscribe to a standard heart-rate monitor</h3>
<p>This is about 40 lines and works with any GATT-compliant strap on the market:</p>
<pre><code class="language-typescript">import { BleManager, Characteristic } from 'react-native-ble-plx';
import { Buffer } from 'buffer';

const manager = new BleManager();
const HR_SERVICE = '0000180d-0000-1000-8000-00805f9b34fb';
const HR_MEASUREMENT = '00002a37-0000-1000-8000-00805f9b34fb';

export function subscribeToHeartRate(
  deviceId: string,
  onBpm: (bpm: number) =&gt; void,
): () =&gt; void {
  const sub = manager.monitorCharacteristicForDevice(
    deviceId,
    HR_SERVICE,
    HR_MEASUREMENT,
    (error, characteristic: Characteristic | null) =&gt; {
      if (error || !characteristic?.value) return;
      const bytes = Buffer.from(characteristic.value, 'base64');
      const flags = bytes[0];
      const is16Bit = (flags &amp; 0x01) !== 0;
      const bpm = is16Bit ? bytes.readUInt16LE(1) : bytes[1];
      onBpm(bpm);
    },
  );
  return () =&gt; sub.remove();
}
</code></pre>
<p>Two hard-won lessons from shipping BLE apps:</p>
<ul>
<li><strong>Never scan continuously.</strong> BLE scanning on iOS wakes the radio and destroys battery. Scan for pairing only; once you know the device ID, call <code>connectToDevice(id)</code> directly next time and start it in the background so reconnect is instant.</li>
<li><strong>Reconnect is a state machine, not a callback.</strong> A worn HR strap disconnects whenever the user walks out of range, sweats too much, or moves their phone. Your app needs an explicit <code>disconnected → reconnecting → connected</code> state that survives foreground/background transitions.</li>
</ul>
<p>Skip either of these and your five-star reviews become one-star "app drains my battery" reviews within a week of launch.</p>
<h2>Step 4: Persist, sync, and survive backgrounding</h2>
<p>You now have a hook streaming BPM in real time. Where does it go?</p>
<p>The pattern that works in production is a <strong>three-tier data store</strong>:</p>
<ol>
<li><strong>In-memory ring buffer</strong> — the last 60 seconds of raw samples for the live chart. Never persisted. Fastest possible read.</li>
<li><strong>On-device SQLite</strong> (via <code>expo-sqlite</code> or WatermelonDB) — the durable copy of every session. Writes are batched every second, not per sample.</li>
<li><strong>Cloud (optional)</strong> — Supabase or your own Postgres, synced when the network is available.</li>
</ol>
<p>The on-device layer is non-negotiable. Users run out of cell coverage, and losing a workout because "the cloud sync failed" is unforgivable. Store first, sync opportunistically.</p>
<p>For background persistence during long workouts (a marathon, a bike ride), enable Expo's background modes:</p>
<pre><code class="language-json">{
  "ios": {
    "infoPlist": {
      "UIBackgroundModes": ["bluetooth-central", "location", "fetch"]
    }
  },
  "android": {
    "permissions": ["FOREGROUND_SERVICE", "FOREGROUND_SERVICE_HEALTH"]
  }
}
</code></pre>
<p>On Android 14+, a health-tracking foreground service is required if the app will keep BLE alive while the screen is off. Getting this wrong is a Play Store rejection.</p>
<h2>Step 5: Ship it</h2>
<p>Once your app runs, EAS Build handles the rest:</p>
<pre><code class="language-bash">eas build --profile production --platform all
eas submit --platform ios
eas submit --platform android
</code></pre>
<p>Two App Store gotchas specific to fitness and health apps:</p>
<ul>
<li><strong>HealthKit apps require a privacy policy URL</strong> in App Store Connect and detailed usage descriptions in <code>Info.plist</code> for every read/write category.</li>
<li><strong>Sign in with Apple is mandatory</strong> if you offer <em>any</em> other login method (Google, email, magic link). Adding the button takes 30 seconds; getting rejected and resubmitting takes 3–7 days.</li>
</ul>
<p>If this is your first TestFlight submission, the walkthrough on <a href="https://www.rapidnative.com/blogs/how-to-publish-a-react-native-app-to-the-app-store?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=how-to-build-fitness-wearable-companion-app-react-native">publishing a React Native app to the App Store</a> covers every step, including the signing pitfalls that catch first-time submitters.</p>
<p><img src="https://images.unsplash.com/photo-1508685096489-7aacd43bd3b1?w=1200" alt="Smartwatch showing workout metrics" />
<em>Real fitness apps live or die on how well they survive the last 5% — background, offline, low-battery — Photo by Filip Mroz on Unsplash</em></p>
<h2>The shortcut: generate the scaffold, then focus on the wearable</h2>
<p>After building several of these, the pragmatic take is this: the parts that make a fitness wearable app <em>yours</em> — the device protocol, the training-load math, the coaching UX — are maybe 25% of the code. The other 75% is scaffold everyone builds identically: onboarding, permission flows, tab bar, session list, chart cards, settings, auth, a Postgres schema for <code>sessions</code> and <code>samples</code>, a Supabase client wired up correctly.</p>
<p>That 75% is what an AI app builder generates from a prompt. Describe the app once — "a fitness companion app with onboarding, device pairing, live session, session history, and settings; use Supabase for sessions" — and get a real Expo project with expo-router routes, a wired-up store, a Postgres schema with RLS policies, and a preview you can open on your phone via QR code. Drop the BLE code and HealthKit hooks from this article into that project and you're most of the way to shipping.</p>
<p>You still write the interesting code. You just don't rewrite the boilerplate.</p>
<h2>Frequently asked questions</h2>
<h3>Can you build a fitness app with React Native?</h3>
<p>Yes. React Native is one of the most common stacks for fitness apps because it gives you shared code across iOS and Android while still exposing native APIs like HealthKit, Health Connect, and Bluetooth Low Energy through well-maintained libraries (<code>react-native-health</code>, <code>react-native-health-connect</code>, <code>react-native-ble-plx</code>).</p>
<h3>Does React Native support HealthKit and Health Connect?</h3>
<p>Yes, both. <code>react-native-health</code> wraps Apple HealthKit on iOS, and <code>react-native-health-connect</code> wraps Health Connect on Android (which replaced Google Fit's developer API in 2025). Both ship Expo config plugins, so setup is a few lines in <code>app.json</code> rather than a manual native code edit.</p>
<h3>How long does it take to build a fitness wearable app?</h3>
<p>A solo developer working full-time can ship a first version — pairing, live session, HealthKit sync, one chart — in 4–8 weeks. Most of that time goes to the wearable integration and background reliability, not the UI. Generating the scaffold with an AI builder can compress the UI and data-layer work from roughly two weeks to a day.</p>
<h3>Do I need bare React Native, or is Expo enough?</h3>
<p>Expo is enough. Every library referenced in this guide — health, BLE, background modes — ships an Expo config plugin. The trade-off that used to justify bare React Native (needing to edit native code) largely no longer applies as of Expo SDK 52+.</p>
<h2>Wrapping up</h2>
<p>Building a fitness wearable app with React Native isn't hard because the tools are missing — it's hard because there are 30 small integrations (permissions, background modes, BLE reconnect, health-store round-trips) and every one has an edge case. Nail the stack (Expo + <code>react-native-health</code> + <code>react-native-health-connect</code> + <code>react-native-ble-plx</code>), design your screens before you touch a peripheral, and treat wearable disconnection as a first-class state, and you'll have a shippable app in weeks rather than quarters.</p>
<p>The scaffold is the boring part. <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=blog&amp;utm_campaign=how-to-build-fitness-wearable-companion-app-react-native">Describe your fitness app to RapidNative</a> to get the six screens, the store, and the Supabase-backed data layer generated in one shot — then spend your time on the wearable code that differentiates the product. It's free to start with 20 credits, no credit card required.</p>
]]></content:encoded></item><item><title><![CDATA[The Complete Guide to React Native Debugging in 2026]]></title><description><![CDATA[If you last debugged a React Native app in 2023, almost every tool you reached for is gone. Flipper is deprecated. The standalone React Native Debugger has stopped receiving Hermes updates. The old "D]]></description><link>https://smithchloe.hashnode.dev/the-complete-guide-to-react-native-debugging-in-2026</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/the-complete-guide-to-react-native-debugging-in-2026</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[debugging]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Fri, 25 Sep 2026 08:06:08 GMT</pubDate><content:encoded><![CDATA[<p>If you last debugged a React Native app in 2023, almost every tool you reached for is gone. Flipper is deprecated. The standalone React Native Debugger has stopped receiving Hermes updates. The old "Debug JS Remotely" toggle no longer exists in modern React Native. And as of React Conf 2025, the New Architecture (Fabric renderer + TurboModules) is no longer an opt-in — it is the only architecture, which means the debugging entry points, the crash signatures, and the profiling flows are all different.</p>
<p>This guide is a complete, up-to-date walkthrough of React Native debugging in 2026 — what tool to use for which problem, how to attach it, what to do when your screen goes blank or your app crashes only in release mode, and how to keep production instrumented so you find bugs before your users do.</p>
<p><img src="https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200" alt="Developer debugging a mobile app on a laptop next to a phone showing React Native code" />
<em>The 2026 React Native debugging stack is Hermes-first, DevTools-native, and increasingly Chrome DevTools Protocol-based. Photo by Emile Perron on Unsplash.</em></p>
<h2>What React Native debugging looks like in 2026</h2>
<p>React Native debugging is the process of inspecting JavaScript runtime state, native module behavior, network traffic, and performance metrics in a React Native or Expo application to identify and fix defects. In 2026, the default debugger is React Native DevTools, which ships built-in with React Native 0.76+ and attaches directly to the Hermes runtime through the Chrome DevTools Protocol. You launch it by pressing <code>j</code> in the Metro terminal.</p>
<p>That short definition hides three shifts worth spelling out, because they change how you approach every bug:</p>
<ol>
<li><strong>Hermes is the default engine.</strong> JavaScriptCore is on its way out. All the modern debugging entry points — CPU profiles, heap snapshots, breakpoints — assume Hermes and talk to it directly through CDP.</li>
<li><strong>The New Architecture is mandatory.</strong> Fabric renders your UI, TurboModules replace the old async bridge, and Codegen produces the C++ interfaces both sides speak. A whole class of "message dropped on the bridge" bugs is gone; a new class of Codegen mismatches and synchronous C++ crashes is here.</li>
<li><strong>Debugging is browser-aligned.</strong> The React Native DevTools frontend is a customized build of Chrome DevTools. If you know how to debug a web app in Chrome, most of what you know now works on device.</li>
</ol>
<p>The rest of this guide is organized around that reality: the tool you already have, when to reach for something else, and the workflows that actually catch bugs in 2026.</p>
<h2>The 2026 React Native debugging tool stack</h2>
<p>Here is the shortest possible mapping between "what am I trying to do" and "what do I open":</p>
<table>
<thead>
<tr>
<th>I want to…</th>
<th>Reach for</th>
</tr>
</thead>
<tbody><tr>
<td>Set a breakpoint, inspect variables, step through JS</td>
<td><strong>React Native DevTools</strong> (press <code>j</code> in Metro)</td>
</tr>
<tr>
<td>Watch network requests</td>
<td><strong>DevTools → Network</strong> (or Reactotron)</td>
</tr>
<tr>
<td>Inspect the component tree, props, state, hooks</td>
<td><strong>DevTools → Components</strong> (React DevTools)</td>
</tr>
<tr>
<td>Measure render performance</td>
<td><strong>DevTools → Profiler</strong> or the Hermes CPU profile</td>
</tr>
<tr>
<td>Trace Redux, Zustand, or MMKV state</td>
<td><strong>Reactotron</strong></td>
</tr>
<tr>
<td>Debug a native crash on iOS</td>
<td><strong>Xcode</strong> — Debug Navigator + <code>.crash</code>/<code>.ips</code> symbolication</td>
</tr>
<tr>
<td>Debug a native crash on Android</td>
<td><strong>Android Studio Logcat</strong> + <code>ndk-stack</code></td>
</tr>
<tr>
<td>Find why a production build is crashing users you can't reach</td>
<td><strong>Sentry, Bugsnag, or Firebase Crashlytics</strong> with uploaded source maps</td>
</tr>
<tr>
<td>Reproduce a "works on my machine" release-only bug</td>
<td>An EAS internal-distribution build + Sentry</td>
</tr>
<tr>
<td>Live-tweak UI without a full reload</td>
<td><strong>Fast Refresh</strong> (on by default; press <code>r</code> to force reload)</td>
</tr>
</tbody></table>
<p>Everything else is an instance of one of these. Keep the table close for the first month; you'll internalize it fast.</p>
<h2>Setting up React Native DevTools</h2>
<p>If you are on React Native 0.76 or later (or Expo SDK 52+), you already have DevTools installed. There is nothing to <code>npm install</code>. The workflow is:</p>
<ol>
<li>Start your app with <code>npx expo start</code> or <code>npx react-native start</code>.</li>
<li>In the Metro terminal, press <code>j</code>.</li>
<li>A Chromium window opens with Sources, Console, Network, Performance, Memory, and Components tabs — attached to your Hermes runtime on the connected device or simulator.</li>
</ol>
<p>The important thing about this architecture is that DevTools connects to the Hermes CDP endpoint in your app process. Your JavaScript keeps running inside Hermes on the device. It is not being shipped over the network to Chrome to be executed there, the way "Debug JS Remotely" used to work. That old model produced a legendary category of Heisenbugs where a bug would disappear the moment the debugger attached, because your code was suddenly running on V8 in Chrome instead of Hermes on the device. That entire class of bug is gone in 2026.</p>
<p>Two practical consequences:</p>
<ul>
<li><strong>Timing-sensitive bugs are now reproducible under the debugger.</strong> Native modules, animations, <code>InteractionManager</code> callbacks all run at real device speed.</li>
<li><strong><code>console.log</code> output is fast.</strong> Logs stream over CDP; you can leave them on during profiling without dropping frames the way the old socket-based bridge did.</li>
</ul>
<p><img src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200" alt="Chrome DevTools open on a laptop screen, inspecting a React application" />
<em>React Native DevTools is a customized build of Chrome DevTools, so most muscle memory from web debugging transfers directly. Photo by Ilya Pavlov on Unsplash.</em></p>
<h2>Debugging JavaScript and React components</h2>
<p>Ninety percent of React Native bugs live in JavaScript, which is why DevTools is the default answer. The three views you will use daily:</p>
<p><strong>Sources.</strong> Set breakpoints by clicking a line number. When execution pauses, the call stack, scope, watches, and closures panel all work exactly like the browser. You can also drop <code>debugger;</code> into your code — the DevTools window will pop to front on hit. For code that only runs on startup, set the breakpoint, then trigger a reload with <code>r</code> in Metro or shake → Reload.</p>
<p><strong>Console.</strong> <code>console.log</code>, <code>console.warn</code>, <code>console.error</code>, and <code>console.table</code> all render. <code>console.trace</code> gives you a stack. If you are debugging a hook that runs on every render, prefer <code>console.count('MyComponent render')</code> so you can see cadence at a glance.</p>
<p><strong>Components (React DevTools).</strong> This is the React inspector, embedded in the same window. Select any element to see its props, state, hooks, and re-render reasons. Turn on "Highlight updates when components render" during a suspicious interaction — anything blinking that shouldn't be re-rendering is a wasted render, and usually a missing <code>React.memo</code> or a new object literal being passed as a prop.</p>
<p>For the New Architecture specifically, one Components-panel behavior is worth flagging: Fabric elements now render synchronously in the same commit as your React tree. If a prop change reaches your component tree but not the native view, the mismatch is almost always in the Codegen spec — either a stale generated file or a props type mismatch that Codegen accepted. Regenerate before you debug further.</p>
<h2>Debugging network requests</h2>
<p>Open the Network tab in DevTools. Every <code>fetch</code>, <code>XMLHttpRequest</code>, and Axios call is captured, including headers, request body, response body, timings, and status. This replaces the Flipper Network plugin entirely.</p>
<p>Two gotchas that still catch people:</p>
<ul>
<li><strong>WebSocket frames are not captured</strong> in the Network panel. Use <code>console.log</code> on your WebSocket handler, or watch traffic at the OS level with Charles Proxy / Proxyman.</li>
<li><strong>Requests made from a native module</strong> (e.g., a native SDK's HTTP client) do not appear in the JS Network panel. Use Charles Proxy or platform tooling.</li>
</ul>
<p>For iOS simulator SSL inspection, install the Charles root certificate in the simulator's Trust Store — this is easier than it used to be, but it is still a one-time setup you will forget you did.</p>
<h2>Debugging state (Redux, Zustand, MMKV, TanStack Query)</h2>
<p>DevTools does not natively understand your state library. The tool that does is <strong>Reactotron</strong>, which is still actively maintained and now has first-class Hermes and New Architecture support.</p>
<p>Install it as a dev-only dependency and initialize it in a file you only import in development. Reactotron gives you:</p>
<ul>
<li>A Redux state tree with time-travel (via a middleware or the <code>redux-devtools-extension</code> bridge).</li>
<li>A live view of Zustand stores.</li>
<li>MMKV read/write logs, which are invaluable because MMKV is synchronous and easy to accidentally block the UI thread with.</li>
<li>TanStack Query devtools output — the queries, their cache keys, staleness, and refetch reasons.</li>
<li>A custom command panel so you can inject "log in as user X" or "clear cache" buttons for QA.</li>
</ul>
<p>Wire Reactotron up once per project. The five minutes it costs you saves hours the first time a Redux action fires with a payload nobody wrote.</p>
<h2>Debugging the New Architecture</h2>
<p>Fabric and TurboModules eliminated the old async message bridge, which fixed a lot and broke a little. The bugs you now see:</p>
<ul>
<li><strong>Codegen mismatches.</strong> Your TypeScript spec says a prop is <code>string</code>; the native side reads it as <code>number</code>. In the old world, this would silently no-op. In the new world it produces a hard C++ crash on first render. Fix: regenerate with <code>npx react-native codegen</code> (or clean-build Expo), and always keep your <code>.ts</code> spec, <code>.podspec</code>, and <code>build.gradle</code> in sync.</li>
<li><strong>Synchronous TurboModule crashes.</strong> The bridge used to swallow exceptions from native modules. TurboModules can be called synchronously, so a thrown exception unwinds straight to your JavaScript and crashes the app if you don't <code>try/catch</code>. Wrap every TurboModule call site that touches user input.</li>
<li><strong>View flattening surprises.</strong> Fabric flattens Views aggressively for performance. A <code>View</code> with no styles that used to be measurable via <code>onLayout</code> may no longer exist as a native view at all. If a layout callback stops firing after upgrading, add a <code>collapsable={false}</code> and re-test.</li>
</ul>
<p>For deeper native inspection, launch the app from Xcode or Android Studio and use their debuggers — LLDB on iOS, LLDB or Java Debugger on Android — with the app running. This is not different from before, but it is the only path when the crash is below the JS layer.</p>
<h2>Performance profiling with the Hermes sampling profiler</h2>
<p>If your app feels slow but you don't have a specific crash, the fastest way to find the cause is a Hermes CPU profile.</p>
<ol>
<li>In your app, wrap the interaction you want to measure with <code>HermesInternal.enableSamplingProfiler()</code> / <code>disableSamplingProfiler()</code> — or trigger it from the DevTools Performance tab's "Record" button, which now maps to the same primitive.</li>
<li>Perform the interaction (open the slow screen, tap the sluggish button).</li>
<li>Stop recording. DevTools will render a flame chart of JavaScript execution, with millisecond-level resolution.</li>
</ol>
<p>What to look for:</p>
<ul>
<li><strong>A single tall function</strong> dominating the flame chart is usually an expensive <code>.map</code> over a large array, or JSON parsing on the JS thread.</li>
<li><strong>A wide, shallow bar of <code>render</code> calls</strong> is a re-render storm — every child rendering because a parent's memoized prop lost referential equality.</li>
<li><strong>Long gaps between JS frames</strong> with nothing in them are native-side work — probably an image decode, a layout pass, or a synchronous bridge call on the old architecture. Cross-check with the Systrace overlay if you need to see the native side too.</li>
</ul>
<p>For heavy investigations, drop the profile file into Chrome DevTools' Performance tab on your desktop for a bigger view than the embedded panel.</p>
<p><img src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200" alt="Screens showing performance graphs and CPU flame charts" />
<em>A Hermes CPU profile visualized as a flame chart makes it obvious where JavaScript is spending time. Photo by Luke Chesser on Unsplash.</em></p>
<h2>Debugging native crashes</h2>
<p>If your JavaScript throws, you see a red screen in development or a JS error in production. If your native code throws, the app dies immediately with no red screen. That is the tell.</p>
<p><strong>iOS.</strong> Attach with Xcode → Product → Attach to Process → your app. When it crashes, the Debug Navigator shows the thread that died and the stack. For production crashes, download the <code>.ips</code> file from App Store Connect, symbolicate it with <code>atos</code> and the matching <code>.dSYM</code>, and the stack becomes readable — this is the same flow Sentry and Crashlytics automate for you if you upload dSYMs during your EAS build.</p>
<p><strong>Android.</strong> <code>adb logcat</code> is still the answer. Filter with <code>ReactNativeJS</code> for JS logs, or <code>System.err</code> and <code>AndroidRuntime</code> for native. A JNI crash shows a memory address; run it through <code>ndk-stack</code> with the matching <code>libhermes.so</code> symbols and you get a real stack. Crashlytics does this for production.</p>
<p><strong>Hermes JS crashes</strong> are different from either. They emit a stack trace with obfuscated file paths (e.g., <code>index.android.bundle:1:1234</code>). Upload your Hermes source map to Sentry or Bugsnag as part of your CI build, and those stacks become source-mapped back to your original TypeScript.</p>
<h2>Production monitoring</h2>
<p>Development debugging tells you about bugs you can reproduce. Production monitoring tells you about the ones you can't. In 2026 the mature choices are Sentry, Bugsnag, and Firebase Crashlytics. All three now support:</p>
<ul>
<li>Hermes source map upload during EAS build via a plugin.</li>
<li>Native crash symbolication (dSYM on iOS, ProGuard/R8 mappings on Android, Hermes bundles).</li>
<li>Release health metrics — crash-free sessions, adoption rate, regression comparison across versions.</li>
<li>Breadcrumbs (recent navigation events, network requests, console logs) attached to each crash.</li>
</ul>
<p>Set up one of them on day one of your project. Retroactively wiring it up after your first App Store crash spike is much harder than pre-installing it and never seeing the alert.</p>
<h2>Common debugging workflows</h2>
<p>A few debugging scenarios come up often enough that they deserve a named workflow.</p>
<p><strong>Blank screen on device, everything works in the emulator.</strong> Almost always a Metro bundler mismatch — your device is holding the old bundle after a <code>package.json</code> change forced a Metro restart. Fix: shake the device → Reload, or close and reopen the app. This is the same class of bug that hits the RapidNative preview pipeline, where a template dependency change severs the running Expo Go session; the app is fine, the client just needs a reload. If you are running a cloud preview and it looks blank, check the workload logs for a fresh <code>bundled in …ms</code> — if you see it, the bundle is fine and only the phone needs the reload.</p>
<p><strong>Red screen with an unhelpful stack.</strong> Turn on source maps in dev if you haven't (they are on by default in modern React Native but people disable them for build speed). If the stack points at a minified file, your source maps are not being generated.</p>
<p><strong>Fast Refresh not refreshing.</strong> Usually one of your files has a syntax error that Fast Refresh could not compile, and it silently fell back to full-reload mode. Save the file with a working state; refresh resumes.</p>
<p><strong>Repeated mid-stream reloads during development.</strong> In our own tooling for the RapidNative editor we learned the hard way that reloading while an app is streaming its content restarts the whole app and discards any partial render — reloads must be gated on real dependency changes, not on every file update. If you write custom tooling around HMR, check whether you're triggering more reloads than you think.</p>
<p><strong>"Works locally, crashes in release."</strong> The two most common causes are (1) a <code>console.log</code> that only ran because the debugger was attached and papered over a race, and (2) a native module bundled differently in release. Build a local release build (<code>npx expo run:ios --configuration Release</code> or the Android equivalent), reproduce, and attach Xcode/Android Studio to it.</p>
<h2>How AI-generated apps sidestep entire bug classes</h2>
<p>At <a href="https://www.rapidnative.com/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-debugging-guide-2026">RapidNative</a> we build a lot of React Native and Expo apps from natural-language prompts, which gives us a data set most teams don't have: what bugs the codebase-level defaults actually prevent. Two patterns are worth borrowing regardless of whether you use an AI builder:</p>
<ul>
<li><strong>Every generated screen ships with the modern data-fetching stack pre-wired</strong> — TanStack Query for server state, Zustand or Redux Toolkit for client state, and a Supabase client for the database — with the debugger hooks already attached. You do not have to remember to install Reactotron. The most productive debugging setup is the one that came pre-installed.</li>
<li><strong>Migrations, RLS policies, and generated database types stay in lockstep.</strong> When schema and types drift, you get exactly the class of runtime bug that produces a blank screen with no error in the console. Regenerating types on every schema change eliminates it. You can <a href="https://www.rapidnative.com/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-debugging-guide-2026">describe the app you want to build</a> and see the whole stack come up debugged from the first render.</li>
</ul>
<p>If you want a broader look at how AI-generated codebases hold up under debugging pressure, we wrote about <a href="https://www.rapidnative.com/blogs/how-we-test-ai-generated-react-native-code-at-scale?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-debugging-guide-2026">how we test AI-generated React Native code at scale</a> and about <a href="https://www.rapidnative.com/blogs/how-ai-generated-apps-stay-fast-our-performance-approach?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-debugging-guide-2026">keeping AI-generated apps fast</a>.</p>
<h2>Frequently asked questions</h2>
<p><strong>Is React Native Debugger still usable in 2026?</strong>
No. Standalone React Native Debugger stopped receiving Hermes updates in 2023 and is not compatible with the New Architecture. Use React Native DevTools (press <code>j</code> in Metro) instead. It ships built-in with React Native 0.76+ and Expo SDK 52+ and covers every use case the standalone debugger did.</p>
<p><strong>Do I still need Flipper?</strong>
No. Flipper was deprecated as a first-class React Native tool in 2023 and its plugins have moved elsewhere. Network requests, React inspection, and performance profiling all live in React Native DevTools. Redux/Zustand/MMKV inspection moved to Reactotron.</p>
<p><strong>How do I debug a release build?</strong>
Build a release binary locally, attach Xcode or Android Studio to the running process, and use the platform's native debugger for crashes below the JS layer. For JS-only issues in release, upload Hermes source maps to Sentry or Bugsnag during your EAS build so production stacks resolve back to your TypeScript.</p>
<p><strong>Can I still use <code>console.log</code> for debugging?</strong>
Yes. In 2026 it is fast (streamed over CDP), does not drop frames, and integrates with the DevTools Console. Prefer <code>console.warn</code>/<code>console.error</code> for signals you want to notice in a busy log, and <code>console.count</code> for hooks that fire too often.</p>
<p><strong>What is the fastest way to find a slow render?</strong>
Open React DevTools → Components, enable "Highlight updates when components render," and interact with the screen. Anything that lights up on an interaction it shouldn't be affected by is a wasted render. Follow up with a Hermes CPU profile if you need microsecond-level cost.</p>
<h2>Wrap-up</h2>
<p>React Native debugging in 2026 is genuinely better than it was three years ago — one built-in tool covers most cases, Hermes runs debug and release with the same semantics, and the New Architecture removed the async bridge that used to swallow half your errors. The catch is that the muscle memory from the Flipper era does not transfer. Start with React Native DevTools, add Reactotron when you need state visibility, wire Sentry in on day one, and you have covered the ninety-percent case before you have written a line of business logic.</p>
<p>If you want to skip the setup entirely and start with a project where the whole debugging stack is already wired up, <a href="https://www.rapidnative.com/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-debugging-guide-2026">describe the app you want and generate it in the RapidNative editor</a> — every project ships with DevTools, source maps, and a testable Supabase backend attached from the first prompt.</p>
<p>Further reading: the official <a href="https://reactnative.dev/docs/debugging">React Native debugging docs</a>, the <a href="https://hermesengine.dev/">Hermes engine documentation</a>, and the <a href="https://chromedevtools.github.io/devtools-protocol/">Chrome DevTools Protocol reference</a> for anyone building custom debugging tools on top of the same primitives.</p>
]]></content:encoded></item><item><title><![CDATA[Push Notifications: The Setup Checklist That Actually Delivers (and Passes Review)]]></title><description><![CDATA[Why push is the feature most likely to "work on my machine" and nowhere else
Every indie developer I know has shipped a build where notifications worked in the simulator, worked on their own phone, an]]></description><link>https://smithchloe.hashnode.dev/push-notifications-the-setup-checklist-that-actually-delivers-and-passes-review</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/push-notifications-the-setup-checklist-that-actually-delivers-and-passes-review</guid><category><![CDATA[mobile]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><category><![CDATA[React Native]]></category><category><![CDATA[Firebase]]></category><category><![CDATA[push notifications]]></category><category><![CDATA[indie hackers]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Thu, 24 Sep 2026 06:16:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/4b9c78e8-cd12-4568-a150-00e5db34ddad.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Why push is the feature most likely to "work on my machine" and nowhere else</h2>
<p>Every indie developer I know has shipped a build where notifications worked in the simulator, worked on their own phone, and silently did nothing for the first 200 real users.</p>
<p>Push is uniquely bad this way because nothing in the chain is yours. Your server talks to Apple's APNs or Google's FCM, they talk to the OS, the OS decides whether the user ever sees it. When it fails, you get no crash, no log line, no support ticket — just a retention chart that never bends upward.</p>
<p>And the failure modes compound the same way privacy rejections do:</p>
<ul>
<li><p>Prod certificate vs. dev certificate mismatch → fix, rebuild, resubmit → 24–48 hours in review</p>
</li>
<li><p>Permission prompt fires on first launch → most users tap "Don't Allow" → you can never ask again in-app</p>
</li>
<li><p>Android 13+ users never see the prompt at all because you forgot the runtime permission → whole platform is dark Apple rejects apps that spam the permission prompt or require push to function (Guideline 4.5.4). Google throttles apps that abuse high-priority messages. And the platforms keep moving: Android 13 made notifications opt-in, iOS 16 added Live Activities, Android 16 added Live Updates, iOS 26 added broadcast push and Apple Intelligence prioritization that can bury your message if it looks like noise.</p>
</li>
</ul>
<p>This post is the checklist I wish I'd had before my first "why is nobody getting these?" week.</p>
<h2>The four things you must have</h2>
<p>Before push works in production, you need four linked-and-consistent pieces:</p>
<h3>1. Platform credentials that match the build you ship</h3>
<ul>
<li><p><strong>iOS:</strong> an APNs auth key (<code>.p8</code>) — not a certificate. Keys don't expire, work for every app on your team, and work for both sandbox and production. Store the Key ID, Team ID, and Bundle ID next to it; you'll need all three on the server.</p>
</li>
<li><p><strong>Android:</strong> a Firebase project with the FCM HTTP v1 API enabled and a service-account JSON. The legacy FCM API was shut down in 2024 — if a tutorial mentions a "Server key" string, it's out of date.</p>
</li>
<li><p><strong>Entitlements:</strong> the <code>aps-environment</code> entitlement in your iOS build, and <code>google-services.json</code> in the correct Android flavor. A TestFlight build with <code>development</code> in the entitlement gets nothing from production APNs.</p>
</li>
</ul>
<h3>2. A permission flow the user actually says yes to</h3>
<p>Both platforms now require explicit opt-in (iOS always has; Android from 13 / API 33). You get <strong>one</strong> system prompt. After a "no," the only recovery is sending the user to Settings.</p>
<p>So the prompt must come <em>after</em> the user understands the value: after they create their first reminder, follow their first thread, place their first order. Never on first launch.</p>
<h3>3. A device-token pipeline that survives reality</h3>
<p>Tokens change. They change on reinstall, on restore from backup, on OS upgrade, sometimes on a whim. Your app needs to:</p>
<ul>
<li><p>Register for a token on every launch (not just once)</p>
</li>
<li><p>Send it to your backend whenever it differs from the last one sent</p>
</li>
<li><p>Associate it with the user <em>and</em> the device, so logout clears it and multi-device works</p>
</li>
<li><p>Handle the platform telling you a token is dead (APNs <code>410 Unregistered</code>, FCM <code>UNREGISTERED</code>) by deleting it — keep sending to dead tokens and both platforms will start dropping <em>valid</em> ones</p>
</li>
</ul>
<h3>4. A payload contract your app version can parse</h3>
<p>Notifications outlive app versions. The payload you send today gets opened by a build from six months ago. Define a small, versioned JSON shape (<code>type</code>, <code>id</code>, <code>deeplink</code>, <code>v</code>) and never change the meaning of a field, only add new ones.</p>
<h2>Common failure patterns and what caused them</h2>
<h3>Pattern 1: Prompt on first launch</h3>
<p>iOS opt-in rates sit in the mid-50s percent on average; apps that prompt cold do far worse. The fix is a <strong>pre-permission screen</strong> — your own UI explaining what the user gets, with a "Turn on" button that triggers the system prompt and a "Not now" that doesn't. If they tap "Not now," you've spent nothing and can ask again later.</p>
<h3>Pattern 2: Works on iOS, silent on Android 13+</h3>
<p><code>POST_NOTIFICATIONS</code> is a runtime permission since API 33. If you never call <code>requestPermission()</code>, the OS never asks, and FCM delivers messages to a channel nobody can see. Also: every notification needs a <strong>channel</strong> on Android 8+. No channel, no notification, no error.</p>
<h3>Pattern 3: Sandbox/production mismatch</h3>
<p>Your server points at <code>api.sandbox.push.apple.com</code> but the TestFlight build is a production build (TestFlight <em>is</em> production for APNs). Or the reverse. Symptom: <code>BadDeviceToken</code>. Fix: derive the APNs host from the build's entitlement, and log which one you hit.</p>
<h3>Pattern 4: Data-only messages that never wake the app</h3>
<p>On iOS, a background push needs <code>content-available: 1</code>, the <code>apns-push-type: background</code> header, the <code>remote-notification</code> background mode enabled — and even then, the OS treats it as a hint, not a guarantee. On Android, data-only messages reach <code>onMessageReceived</code> only when the app is running or backgrounded; if the user force-quit it, or the device is in Doze, it waits. If a user must see it, send a <em>notification</em> message, not a data message.</p>
<h3>Pattern 5: Rich media that never shows</h3>
<p>Images and action buttons on iOS require a <strong>Notification Service Extension</strong> — a separate target that downloads the attachment before display. Skip the extension and the <code>mutable-content</code> flag and the notification renders as plain text. The extension gets ~30 seconds and limited memory; download small, fail gracefully.</p>
<h3>Pattern 6: Push that requires push</h3>
<p>Apple rejects apps that gate core functionality behind notification permission, and both stores reject notifications used for marketing without a separate opt-in. Ads-in-push are a rejection on iOS unless the user explicitly opted into them in-app.</p>
<h2>A working payload + pre-permission template</h2>
<p><strong>APNs payload (server → Apple):</strong></p>
<pre><code class="language-json">{
  "aps": {
    "alert": {
      "title": "Order #4821 is out for delivery",
      "body": "Arriving in about 20 minutes."
    },
    "sound": "default",
    "badge": 1,
    "mutable-content": 1,
    "thread-id": "order-4821",
    "category": "ORDER_UPDATE"
  },
  "v": 1,
  "type": "order.status",
  "id": "4821",
  "deeplink": "myapp://orders/4821",
  "image": "https://cdn.example.com/n/4821.jpg"
}
</code></pre>
<p>Headers: <code>apns-topic: com.example.app</code>, <code>apns-push-type: alert</code>, <code>apns-priority: 10</code>, <code>apns-collapse-id: order-4821</code>.</p>
<p><strong>FCM v1 payload (server → Google):</strong></p>
<pre><code class="language-json">{
  "message": {
    "token": "&lt;device-token&gt;",
    "notification": {
      "title": "Order #4821 is out for delivery",
      "body": "Arriving in about 20 minutes.",
      "image": "https://cdn.example.com/n/4821.jpg"
    },
    "data": {
      "v": "1",
      "type": "order.status",
      "id": "4821",
      "deeplink": "myapp://orders/4821"
    },
    "android": {
      "priority": "high",
      "collapse_key": "order-4821",
      "notification": { "channel_id": "orders" }
    }
  }
}
</code></pre>
<p>Note FCM <code>data</code> values must be strings. Keep the custom keys identical across both so the client parses one shape.</p>
<p><strong>Pre-permission copy (adapt to your app):</strong></p>
<blockquote>
<p><strong>Know when your order moves</strong></p>
<p>We'll ping you when it ships, when it's nearby, and if anything goes wrong. Nothing else — no promos unless you ask.</p>
<p><code>[ Turn on notifications ]</code> <code>[ Not now ]</code></p>
</blockquote>
<p>The key: every promise here has to be true. If you later send a promo without a separate opt-in, users disable everything, and both stores treat it as a policy issue.</p>
<h2>How to keep push working as you add features</h2>
<p>The failure mode I've seen most often: push works at launch; three months later you add a new notification type, ship it from the server first, and 40% of installs are on a build that crashes on the unknown payload — or you add a new Android channel in code but never create it, and that type silently vanishes.</p>
<p>The checklist that runs at every release:</p>
<ul>
<li><p>Did we add a new notification type? → Client ignores unknown <code>type</code> values gracefully; Android channel created on startup; iOS category registered.</p>
</li>
<li><p>Did we change any payload field? → Only additive; <code>v</code> bumped if semantics changed.</p>
</li>
<li><p>Did we touch credentials or bundle IDs? → Send a real test push to a TestFlight/internal-track build, not just the simulator.</p>
</li>
<li><p>Did we add a platform (Web Push, watchOS, Live Activities/Live Updates)? → Separate token type, separate registration path, separate test.</p>
</li>
<li><p>Are we cleaning dead tokens? → Check the 410/UNREGISTERED handler is still wired after any backend refactor.</p>
</li>
<li><p>Is the permission prompt still gated behind a value moment? → Re-check after onboarding redesigns; this regresses constantly. Five minutes per release. Beats a week of "notifications are broken" tickets you can't reproduce.</p>
</li>
</ul>
<h2>Tools + services that help</h2>
<ul>
<li><p><a href="https://developer.apple.com/documentation/usernotifications/testing-notifications-using-the-push-notification-console"><strong>Apple Push Notification Console</strong></a> — send test pushes and inspect delivery from Apple's side. First stop for any "is it us or them?" question.</p>
</li>
<li><p><a href="https://console.firebase.google.com/"><strong>Firebase Console → Messaging</strong></a> — compose test messages to a single token; use it to isolate client bugs from server bugs.</p>
</li>
<li><p><a href="https://docs.expo.dev/push-notifications/overview/"><strong>Expo Notifications</strong></a> / <a href="https://onesignal.com/"><strong>OneSignal</strong></a> / <a href="https://knock.app/"><strong>Knock</strong></a> — managed token + delivery layers if you'd rather not run APNs/FCM clients yourself. Trade-off: another SDK in your privacy manifest.</p>
</li>
<li><p><a href="https://github.com/KnuffApp/Knuff"><strong>Knuff</strong></a> / <a href="https://github.com/noodlewerk/NWPusher"><strong>NWPusher</strong></a> — desktop tools for firing raw APNs payloads at a token.</p>
</li>
<li><p><a href="https://letsdeploy.it/?utm_source=hashnode&amp;utm_medium=organic&amp;utm_campaign=push_notifications"><strong>LetsDeployIt</strong></a> — the service we're building for exactly this: pre-submission checks that catch sandbox/production mismatches, missing entitlements, and permission-prompt patterns that get rejected. (Yes, that's a plug. Debugging silent push failures is what motivated it.)</p>
</li>
</ul>
<h2>Closing</h2>
<p>Push is the one feature where "it works for me" is nearly meaningless, because the interesting failures happen on other people's phones, on other builds, with tokens you issued months ago.</p>
<p>The four pieces — credentials, permission flow, token pipeline, payload contract — have to match the build you ship, and they have to keep matching as you add notification types and platforms.</p>
<p>Get this right the first time and your retention chart will show it. Get it wrong and you'll spend more time explaining why nobody got the notification than building the thing it was about.</p>
<p>Ship notifications people actually receive. Save the ship-day for the code.</p>
]]></content:encoded></item><item><title><![CDATA[React Native Maps: Build Location Features That Actually Work]]></title><description><![CDATA[Adding a map to a React Native app looks easy — until you ship it. Then you learn about API keys that only fail in release builds, permission prompts that Apple rejects in review, marker lists that tu]]></description><link>https://smithchloe.hashnode.dev/react-native-maps-build-location-features-that-actually-work</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/react-native-maps-build-location-features-that-actually-work</guid><category><![CDATA[React Native]]></category><category><![CDATA[mobile app development]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Wed, 23 Sep 2026 08:36:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/c8a6fe74-2ccf-4225-81e5-5f9bb3936390.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Adding a map to a React Native app looks easy — until you ship it. Then you learn about API keys that only fail in release builds, permission prompts that Apple rejects in review, marker lists that turn your framerate into a slideshow, and the difference between "location permission granted" and "location actually arriving." This guide covers React Native Maps the way it actually behaves in production, not the way a 10-line demo makes it look.</p>
<p>If you're building anything with a "where" attached to it — a ride-share, a food delivery MVP, a fitness tracker, a real-estate app — you have essentially three library choices in 2026: <a href="https://github.com/react-native-maps/react-native-maps">react-native-maps</a>, the veteran; <a href="https://docs.expo.dev/versions/latest/sdk/maps/">expo-maps</a>, the modern SwiftUI/Jetpack Compose alternative; and third-party wrappers like Mapbox. We'll walk through when to pick each, how to wire up markers and location tracking without leaking memory, and how AI mobile app builders like <a href="https://www.rapidnative.com/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">RapidNative</a> scaffold this entire stack from a plain-English prompt.</p>
<img src="https://images.unsplash.com/photo-1524661135-423995f22d0b?w=1200" alt="Mobile phone displaying a map with location pins in a city environment" style="display:block;margin:0 auto" />

<p><em>Location-aware apps are the highest-value category in the App Store — and the most technically expensive to build correctly. — Photo by henry perks on Unsplash</em></p>
<h2>What is React Native Maps and why does everyone use it?</h2>
<p><strong>React Native Maps is a cross-platform mapping library that renders native map views inside a React Native app — Google Maps on Android, and either Apple Maps or Google Maps on iOS.</strong> It exposes a declarative <code>MapView</code> component plus primitives like <code>Marker</code>, <code>Polyline</code>, <code>Polygon</code>, and <code>Circle</code>. Under the hood it bridges to <code>com.google.android.gms.maps.MapView</code> and <code>MKMapView</code>, so gestures, tiles, and rendering all happen at native speed rather than in JavaScript.</p>
<p>Two things made it the default choice: it works inside Expo Go without a config plugin, and it has close to a decade of production battle-scars — including AirBnB's early sponsorship. At the time of writing it does close to 100,000 weekly downloads on npm, which is roughly two orders of magnitude more than the closest alternative.</p>
<h2>react-native-maps vs expo-maps: which one to pick in 2026</h2>
<p>Expo shipped <a href="https://expo.dev/blog/introducing-expo-maps-a-modern-maps-api-for-expo-developers">expo-maps</a> as a modern rewrite built on <strong>SwiftUI</strong> and <strong>Jetpack Compose</strong>. Same idea, different tradeoffs. Here's the honest comparison:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>react-native-maps</th>
<th>expo-maps</th>
</tr>
</thead>
<tbody><tr>
<td>Providers</td>
<td>Google Maps + Apple Maps (both on iOS)</td>
<td>Apple Maps on iOS, Google Maps on Android — no cross-provider</td>
</tr>
<tr>
<td>Expo Go support</td>
<td>Yes</td>
<td>No (custom dev client / EAS build required)</td>
</tr>
<tr>
<td>Setup complexity</td>
<td>Config plugin + API keys + Podfile pins</td>
<td>Config plugin, cleaner defaults</td>
</tr>
<tr>
<td>Community size</td>
<td>Enormous, ~9 years of Stack Overflow answers</td>
<td>Small, growing</td>
</tr>
<tr>
<td>iOS minimum</td>
<td>iOS 13</td>
<td>iOS 17+ recommended</td>
</tr>
<tr>
<td>Native feel</td>
<td>Good</td>
<td>Excellent — real Apple Maps interaction model</td>
</tr>
<tr>
<td>Marker performance at scale</td>
<td>Needs manual clustering above ~200 markers</td>
<td>Similar limits</td>
</tr>
<tr>
<td>Best for</td>
<td>Production apps, complex custom markers, cross-provider consistency</td>
<td>Greenfield apps targeting new OS versions, minimum-friction setup</td>
</tr>
</tbody></table>
<p><strong>Our recommendation for most teams in 2026:</strong> start with <code>react-native-maps</code> unless you have a hard requirement for the newest platform look-and-feel. The reason is not technical superiority — it's that every third tutorial, every Stack Overflow answer, and every LLM-produced snippet still assumes react-native-maps. When you hit a weird bug at 11pm before a release, that ecosystem matters more than an extra 200ms of startup time.</p>
<h2>Setting up react-native-maps with Expo (without pain)</h2>
<p>The install command is trivial. The problems come after.</p>
<pre><code class="language-bash">npx expo install react-native-maps
</code></pre>
<p>For Google Maps on iOS or Android, you need API keys from the <a href="https://developers.google.com/maps/documentation/android-sdk/start">Google Maps Platform</a>, and you configure them through Expo's config plugin system in <code>app.json</code>:</p>
<pre><code class="language-json">{
  "expo": {
    "ios": {
      "config": {
        "googleMapsApiKey": "YOUR_IOS_KEY"
      },
      "infoPlist": {
        "NSLocationWhenInUseUsageDescription": "We show nearby places on a map.",
        "NSLocationAlwaysAndWhenInUseUsageDescription": "We sync your route in the background so you can see the full trip."
      }
    },
    "android": {
      "config": {
        "googleMaps": {
          "apiKey": "YOUR_ANDROID_KEY"
        }
      },
      "permissions": ["ACCESS_FINE_LOCATION", "ACCESS_COARSE_LOCATION"]
    }
  }
}
</code></pre>
<p>Three gotchas that will bite you in this exact order:</p>
<ol>
<li><p><strong>API keys must be restricted per-platform.</strong> A single "any bundle ID" key will work in development and get flagged and rotated by Google's abuse detection within a week of going live. Restrict by iOS bundle identifier and by Android SHA-1 fingerprint.</p>
</li>
<li><p><strong>iOS release builds need the key wired through Podfile modifications.</strong> If your map is blank in TestFlight but works in dev, this is almost always why.</p>
</li>
<li><p><strong>Every</strong> <code>NSLocation*</code> <strong>string must explain <em>why</em> in plain user language.</strong> Apple's review team rejects generic strings like "We need your location." Write it like you're the user.</p>
</li>
</ol>
<img src="https://images.unsplash.com/photo-1587620962725-abab7fe55159?w=1200" alt="Developer working on mobile code with a laptop, notepad, and coffee" style="display:block;margin:0 auto" />

<p><em>Getting react-native-maps to build cleanly in EAS is 70% configuration and 30% code. — Photo by Christina @ wocintechchat.com on Unsplash</em></p>
<h2>Rendering a map and placing markers the right way</h2>
<p>Here's a minimal <code>MapView</code> with a marker:</p>
<pre><code class="language-tsx">import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import { StyleSheet, View } from 'react-native';

export default function LocationScreen() {
  return (
    &lt;View style={{ flex: 1 }}&gt;
      &lt;MapView
        provider={PROVIDER_GOOGLE}
        style={StyleSheet.absoluteFill}
        initialRegion={{
          latitude: 37.78825,
          longitude: -122.4324,
          latitudeDelta: 0.0922,
          longitudeDelta: 0.0421,
        }}
      &gt;
        &lt;Marker
          coordinate={{ latitude: 37.78825, longitude: -122.4324 }}
          title="Union Square"
          description="Downtown San Francisco"
        /&gt;
      &lt;/MapView&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<p>A few things that trip up almost every first-time build:</p>
<ul>
<li><p><code>initialRegion</code> <strong>vs</strong> <code>region</code><strong>.</strong> Use <code>initialRegion</code> for uncontrolled maps that the user pans freely. Use <code>region</code> only if you have a very good reason to re-center the map on every render — it fights the user's gestures and can feel broken.</p>
</li>
<li><p><code>latitudeDelta</code> <strong>and</strong> <code>longitudeDelta</code> <strong>set zoom, not radius.</strong> Roughly, <code>0.01</code> is city-block zoom, <code>0.1</code> is neighborhood, <code>1.0</code> is metro area. There is no <code>zoom</code> prop.</p>
</li>
<li><p><code>PROVIDER_GOOGLE</code> <strong>on iOS forces Google tiles.</strong> Omit it to fall back to Apple Maps on iOS, which loads faster and is often what Apple's review team prefers for iOS-first UX.</p>
</li>
<li><p><code>StyleSheet.absoluteFill</code> <strong>on the wrapping</strong> <code>View</code><strong>.</strong> A <code>MapView</code> with <code>flex: 1</code> inside a parent that isn't sized will silently render at zero height. This is the #1 cause of "the map is blank" bug reports.</p>
</li>
</ul>
<h2>Requesting location permissions without getting rejected</h2>
<p>The map shows the world. To show <em>the user in the world</em>, you need location. This is where most tutorials wave their hands and where most App Store rejections come from. React Native Maps pairs with <a href="https://docs.expo.dev/versions/latest/sdk/location/">expo-location</a> for the permission and GPS layer:</p>
<pre><code class="language-tsx">import * as Location from 'expo-location';

async function getCurrentPosition() {
  const { status: foreground } = await Location.requestForegroundPermissionsAsync();
  if (foreground !== 'granted') {
    return null; // User denied. Show a soft explainer, not a hard error.
  }

  const location = await Location.getCurrentPositionAsync({
    accuracy: Location.Accuracy.Balanced,
  });

  return location.coords;
}
</code></pre>
<p>Three rules that will save you an App Store rejection:</p>
<ol>
<li><p><strong>Never request permission on app launch.</strong> Trigger it when the user clicks a "Show my location" button or opens a screen that visibly needs it. Apple reviewers will download your app, see an unexplained permission prompt on the splash screen, and reject.</p>
</li>
<li><p><strong>Foreground permission must be granted before you request background.</strong> iOS enforces this strictly. Your background permission call will silently fail otherwise.</p>
</li>
<li><p><code>Accuracy.Balanced</code> <strong>is the correct default.</strong> <code>Accuracy.BestForNavigation</code> drains battery and is intended only for driving/running apps that literally need meter-level precision. Reviewers do notice.</p>
</li>
</ol>
<p>For live tracking, <code>Location.watchPositionAsync</code> returns a subscription with a <code>remove()</code> method. You <strong>must</strong> call <code>remove()</code> on unmount, or you will leak GPS listeners, drain the battery, and eventually get 1-star reviews about "this app kills my phone."</p>
<pre><code class="language-tsx">import { useEffect } from 'react';

useEffect(() =&gt; {
  let subscription: Location.LocationSubscription | undefined;
  (async () =&gt; {
    subscription = await Location.watchPositionAsync(
      { accuracy: Location.Accuracy.Balanced, timeInterval: 3000, distanceInterval: 5 },
      (location) =&gt; setPosition(location.coords),
    );
  })();
  return () =&gt; subscription?.remove();
}, []);
</code></pre>
<h2>Drawing routes, polylines, and geofences</h2>
<p>Once you have coordinates flowing, you'll want to draw on the map. <code>Polyline</code> for routes, <code>Polygon</code> for regions, <code>Circle</code> for radii:</p>
<pre><code class="language-tsx">import { Polyline, Polygon, Circle } from 'react-native-maps';

&lt;Polyline
  coordinates={route}          // array of { latitude, longitude }
  strokeColor="#2563eb"
  strokeWidth={4}
/&gt;

&lt;Circle
  center={{ latitude, longitude }}
  radius={500}                 // meters
  fillColor="rgba(37, 99, 235, 0.15)"
  strokeColor="#2563eb"
/&gt;
</code></pre>
<p>For routing between two points (turn-by-turn directions), react-native-maps does not calculate the path itself. Pair it with the <strong>Google Directions API</strong> or <strong>Apple MapKit Directions</strong> and feed the resulting coordinate array into a <code>Polyline</code>. Libraries like <code>react-native-maps-directions</code> wrap this call for Google, but if you exceed the free tier you're paying per request — budget accordingly.</p>
<p>Geofencing (fire an event when the user enters/exits a region) is handled by <code>expo-location</code>'s <code>startGeofencingAsync</code>, which uses the OS-level geofence APIs and continues to work when the app is fully backgrounded. This is what powers "welcome to the store" push notifications in retail apps.</p>
<h2>Marker clustering: the invisible performance cliff</h2>
<p>Here is the bug you cannot afford to learn about in production: <code>react-native-maps</code> <strong>renders every</strong> <code>Marker</code> <strong>as a native view</strong>. A hundred markers is fine. Five hundred markers is a slideshow on mid-range Android. Two thousand markers will crash iOS with an out-of-memory error.</p>
<p>The fix is clustering — grouping nearby markers into a single "cluster" pin that expands as you zoom in. Options in order of pragmatism:</p>
<ul>
<li><p><a href="https://github.com/venits/react-native-map-clustering"><strong>react-native-map-clustering</strong></a> — Drop-in replacement for <code>MapView</code>, uses supercluster under the hood. Fastest way to get working clustering for typical dataset sizes (up to ~10k markers).</p>
</li>
<li><p><strong>Server-side clustering</strong> — For anything above ~50k points, cluster on the backend and only send the visible viewport. This is how Zillow and Airbnb do it.</p>
</li>
<li><p><strong>Custom bitmap markers</strong> — If clustering isn't an option, rendering markers as pre-rasterized bitmaps (<code>image</code> prop pointing to a PNG) is dramatically faster than rendering custom React components as markers.</p>
</li>
</ul>
<p>The last point deserves emphasis: <strong>custom</strong> <code>&lt;Marker&gt;</code> <strong>children re-render on every zoom, every pan, every state change.</strong> If you're rendering a <code>&lt;View&gt;</code> with a <code>&lt;Text&gt;</code> inside a <code>&lt;Marker&gt;</code>, wrap it in <code>React.memo</code> and hoist any callbacks. A slow map is almost always a re-rendering markers problem, not a tile problem.</p>
<img src="https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=1200" alt="Mobile app developer testing a location app on iPhone" style="display:block;margin:0 auto" />

<p><em>Test map-heavy screens on a mid-range Android device, not the latest iPhone. Your users are on the Android. — Photo by Daniel Korpai on Unsplash</em></p>
<h2>When AI can scaffold this for you</h2>
<p>Wiring up a <code>MapView</code>, adding markers, requesting permissions, drawing a route, and handling the "location denied" state is roughly 200-400 lines of code and half a day of "why is my map blank" debugging. It is also the kind of code that changes very little from app to app — a delivery app, a fitness app, and a real-estate app all need the same skeleton, just wired to different data.</p>
<p>This is where AI mobile app builders earn their place in the toolkit. When you describe an app to <a href="https://www.rapidnative.com/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">RapidNative</a> — "a food delivery app where couriers see nearby orders on a map" — the generator produces the entire React Native / Expo scaffold: <code>react-native-maps</code> installed and wired with the correct <code>provider</code>, <code>expo-location</code> permissions wired to the right <code>Info.plist</code> strings, <code>initialRegion</code> derived from the user's coordinates, and a <code>Marker</code> list bound to a Supabase-backed data model. What comes out isn't a mockup or throwaway prototype — it's real React Native code you can <a href="https://www.rapidnative.com/blogs?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">export</a>, open in your own editor, and modify by hand.</p>
<p>You can also start from other input modes — a <a href="https://www.rapidnative.com/whiteboard?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">whiteboard sketch of your map screen</a>, a <a href="https://www.rapidnative.com/prd-to-app?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">PRD describing the location features you need</a>, or <a href="https://www.rapidnative.com/image-to-app?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">a screenshot of a competitor's app</a> — and the resulting Expo project ships to real iOS and Android builds. The <a href="https://www.rapidnative.com/pricing?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">pricing page</a> has the credit breakdown for the free and paid tiers.</p>
<p>The point isn't that AI replaces your engineer. It's that the boilerplate — the config plugin, the permission strings, the initial region math, the memoized marker — is done by the time you sit down to work on the parts that actually differentiate your app.</p>
<h2>FAQ</h2>
<h3>How do I fix a blank map in React Native?</h3>
<p>The blank map is almost always one of four things: (1) the map's parent view has no explicit height, (2) the Google Maps API key isn't wired into the release build, (3) the key is restricted to a bundle ID that doesn't match your build, or (4) you forgot the config plugin in <code>app.json</code>. Check them in that order.</p>
<h3>Is react-native-maps free?</h3>
<p>The library itself is free and MIT licensed. However, Google Maps tiles are billed by the <a href="https://developers.google.com/maps/billing/gmp-billing">Google Maps Platform</a> after a monthly free quota (currently $200 of usage credit). Apple Maps on iOS is free with no quota. For map-heavy apps at scale, this billing detail matters more than the library choice.</p>
<h3>Can I use react-native-maps in Expo Go?</h3>
<p>Yes. This is one of the main reasons to prefer it over expo-maps if you rely on Expo Go for development. <code>expo-maps</code> requires a custom development client via EAS Build. Both can be used in production Expo apps.</p>
<h3>How do I track a user's location in the background?</h3>
<p>You need <code>NSLocationAlwaysAndWhenInUseUsageDescription</code> on iOS and the <code>ACCESS_BACKGROUND_LOCATION</code> permission on Android, plus an Info.plist <code>UIBackgroundModes</code> entry with <code>location</code>. Then use <code>expo-location</code>'s <code>startLocationUpdatesAsync</code> with a registered task. Foreground permission must be granted first. Apple review is strict here — you must justify why continuous location is needed for the app's core function.</p>
<h2>Ship it</h2>
<p>Location-based apps are among the most valuable and most technically demanding categories in mobile. Getting react-native-maps to render is the easy part; getting it to pass App Store review, stay under battery quota, and not stutter with 500 markers is the real work. The good news is that all of that work is well-charted — the patterns above cover 90% of what you'll hit.</p>
<p>The other good news: you no longer have to write the scaffold by hand. Describe the app in a sentence, and let the AI produce the React Native and Expo project — including maps, permissions, and a real backend. <a href="https://www.rapidnative.com/?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">Start building on RapidNative for free</a> — 20 credits, no credit card. Or read more on the <a href="https://www.rapidnative.com/blogs?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=react-native-maps-location-based-features-guide">RapidNative blog</a> for practical guides on integrating third-party APIs, handling state, and shipping to the stores.</p>
]]></content:encoded></item><item><title><![CDATA[Docker Alternatives in 2026: What to Use Instead (and When You Actually Need To)]]></title><description><![CDATA[Docker didn't invent containers, but for a decade it was containers. That started to change in 2021, when Docker Inc. announced that Docker Desktop would require a paid subscription for commercial use]]></description><link>https://smithchloe.hashnode.dev/docker-alternatives-in-2026-what-to-use-instead-and-when-you-actually-need-to</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/docker-alternatives-in-2026-what-to-use-instead-and-when-you-actually-need-to</guid><category><![CDATA[Docker]]></category><category><![CDATA[containers]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[podman]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Tue, 22 Sep 2026 06:52:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/b1e331c8-5506-4492-b3da-bf8399098652.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Docker didn't invent containers, but for a decade it <em>was</em> containers. That started to change in 2021, when Docker Inc. announced that Docker Desktop would require a paid subscription for commercial use at companies with more than 250 employees or $10M in revenue. Overnight, a lot of engineering teams learned the difference between "Docker the format" and "Docker the product."</p>
<p>Five years on, the ecosystem has sorted itself out nicely. The good news: you can keep your Dockerfiles, your <code>docker compose</code> habits, and your registry workflows and swap out almost everything underneath. This article walks through the real alternatives, grouped by what they actually replace.</p>
<h3>First, know what you're replacing</h3>
<p>"Docker" is really three things stacked on top of each other:</p>
<ol>
<li><p><strong>The runtime</strong> — containerd and runc, which actually create and run containers. This layer is open source and shared by almost everyone, including Kubernetes.</p>
</li>
<li><p><strong>The engine and CLI</strong> — the <code>docker</code> command and the daemon it talks to.</p>
</li>
<li><p><strong>Docker Desktop</strong> — the macOS/Windows app that bundles a Linux VM, a GUI, and the licensing that started all this.</p>
</li>
</ol>
<p>Most people who say "I want a Docker alternative" mean layer 3. Some mean layer 2. Almost nobody needs to replace layer 1. Figure out which you mean and the choice gets much easier.</p>
<h3>Engine and CLI alternatives</h3>
<h4>Podman</h4>
<p>Podman is the most complete drop-in replacement for the Docker engine. It's daemonless (no long-running root process) and rootless by default, which is why security-focused teams and most Linux distros have gravitated toward it. The CLI mirrors Docker closely enough that <code>alias docker=podman</code> works for the vast majority of day-to-day commands.</p>
<p>It pairs with two sibling tools from Red Hat: <strong>Buildah</strong> for building images without a daemon, and <strong>Skopeo</strong> for inspecting and copying images between registries. Podman also has a native concept of "pods" (groups of containers sharing a network namespace), and can generate Kubernetes YAML directly from running containers, which is handy if you're heading toward k8s anyway.</p>
<p><strong>Trade-offs:</strong> Compose support (<code>podman compose</code>) has improved a lot but occasionally diverges from Docker Compose on edge cases. Some third-party tools still assume a Docker socket.</p>
<h4>nerdctl (containerd directly)</h4>
<p>If you're comfortable going one layer lower, <code>nerdctl</code> gives you a Docker-compatible CLI straight on top of containerd. It's what many of the desktop tools below use internally. You get lazy-pulling, image encryption, and other containerd features that Docker itself doesn't expose. It's a great fit for Linux servers and CI runners where you want minimal moving parts.</p>
<h3>Docker Desktop alternatives (macOS and Windows)</h3>
<p>This is where most of the action is, because on a Mac or Windows machine you need a Linux VM somewhere, and how that VM is managed makes a huge difference to speed and battery life.</p>
<h4>OrbStack (macOS)</h4>
<p>OrbStack has effectively become the default on Mac. It's a commercial tool, but the performance gap is real: memory usage sits around a third of Docker Desktop's, and it starts in under two seconds. File sharing and port forwarding just work, it runs Linux VMs alongside containers, and it ships Kubernetes via K3s. It's free for personal use and $8 per user for commercial use, so it clears the licensing hurdle at a lower price point than Docker Desktop but isn't free for teams.</p>
<p><strong>Trade-offs:</strong> macOS only. Closed source.</p>
<h4>Colima (macOS and Linux)</h4>
<p>Colima is the free, no-frills choice. It's a thin wrapper around Lima that spins up a VM with either the Docker engine or containerd, and you keep using your normal <code>docker</code> CLI. One <code>brew install colima</code> and <code>colima start</code> and you're running. It's terminal-only and needs a little more manual tuning (CPU, memory, mount type) than OrbStack, but it's rock-solid and fully open source.</p>
<h4>Lima</h4>
<p>Lima is the layer under Colima: a tool for running Linux VMs on macOS with automatic file sharing and port forwarding. Use it directly if you want full control over the VM, or want multiple VMs with different runtimes. It's not a container tool by itself, but it's the foundation many of the others are built on.</p>
<h4>Finch (AWS)</h4>
<p>Finch is AWS's open-source bundle of Lima, containerd, nerdctl, and BuildKit in one package. Think of it as "Colima with opinions": a single install, a <code>finch</code> command that behaves like <code>docker</code>, and no daemon. It's a reasonable pick if you're an AWS shop and want something with a big vendor behind it.</p>
<h4>Rancher Desktop</h4>
<p>Rancher Desktop is the pick for Kubernetes-first developers. It gives you a GUI, a local k8s cluster you can pin to a specific version, and lets you flip between containerd and the Docker engine as the runtime. It's heavier than the CLI tools because Kubernetes is always along for the ride, but if your workflow already involves <code>kubectl</code>, that's a feature rather than a cost.</p>
<h4>Podman Desktop</h4>
<p>The GUI companion to Podman, now well into its 1.x releases with serious Red Hat backing. It manages containers, pods, images, and Kubernetes connections, and it's the natural choice if you've already committed to Podman on Linux and want the same model on your laptop. It's also the one option that's free at any company size <em>and</em> has a graphical interface.</p>
<h4>Apple's Container framework (macOS)</h4>
<p>Worth watching: Apple announced a native Container framework at WWDC 2025, and as of 2026 it ships as part of macOS itself. It runs each container in its own lightweight VM, which is a different (and more isolated) model from everything else here. It's still early and the tooling around it is thin, but it means the OS now has a container runtime built in, and the other tools may start building on it.</p>
<h3>Quick decision guide</h3>
<table>
<thead>
<tr>
<th>You want...</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td>Fastest, smoothest Mac experience and $8/user is fine</td>
<td>OrbStack</td>
</tr>
<tr>
<td>Free at any company size, with a GUI</td>
<td>Podman Desktop</td>
</tr>
<tr>
<td>Free, lightweight, terminal-only on Mac</td>
<td>Colima</td>
</tr>
<tr>
<td>Local Kubernetes that matches production</td>
<td>Rancher Desktop</td>
</tr>
<tr>
<td>Daemonless, rootless engine on Linux servers</td>
<td>Podman</td>
</tr>
<tr>
<td>Minimal CI runners</td>
<td>nerdctl + containerd</td>
</tr>
<tr>
<td>Full control over the VM</td>
<td>Lima</td>
</tr>
</tbody></table>
<h3>One piece of advice</h3>
<p>Whatever you pick, pick it as a team. Five developers on five different local setups is a support burden that quickly outweighs whatever you saved on licensing. Standardize the choice, put the setup in a script, and move on.</p>
<p><strong>And remember</strong>: none of this touches your Dockerfiles, your Compose files, or your images. The OCI standards are what made this whole ecosystem possible, and they're the reason switching is a lot less scary than it sounds.</p>
]]></content:encoded></item><item><title><![CDATA[The Cold-Start Problem for Paid React Native Templates]]></title><description><![CDATA[I'm going to walk you through, in embarrassing detail, how we got the first 100 paying customers for Applighter, a catalog of full-stack React Native + Expo templates. Every channel we tried, the numb]]></description><link>https://smithchloe.hashnode.dev/the-cold-start-problem-for-paid-react-native-templates</link><guid isPermaLink="true">https://smithchloe.hashnode.dev/the-cold-start-problem-for-paid-react-native-templates</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[supabase]]></category><category><![CDATA[indie hackers]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[Chloe]]></dc:creator><pubDate>Mon, 21 Sep 2026 06:36:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad1b7ad5bf401defc61a1e/79dec5cb-93ad-4e58-97f2-eeb5a6d426e5.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I'm going to walk you through, in embarrassing detail, how we got the first 100 paying customers for <a href="https://www.applighter.com">Applighter</a>, a catalog of full-stack React Native + Expo templates. Every channel we tried, the numbers behind each, and the ones we quietly buried.</p>
<p>If you're a solo dev or a two-person team about to sell paid code for the first time, this is the write-up I wish I'd had before launch.</p>
<h2>TL;DR</h2>
<ul>
<li><p>The first 20 customers took 3 months. The next 80 took 2.</p>
</li>
<li><p>Long-tail SEO and brand search drove the overwhelming majority of sales.</p>
</li>
<li><p>Twitter/X, Product Hunt, and paid ads were all dead ends for us.</p>
</li>
<li><p>The real problem in month 1 isn't "no customers." It's no evidence.</p>
</li>
</ul>
<h2>The situation, in numbers</h2>
<ul>
<li><p>7 templates live at launch (all Expo SDK 54 + TypeScript + Supabase)</p>
</li>
<li><p>$79 per single-app license</p>
</li>
<li><p>7-day no-questions-asked refund</p>
</li>
<li><p>Zero email list, zero Twitter following, zero backlinks</p>
</li>
<li><p>First sale: day 12</p>
</li>
<li><p>Customer #100: about 5 months in</p>
</li>
<li><p>Total revenue by #100: modest four figures</p>
</li>
</ul>
<p>The path was not linear. Once compounding starts, everything changes, but you have to survive the flat months first.</p>
<h2>The cold-start problem, defined</h2>
<p>The cold-start problem for paid templates is not "no customers." That's a symptom. The disease is <strong>no evidence</strong>: no reviews, no case studies, no Google rank, no name recognition, no referrals. Every channel that scales in month 12 requires evidence that doesn't exist in month 1.</p>
<p>So the entire game of the first 100 sales is one thing: build evidence faster than your runway burns. Copy, pricing tiers, and the color of the buy button are rounding errors next to that.</p>
<h2>Channel 1: Long-tail SEO (biggest lever, slowest payoff)</h2>
<p>We don't try to rank for "react native template." We rank for queries like:</p>
<ul>
<li><p><code>react native supabase template with rls and storage</code></p>
</li>
<li><p><code>streaming ai responses to react native</code></p>
</li>
<li><p><code>expo eas build cost calculator</code></p>
</li>
<li><p><code>how to sign urls in supabase storage for private buckets</code></p>
</li>
</ul>
<p>These have search volume in the low hundreds per month. That's the point. The developer typing that exact query has already decided to build the thing. They're not tire-kicking. They convert.</p>
<p>Here's the funnel breakdown from month 6:</p>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Sessions</th>
<th>CVR</th>
<th>Attributed sales</th>
</tr>
</thead>
<tbody><tr>
<td>Long-tail SEO (blog)</td>
<td>4,200</td>
<td>0.9%</td>
<td>38</td>
</tr>
<tr>
<td>Direct + brand search</td>
<td>1,100</td>
<td>3.4%</td>
<td>37</td>
</tr>
<tr>
<td>Reddit referral</td>
<td>380</td>
<td>1.3%</td>
<td>5</td>
</tr>
<tr>
<td>Hacker News (one-off)</td>
<td>2,100</td>
<td>0.14%</td>
<td>3</td>
</tr>
<tr>
<td>Twitter/X</td>
<td>90</td>
<td>1.1%</td>
<td>1</td>
</tr>
<tr>
<td>Paid Google Ads</td>
<td>40</td>
<td>0%</td>
<td>0</td>
</tr>
</tbody></table>
<p>Long-tail wins on volume. Brand search wins on conversion. Together they account for 75 of the 84 attributed sales. Everything else is noise on the tail.</p>
<p><strong>Why it works:</strong> every technical essay is a permanent asset. Written once, it ranks for years and keeps producing sales. Measured as labor hours against recurring sales, this is the highest-paid work I've done as a founder.</p>
<p><strong>The catch:</strong> the payoff window is 3 to 6 weeks minimum after publishing. Google has to index you, judge you, rank you, and get a real human to click. That loop runs no matter how good the essay is, and most founders quit at week three.</p>
<h2>Channel 2: Reddit, done carefully</h2>
<p>Not r/reactnative. The moderator culture there is heavily anti-promo, and we got flagged twice.</p>
<p>What worked:</p>
<ul>
<li><p><strong>r/expo:</strong> answering technical questions with real depth, mentioning the template at the end only when directly relevant.</p>
</li>
<li><p><strong>r/indiehackers:</strong> long-form "here's how I built this" posts with numbers.</p>
</li>
<li><p><strong>r/SideProject:</strong> same, slightly more casual.</p>
</li>
<li><p><strong>r/Supabase:</strong> bug threads where our stack overlapped.</p>
</li>
</ul>
<p>The rule: you can promote in a comment after you've given three comments of pure help. Reddit's spam radar, human and algorithmic, is exceptional at spotting drive-bys.</p>
<h2>Channel 3: Open source as credibility</h2>
<p>We open-sourced two things. First, a tiny Supabase migration generator:</p>
<pre><code class="language-ts">export function generateMigration(schema: SchemaDefinition) {
  const columns = schema.columns.map(colToSql).join(',\n  ');
  return `create table ${schema.table} (\n  ${columns}\n);`;
}
</code></pre>
<p>Second, our Stripe-to-Supabase license-grant webhook pattern:</p>
<pre><code class="language-ts">export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    req.headers.get('stripe-signature')!,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    await supabase.from('license_grants').insert({
      user_id: session.metadata!.user_id,
      product_slug: session.metadata!.product_slug,
      granted_at: new Date().toISOString(),
    });
  }
  return new Response(null, { status: 200 });
}
</code></pre>
<p>Neither piece went viral. But both:</p>
<ul>
<li><p>Gave us a real GitHub org for buyers to click through</p>
</li>
<li><p>Turned into two Indie Hackers threads and one Hacker News mention</p>
</li>
<li><p>Continue to send small trickles of trust-first traffic</p>
</li>
</ul>
<p>The HN mention alone drove 3 same-day sales and got us into Google's index for terms we didn't otherwise rank for.</p>
<p>Don't open-source the whole template. Open-source one useful piece. Buyers pay for the composition of the parts, not the parts.</p>
<h2>Channel 4: Multi-SKU cross-linking</h2>
<p>Once we had three or more templates, we cross-linked them on every product page. When AI Voice Notes started ranking, it pulled up Chat with PDF and AI Calorie Tracker with it. Same buyer intent, same domain authority.</p>
<p>This is the compounding advantage of a small catalog over a single hero product.</p>
<h2>Channel 5: Refund policy on the pricing card</h2>
<p>7 days, no questions asked. Refund rate to date: 3.1%. Moving the badge from the footer to the pricing card gave us a significant CVR lift, though it's hard to isolate cleanly from other changes we made at the same time.</p>
<p>Buyers of a $79 developer template are risk-buyers. A visible, generous refund is the cheapest way to reduce perceived risk. Our accountant hates it. Our conversion rate loves it.</p>
<h2>What we buried</h2>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Twitter/X threads</td>
<td>Broad reach, zero buyer intent</td>
</tr>
<tr>
<td>Product Hunt launch</td>
<td>One-day spike, no SEO compounding</td>
</tr>
<tr>
<td>Paid Google Ads</td>
<td>$120 CAC on a $79 product</td>
</tr>
<tr>
<td>LinkedIn posts</td>
<td>Not repeatable at scale</td>
</tr>
<tr>
<td>YouTube tutorials</td>
<td>Editing labor per view was untenable</td>
</tr>
</tbody></table>
<p>The pattern: broad-intent paid channels have bad economics. Specific-intent organic is the whole game.</p>
<h2>Against ThemeForest</h2>
<p>The category we sell into has been trained by ThemeForest to expect UI-only templates with mocked data. Every template we ship goes deeper:</p>
<ul>
<li><p>Full Expo SDK 54 source</p>
</li>
<li><p>Supabase Row Level Security policies</p>
</li>
<li><p>Working AI integrations with real API contracts</p>
</li>
<li><p>Stripe-powered license grants provisioned to the repo the moment payment clears</p>
</li>
<li><p>NativeWind, TypeScript, and an EAS Build config that actually builds</p>
</li>
</ul>
<p>The gap between "UI template" and "working product" is what buyers pay for. It's also the gap you have to demonstrate, because writing "full-stack" on a landing page means nothing on day one.</p>
<h2>If we started over</h2>
<ol>
<li><p>Ship one template. One.</p>
</li>
<li><p>Write 4 technical essays about problems your template solves.</p>
</li>
<li><p>Set up analytics before launch: Search Console, Plausible, a UTM convention.</p>
</li>
<li><p>Open-source one small, useful utility.</p>
</li>
<li><p>Show up in 2 or 3 communities regularly.</p>
</li>
<li><p>Put the refund policy on the pricing card.</p>
</li>
<li><p>Wait. Six months.</p>
</li>
</ol>
<p>The founders who cleared the cold-start went through the same flat months you're in now. They just kept writing.</p>
<p>If you're selling code and stuck in those flat months, tell me in the comments which channel you're betting on. Happy to share what we saw.</p>
<p><em>Originally published on the</em> <a href="https://www.applighter.com/blog/cold-start-problem-paid-react-native-templates-first-100-customers"><em>Applighter blog</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>