[{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/alloy/","section":"Tags","summary":"","title":"Alloy","type":"tags"},{"content":"A quick example of how to start using author taxonomies in your articles.\n","date":"19 July 2026","externalUrl":null,"permalink":"/authors/","section":"Authors Taxonomy Listing Example","summary":"\u003cp\u003eA quick example of how to start using author taxonomies in your articles.\u003c/p\u003e","title":"Authors Taxonomy Listing Example","type":"authors"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/automation/","section":"Tags","summary":"","title":"Automation","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/ci-cd/","section":"Tags","summary":"","title":"Ci-Cd","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/cloudflare-tunnel/","section":"Tags","summary":"","title":"Cloudflare-Tunnel","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/coder/","section":"Tags","summary":"","title":"Coder","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/containers/","section":"Tags","summary":"","title":"Containers","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/dev-workspaces/","section":"Tags","summary":"","title":"Dev-Workspaces","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/developer-environment/","section":"Tags","summary":"","title":"Developer-Environment","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/categories/devops/","section":"Categories","summary":"","title":"Devops","type":"categories"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/drone/","section":"Tags","summary":"","title":"Drone","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/grafana/","section":"Tags","summary":"","title":"Grafana","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/categories/guide/","section":"Categories","summary":"","title":"Guide","type":"categories"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/homelab/","section":"Tags","summary":"","title":"Homelab","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"Articles for founders and engineering leaders evaluating mobile development — hiring guides, architecture comparisons, and the practitioner\u0026rsquo;s view of AI-assisted delivery and mobile CI/CD.\nStart here — pick a guide:\nHire a Flutter Developer in 2026 → — the complete hiring guide Flutter vs React Native vs Expo → — which to pick for an MVP AI-Assisted Mobile Development → — how it actually works Mobile CI/CD \u0026amp; Release Automation → — the complete stack ","date":"19 July 2026","externalUrl":null,"permalink":"/insights/","section":"Insights","summary":"\u003cp\u003eArticles for founders and engineering leaders evaluating mobile development — hiring guides, architecture comparisons, and the practitioner\u0026rsquo;s view of AI-assisted delivery and mobile CI/CD.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eStart here\u003c/strong\u003e — pick a guide:\u003c/p\u003e","title":"Insights","type":"insights"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/loki/","section":"Tags","summary":"","title":"Loki","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/authors/mjashem/","section":"Authors Taxonomy Listing Example","summary":"","title":"Mjashem","type":"authors"},{"content":"Senior full-stack mobile engineer with 7+ years building production apps end-to-end — Flutter, React Native, Expo across Android, iOS, Web and TV, plus the backend, CI/CD, and infrastructure that ship and sustain them. Architecture-first, AI-native delivery with AI code review in the loop.\nThis site is my freelance home and personal lab notebook: case studies, services, and writing on mobile architecture, hiring, and DevOps experiments.\n","date":"19 July 2026","externalUrl":null,"permalink":"/","section":"Mohammad Jashem — Senior Full-Stack Mobile Engineer","summary":"\u003cp\u003eSenior full-stack mobile engineer with \u003cstrong\u003e7+ years\u003c/strong\u003e building production apps end-to-end — Flutter, React Native, Expo across Android, iOS, Web and TV, plus the backend, CI/CD, and infrastructure that ship and sustain them. Architecture-first, AI-native delivery with AI code review in the loop.\u003c/p\u003e","title":"Mohammad Jashem — Senior Full-Stack Mobile Engineer","type":"page"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/observability/","section":"Tags","summary":"","title":"Observability","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/prometheus/","section":"Tags","summary":"","title":"Prometheus","type":"tags"},{"content":"Open a browser tab, get a fully-loaded dev environment. Close it, it\u0026rsquo;s gone. Open it tomorrow, it\u0026rsquo;s identical — same tools, same shell history, same checkout on the same branch.\nThat\u0026rsquo;s the experience this homelab actually delivers. Coder on a single Docker daemon, every workspace a sibling container, every workspace reachable on its own subdomain.\nWhat Coder is # Coder treats development environments as code. A template defines the workspace — base image, tools, env vars, the startup script. Coder provisions a workspace from a template on demand, gives it a URL, and tears it down when you stop it. State is reproducible because it\u0026rsquo;s declared, not hand-installed.\nThe payoff shows up in the parts of dev work nobody enjoys: a new contributor productive in the time it takes to click create workspace, a broken laptop that costs zero setup time to replace, a \u0026ldquo;works on my machine\u0026rdquo; bug that reproduces from the same template. The browser-based IDE is part of it; the real value is that the environment is code, version-controlled and rebuildable.\nThe Docker provider — workspaces as sibling containers # Coder is provider-agnostic; the provider decides where workspaces actually land. In this homelab that\u0026rsquo;s the Docker provider, and the mechanic is worth understanding plainly:\nflowchart TB USER[Browser] --\u003e|\"HTTPSworkspace-name.example.com\"| TR subgraph HOST[\"Docker host (single daemon)\"] CODER[\"coder containermounts /var/run/docker.sockmounts /opt/coder-data:/home/coder\"] PG[(\"coder-db · postgresusers · workspaces · builds\")] CODER ---|\"CODER_PG_CONNECTION_URLcontainer-to-container\"| PG CODER --\u003e|\"on 'create workspace'docker run from template image\"| WS1[\"workspace container(sibling, not nested)\"] CODER --\u003e|\"docker run\"| WS2[\"workspace container(sibling, not nested)\"] TR[\"Traefikroutes workspace subdomains\"] end TR --\u003e|routes| CODER CODER --\u003e|\"proxies to the workspace\"| WS1 CODER --\u003e WS2 Because the Coder container mounts the host\u0026rsquo;s Docker socket, \u0026ldquo;create a workspace\u0026rdquo; is effectively docker run with the right image — the new workspace is just another container on the same daemon, sitting next to Coder itself. No nested Docker-in-Docker, no virtual machine, no separate runtime. The workspace sees the host\u0026rsquo;s images, the host\u0026rsquo;s networks, the host\u0026rsquo;s volumes, because it is a host container.\nThis is the simplest possible arrangement, and it\u0026rsquo;s the one I run. It\u0026rsquo;s also the one with the most permissive trust boundary, which I\u0026rsquo;ll come back to.\nThe compose, annotated # The real docker-compose.yml, with only the Coder-relevant services (a couple of unrelated co-located apps live in the same file and aren\u0026rsquo;t part of this setup):\nservices: coder: image: ghcr.io/coder/coder container_name: coder ports: - \u0026#34;80:80\u0026#34; - \u0026#34;2112:2112\u0026#34; group_add: - \u0026#34;${DOCKER_GID}\u0026#34; environment: CODER_ACCESS_URL: ${CODER_ACCESS_URL} CODER_WILDCARD_ACCESS_URL: ${CODER_WILDCARD_ACCESS_URL} CODER_HTTP_ADDRESS: \u0026#34;0.0.0.0:80\u0026#34; CODER_PG_CONNECTION_URL: postgres://coder:${CODER_DB_PASSWORD}@coder-db:5432/coder?sslmode=disable CODER_PROMETHEUS_ENABLE: \u0026#34;true\u0026#34; CODER_PROMETHEUS_ADDRESS: \u0026#34;0.0.0.0:2112\u0026#34; CODER_PROMETHEUS_COLLECT_AGENT_STATS: \u0026#34;true\u0026#34; volumes: - /var/run/docker.sock:/var/run/docker.sock - /opt/coder-data:/home/coder depends_on: coder-db: condition: service_healthy restart: unless-stopped coder-db: image: postgres container_name: coder-db shm_size: 256mb environment: POSTGRES_USER: coder POSTGRES_PASSWORD: ${CODER_DB_PASSWORD} POSTGRES_DB: coder command: \u0026gt; postgres -c shared_buffers=256MB -c effective_cache_size=2GB -c work_mem=16MB -c maintenance_work_mem=128MB -c wal_buffers=16MB -c max_parallel_workers_per_gather=1 -c random_page_cost=1.1 volumes: - coder_pgdata:/var/lib/postgresql/data healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U coder\u0026#34;] interval: 5s timeout: 5s retries: 5 restart: unless-stopped volumes: coder_pgdata: A few things worth pointing at.\nTwo ports. :80 is the Coder UI and API. :2112 is Prometheus metrics, enabled with CODER_PROMETHEUS_ENABLE=true and CODER_PROMETHEUS_ADDRESS=0.0.0.0:2112. The central Prometheus scrapes :2112, and I get workspace-build rates and durations with no extra wiring. The Docker socket mount. /var/run/docker.sock:/var/run/docker.sock is the provider. This single line is what lets Coder spawn sibling containers on the host daemon — without it, Coder can\u0026rsquo;t provision anything. The group_add: [\u0026quot;${DOCKER_GID}\u0026quot;] line gives the in-container user the host group membership needed to actually talk to that socket. Postgres holds everything. Users, workspaces, builds, template versions — all in coder-db. CODER_PG_CONNECTION_URL points Coder at it; sslmode=disable because the connection is container-to-container on the same compose network. The healthcheck on coder-db and the depends_on: { condition: service_healthy } on coder mean Coder will not start until Postgres is ready to accept connections. The tuning lines are deliberate. shared_buffers=256MB, effective_cache_size=2GB, work_mem=16MB, maintenance_work_mem=128MB, random_page_cost=1.1 — these are not defaults. For a small database on a host with a few GB of RAM and SSD storage, the defaults over-provision parallelism and under-provision cache. effective_cache_size=2GB tells the planner the OS page cache is real and large, which changes query plans for the better; random_page_cost=1.1 tells it random reads are roughly as cheap as sequential ones, which is true on SSD. Invisible until you have a slow query and wonder why. Every workspace gets its own subdomain # Every workspace container is reachable at its own subdomain — no per-workspace DNS entry, no per-workspace routing rule, nothing to remember. CODER_WILDCARD_ACCESS_URL on the Coder side plus wildcard DNS at the edge is all it takes; Coder handles the mapping.\nOne gotcha, plainly stated: workspace names must not collide with existing subdomains. A workspace named the same as a real service shadows that service — and if you name it after the Coder UI itself, you shadow the very UI you\u0026rsquo;d need to rename or delete it with. That\u0026rsquo;s a self-inflicted footgun, easy to avoid once you know about it; name workspaces feature-X or scratch-Y, not after your real services.\nWorkspace connectivity — Tailscale inside Coder # There\u0026rsquo;s a second network path inside Coder that\u0026rsquo;s easy to miss, because it doesn\u0026rsquo;t show up in the compose. Coder uses Tailscale\u0026rsquo;s networking internally — the DERP relay protocol — so that workspace terminals, SSH, and the in-browser IDE can reach the dashboard even when the workspace container lives on a different Docker network than the browser-facing edge. Concretely: every workspace has to be able to reach CODER_ACCESS_URL. If a workspace can\u0026rsquo;t resolve or route to that URL, the dashboard\u0026rsquo;s terminal and SSH buttons silently fail to connect — the workspace is up, but unreachable through the UI.\nThis is separate from the Tailscale mesh I use to administer the host machines. Coder ships its own Tailscale, scoped to workspace connectivity; the admin mesh is the one I SSH through. Don\u0026rsquo;t conflate them — they\u0026rsquo;re different overlays for different jobs. The admin-mesh context is in the zero-trust homelab write-up.\nThe practical fix, when workspace terminals won\u0026rsquo;t connect: make sure the workspace container can reach CODER_ACCESS_URL. In a reverse-proxy-fronted setup that usually means attaching workspace containers to the same Docker network the proxy lives on, so the workspace can reach it by name. The wildcard subdomain also has to be in DNS, pointing at the edge, or port-forwarding via the dashboard breaks for the same underlying reason.\nWhere Coder fits in the homelab # Coder is one of several services co-located on the work VM. It\u0026rsquo;s reachable the same way everything else is — through Cloudflare Tunnel, Traefik, and the wildcard DNS — and monitored the same way: scraped on :2112 by the central Prometheus, with logs shipped by the per-VM Alloy agent like every other container.\nIn practice, that means a Coder workspace build shows up in the same Grafana dashboards as every other service, and a workspace container crash appears in the same Loki queries. The edge trust boundary — outbound-only tunnel, no inbound ports — applies to Coder exactly as it does to the rest of the fleet. The full edge and observability story is in the self-hosted zero-trust homelab write-up; this article is the Coder-specific slice.\nThe trust boundary — read this before you copy it # I\u0026rsquo;ll say this plainly because the rest of the article makes the setup sound free, and it isn\u0026rsquo;t.\nMounting /var/run/docker.sock into the Coder container means the Coder process can drive the host Docker daemon as root — and anyone who can create or modify a workspace template can effectively direct that access. A template that mounts the socket into a workspace hands that workspace the ability to spin up containers, inspect others, attach to their volumes, and read environment variables — including secrets — of every other container on the same daemon. The Docker socket is root-equivalent on the host.\nFor a single-user homelab, or a small team that mutually trusts each other, this is a reasonable trade-off — and it\u0026rsquo;s the one I run. The Docker-provider setup is simple, it has one moving part, and it works. For multi-tenant use, or where workspace users don\u0026rsquo;t fully trust each other, this is not the right shape: you\u0026rsquo;d want each workspace against a separate Docker daemon, or under a sandboxing runtime like gVisor. This setup does not do that. That\u0026rsquo;s a deliberate choice — the goal is simplicity for trusted users, not isolation between untrusted ones.\nIf your trust model is different, the trade-off is different, and this article is not your blueprint.\nDay-2 — what to watch, what breaks # The metrics worth alerting on are the ones about builds, because builds are where the user-visible pain shows up:\ncoderd_workspace_builds_total coderd_workspace_build_duration_seconds The first counts workspace builds; the second is a histogram of build duration. A spike in failed builds, or a climb in duration, is the early signal — usually a template that pulled a broken image, or a Postgres that\u0026rsquo;s gotten slow.\nWhere things break, and where to look:\nWorkspace build fails. First place to look is the Coder container\u0026rsquo;s logs — docker logs coder on the host — for the build error. If the template image can\u0026rsquo;t be pulled, that\u0026rsquo;s the cause. If builds are slow or stall, check coder-db health with pg_isready -U coder from inside the coder-db container; a Postgres that\u0026rsquo;s struggling shows up as build timeouts before it shows up anywhere else. Workspace unreachable at its subdomain. First check the name doesn\u0026rsquo;t collide with an existing subdomain (coder, or any other app already routed on this homelab). If it does, the request is hitting the more specific route and never reaches Coder. Subdomain returns 404. Verify the workspace route is present in the edge proxy and that CODER_WILDCARD_ACCESS_URL is set in Coder\u0026rsquo;s environment. Coder needs to know its own wildcard to construct the right URLs and to validate inbound workspace requests. Workspace is up but terminal or SSH won\u0026rsquo;t connect. That\u0026rsquo;s the Tailscale-inside-Coder path above. Confirm the workspace container can reach CODER_ACCESS_URL — usually a Docker-network attachment issue in the template — and that wildcard DNS resolves. Frequently asked # Is mounting docker.sock safe? # For a trusted single user or a small team that mutually trusts each other, yes. For untrusted users, no — the socket is root-equivalent on the host. See the trust-boundary section above for the full trade-off.\nHow do workspaces get their own subdomains? # Wildcard DNS plus CODER_WILDCARD_ACCESS_URL. Coder maps each workspace to its own subdomain. No per-workspace DNS entry, no per-workspace routing rule.\nWhy does the workspace terminal sometimes fail to connect? # Almost always a workspace-to-CODER_ACCESS_URL reachability problem. Coder\u0026rsquo;s in-browser terminal and SSH ride its internal Tailscale (DERP) networking, which needs the workspace container to be able to route to the access URL. Attach the workspace to the Traefik network in the Coder template, verify wildcard DNS resolves, and the terminal connects.\nWant this behind your team? # The same Docker-socket-and-wildcard-subdomain pattern scales from a one-person homelab to a small, mutually-trusting team without changing shape. If you want a setup like this behind your own product — provisioned, monitored, integrated with your edge — look at what I offer or start a conversation.\n","date":"19 July 2026","externalUrl":null,"permalink":"/insights/self-hosted-coder-dev-workspaces/","section":"Insights","summary":"\u003cp\u003eOpen a browser tab, get a fully-loaded dev environment. Close it, it\u0026rsquo;s gone. Open it tomorrow, it\u0026rsquo;s identical — same tools, same shell history, same checkout on the same branch.\u003c/p\u003e","title":"Self-Hosted Coder on Docker: Dev Workspaces with Subdomains","type":"insights"},{"content":"Push to main. Hugo rebuilds in CI, the built site ships to the server over SCP, the container force-recreates, and the new content is live in well under a minute, in practice. No port ever opens on the server. That last part is the whole point — and once you\u0026rsquo;ve run a static site this way, the idea of exposing a box to the internet to serve HTML feels archaic.\nThis is the real pipeline behind this site. The configs below are the ones in the repo, not a tidied-up rewrite.\nThe pipeline at a glance # flowchart TB PUSH[\"git push · branch mainself-hosted git host on the LAN\"] --\u003e BUILD subgraph DRONE[\"Drone CI · builds on push\"] BUILD[\"build stephugomods/hugo:exts-0.164.0hugo --environment production --minify\"] --\u003e SCP[\"copy stepappleboy/drone-scpships public/ + docker-compose.yml\"] --\u003e SSH[\"deploy stepappleboy/drone-sshdocker compose up -d --force-recreate web\"] end SSH --\u003e|SCP + SSH · outbound to ~/hugo-site/| APPS USER[\"Visitor browser\"] --\u003e|HTTPS| CF[\"Cloudflare edgeTLS terminate · CDN · DDoS\"] CF -.-\u003e|\"Cloudflare Tunnel · outbound only\"| CT subgraph ROUTER[\"MikroTik L009UiGS router · armv7\"] CT[\"cloudflared container--protocol http2\"] end CT --\u003e|\"HTTP · port 80 only\"| GW[\"gateway VM · Traefik v3\"] GW --\u003e|Host header route| APPS[\"apps VM · Hugo container :8888zero inbound ports to the internet\"] Three Drone steps build and ship the site; the serving path then runs through three separate boxes — the router, the gateway VM, and the apps VM. The interesting bits are what\u0026rsquo;s not there: no port forwarding on the router, no exposed origin IP, no TLS certificate on the box.\nWhere the code lives # The source for this site lives on my git host — self-hosted, on the same LAN as the deploy target. The push that triggers the pipeline is a plain git push to that host.\nA note on CI history, plainly: this pipeline used to run on Woodpecker. I moved it to Drone for the same webhook-reliability reasons I moved the mobile pipeline — Woodpecker would silently miss pushes against my git host, and Drone hasn\u0026rsquo;t. The full reasoning is in the Woodpecker-to-Drone migration write-up.\nThe Drone pipeline, annotated # --- kind: pipeline type: docker name: default trigger: branch: - main event: - push steps: - name: build image: hugomods/hugo:exts-0.164.0 commands: - hugo --environment production --minify - name: copy image: appleboy/drone-scp depends_on: [build] settings: host: from_secret: DEPLOY_HOST # credentials (user + auth) are injected from Drone\u0026#39;s secret store, not the repo source: - public/ - docker-compose.yml target: ~/hugo-site/ rm: true - name: deploy image: appleboy/drone-ssh depends_on: [copy] settings: host: from_secret: DEPLOY_HOST # credentials injected from Drone\u0026#39;s secret store script: - cd ~/hugo-site - docker compose up -d --force-recreate web Step by step:\nbuild. Pull hugomods/hugo:exts-0.164.0, run hugo --environment production --minify. The output is the rendered static site in public/, assets fingerprinted and minified. Nothing else happens in this step — it\u0026rsquo;s a pure function of the source. copy. appleboy/drone-scp ships public/ and docker-compose.yml to ~/hugo-site/ on the deploy host. The rm: true flag is the important one: it wipes the target directory before writing, so stale assets from a removed page don\u0026rsquo;t linger. Forget it and you\u0026rsquo;ll occasionally ship a deleted image to production. deploy. appleboy/drone-ssh runs cd ~/hugo-site \u0026amp;\u0026amp; docker compose up -d --force-recreate web. --force-recreate is what picks up the new content — the image is the same, but the bind-mounted public/ has changed, and the recreate ensures the container re-reads the directory rather than holding anything in cache. The from_secret: references resolve in Drone\u0026rsquo;s secret store. The repo contains no credentials and no host IP — those live in Drone\u0026rsquo;s UI under the repo\u0026rsquo;s secret settings.\nWhat runs on the server # # docker-compose.yml services: web: image: hugomods/hugo:nginx restart: always labels: prometheus.scrape: \u0026#34;false\u0026#34; ports: - \u0026#34;8888:80\u0026#34; volumes: - ./public:/site hugomods/hugo:nginx is the same project\u0026rsquo;s Nginx-served variant — it serves whatever is in /site as static files. The compose binds ./public (what SCP just dropped) to /site in the container. Port 8888:80 exposes Nginx on 8888 on the host, and that port is reachable only from the LAN — not from the internet. The prometheus.scrape: \u0026quot;false\u0026quot; label opts the container out of metrics scraping, which keeps Prometheus clean of a static-site target that has nothing useful to report.\nThis compose file ships with the deploy. SCP carries both public/ and docker-compose.yml, so a change to the runtime config is just another commit — push, and Drone handles it.\nSecurity, plainly # Zero inbound ports on the LAN. Origin IP not published in DNS — the public DNS for the site points at the tunnel, not at any IP I own. TLS terminated at Cloudflare\u0026rsquo;s edge; the hop from the edge to my origin rides the tunnel\u0026rsquo;s own encrypted connection back to Cloudflare.\nNo \u0026ldquo;military-grade\u0026rdquo; anything. No invented SSL-Labs score. The architecture is the security claim: there\u0026rsquo;s nothing to attack from outside except Cloudflare\u0026rsquo;s edge, and the only way in is the outbound tunnel my own machine initiated. If you want the full edge story — UFW rules, the gateway\u0026rsquo;s trustedIPs setup, the rate-limit and security-header tiers — that\u0026rsquo;s in the self-hosted zero-trust homelab write-up.\nCost, plainly # Hardware I already own. A domain per year. Cloudflare\u0026rsquo;s free tier — the tunnel and DNS cost me nothing at my traffic. The software stack (Hugo, Drone, hugomods/hugo:nginx, Traefik, cloudflared) is open source and costs zero.\nI\u0026rsquo;m not going to fabricate a five-year savings table. If you don\u0026rsquo;t already have hardware, that\u0026rsquo;s the one real upfront line item; the rest is your time.\nSame engine, different target # The Drone instance that builds this site is the same one that ships mobile releases to the app stores. Different target — static files to a single VM vs. signed binaries to App Store Connect and Play Console — but the same self-hosted CI, the same webhook plumbing, the same secret store. The patterns reuse cleanly: a build step, a delivery step, a notification step. The mobile CI/CD write-up walks through the app-store side of the same engine.\nWhen it breaks, where to look # Real commands for the real flow:\n# Drone build logs → Drone UI # On the server: docker compose -f ~/hugo-site/docker-compose.yml logs web # Traefik router health (gateway VM): curl -s http://\u0026lt;gateway-ip\u0026gt;:\u0026lt;traefik-api-port\u0026gt;/api/http/routers | jq \u0026#39;.[].name\u0026#39; The pipeline fails in three places inside it, plus the edge layer. The build fails when Hugo doesn\u0026rsquo;t like the content — bad front matter, a broken shortcode, a missing image — and the Drone UI shows the exact line. The copy fails when SSH credentials are wrong or the deploy host is unreachable; rare, and the SCP plugin logs clearly. The deploy fails when Docker can\u0026rsquo;t pull the image or the compose file is malformed — docker compose ... logs web on the server tells you which.\nThe edge-layer failure mode is the one to watch for: Traefik returns 404 for the site. That\u0026rsquo;s almost always a stale or missing router after a config change. The curl against the gateway\u0026rsquo;s API lists every router Traefik currently knows about; if the site\u0026rsquo;s hostname isn\u0026rsquo;t in the list, the route file is wrong or wasn\u0026rsquo;t reloaded.\nFrequently asked # Drone vs Woodpecker for a static site? # Both work. I moved to Drone for webhook reliability against my git host — Woodpecker would silently miss pushes, and a static-site pipeline that doesn\u0026rsquo;t run on push is a pipeline that doesn\u0026rsquo;t exist. Drone\u0026rsquo;s webhook handling has been stable. The full reasoning, including the failure modes I was hitting, is in the migration write-up.\nWhy SCP and not a container registry? # Because a static site is just files. The output of a Hugo build is a directory of HTML, CSS, JS, and images — there\u0026rsquo;s no binary to version, no image layer to cache, no runtime to pin. SCP\u0026rsquo;ing public/ to the server and recreating the container is the simplest thing that could possibly work, and for a site this size it\u0026rsquo;s also the fastest. A registry is the right call when you\u0026rsquo;re shipping a real image — an app server, an API, anything with a process that has to keep running. For a directory of static files, it\u0026rsquo;s overhead with no payoff.\nDo I need Traefik if I have Cloudflare Tunnel? # You don\u0026rsquo;t need it — cloudflared can route directly to a single backend. The reason I keep Traefik in the path is that the tunnel serves more than this one site. Traefik gives me hostname routing for all of them in one config language, rate limits per route, security headers per route, and a single place to add the next service. If this site were the only thing on the tunnel, I could collapse the layers. It isn\u0026rsquo;t, so I don\u0026rsquo;t.\nHow long does a deploy take? # Under a minute, push to live, as observed — not formally benchmarked. The build is fast (Hugo is fast), the SCP is a few hundred files over a LAN link, and docker compose up -d --force-recreate completes in a second or two. If a deploy ever takes more than two minutes, something is wrong and the Drone UI will show where.\nWant this behind your site? # This is the same pipeline and the same edge infrastructure I use for client work — self-hosted CI, an outbound-only tunnel, a reverse proxy I control. If you want a setup like this behind your own product instead of a managed host you can\u0026rsquo;t see inside, look at what I offer or start a conversation. For the broader picture — same engine shipping app-store releases — read the mobile CI/CD write-up.\n","date":"19 July 2026","externalUrl":null,"permalink":"/insights/hugo-cicd-self-hosted-drone/","section":"Insights","summary":"\u003cp\u003ePush to \u003ccode\u003emain\u003c/code\u003e. Hugo rebuilds in CI, the built site ships to the server over SCP, the container force-recreates, and the new content is live in well under a minute, in practice. No port ever opens on the server. That last part is the whole point — and once you\u0026rsquo;ve run a static site this way, the idea of exposing a box to the internet to serve HTML feels archaic.\u003c/p\u003e","title":"Self-Hosted Hugo CI/CD: Drone + Cloudflare Tunnel","type":"insights"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/self-hosting/","section":"Tags","summary":"","title":"Self-Hosting","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/static-site/","section":"Tags","summary":"","title":"Static-Site","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/traefik/","section":"Tags","summary":"","title":"Traefik","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/zero-trust/","section":"Tags","summary":"","title":"Zero-Trust","type":"tags"},{"content":"Most homelab guides tell you to forward ports 80 and 443 on your router and put your home IP in DNS. This one opens zero inbound ports. The whole fleet — photos, passwords, CI, dev environments, this site — sits behind a single outbound tunnel that terminates on the router itself. No port to forward, no public IP to leak.\nWhat\u0026rsquo;s running # Two Proxmox VE nodes, four VMs, and a MikroTik router at the edge.\nNode A — AMD Ryzen 7 5700x, 48 GB RAM, 1 TB storage. The agentic stack — the one box that earns the bigger CPU and RAM.\nWork VM — Coder (browser-based dev environments), Multica (a self-hosted managed-agents platform — the layer above coding agents like Claude Code and Codex that turns them into assignable teammates with issues, progress streams, and reusable skills), Memory/Hindsight (persistent memory for those agents). Embedded LLM — llama.cpp running local inference on the same metal, feeding the agent stack. Node B — Intel i3-1215U mini PC, 32 GB RAM, 1.5 TB storage.\nPersonal VM — Immich (photos), Vaultwarden (passwords). Apps VM — Drone CI, this Hugo site. Gateway VM — Traefik v3 plus the observability stack: Prometheus, Loki, Grafana, Alertmanager. Edge — a MikroTik L009UiGS router running cloudflared as a RouterOS container. That replaces what used to be a dedicated tunnel VM; the entire inbound path now ends on the router.\nThe topology # flowchart TB USER[Visitor browser] --\u003e|HTTPS| CF[Cloudflare edgeTLS terminate · DDoS · CDN] subgraph ROUTER[\"MikroTik L009UiGS router\"] CT[cloudflared containerarmv7 · --protocol http2] end CF -.-\u003e|outbound-only tunnel| CT subgraph NODEB[\"Proxmox Node B · i3-1215U · 32 GB\"] NBH((\" \")):::ghost GW[gateway VM · Traefik v3 :80Prometheus · Loki · Grafana · Alertmanager] PER[personal VM · Immich · Vaultwarden] APPS[apps VM · Drone CI · Hugo site] end subgraph NODEA[\"Proxmox Node A · Ryzen 7 5700x · 48 GB\"] NAH((\" \")):::ghost BIZ[work VM · Coder · Multica · Memory] LLM[embedded LLM · llama.cpp] end CT --\u003e|HTTP · firewall-locked to :80| GW GW --\u003e BIZ GW --\u003e PER GW --\u003e APPS NBH ~~~ GW NBH ~~~ PER NBH ~~~ APPS NAH ~~~ BIZ NAH ~~~ LLM TS[Tailscale admin mesh] -.-\u003e|SSH · management| NODEA TS -.-\u003e NODEB TS -.-\u003e ROUTER classDef ghost fill:none,stroke:none,color:transparent Each hop earns its place. Cloudflare hides the origin IP and absorbs L7 noise. The tunnel is outbound-only, so nothing on the LAN listens to the internet. The router-local cloudflared keeps encrypted egress as close to the wire as possible. Traefik on the gateway VM is the single place routing and middleware policy live. The dotted Tailscale overlay is the management plane — separate from the request path; more on it below.\nWhy the tunnel lives on the router # This used to sit on its own VM — a dedicated, minimal box that did nothing but run cloudflared. That worked, but it also meant a whole VM (disk, RAM, an OS to patch, an Alloy agent to keep alive) for a process that does almost nothing. The MikroTik L009UiGS runs RouterOS containers, so the tunnel moved there.\nTwo real problems had to be solved first. The official cloudflare/cloudflared image dropped armv7 builds, and the L009UiGS is armv7 — so I build a custom image from the official binary and push it to Docker Hub. Then, cloudflared defaults to QUIC on outbound, which needs UDP/7844 open through the router firewall — fiddly on RouterOS. Forcing --protocol http2 collapses it to a single outbound HTTPS TCP connection, so there\u0026rsquo;s no outbound firewall rule to maintain.\nThe container sits on the router\u0026rsquo;s own container network, firewalled to reach only the gateway VM\u0026rsquo;s port 80 — nothing else on the LAN. If it were ever compromised, the blast radius is one TCP port on one VM. The honest trade-off: the router is now a critical piece of infrastructure, not a box you forget about. If it reboots, the tunnel drops with it. I\u0026rsquo;ve accepted that to remove a whole VM.\nTwo ISPs, one tunnel # The router has two WAN ports and two ISPs behind them. That\u0026rsquo;s the resilience layer under everything above. The Cloudflare Tunnel is an outbound connection, so it rides whichever WAN is alive — kill one ISP and the tunnel, and every service behind it, stays up on the other.\nSplitting traffic across both is standard MikroTik dual-WAN. New connections are marked by a per-connection-classifier (PCC) and balanced across the two links; each ISP owns its own routing table with a recursive default route that pings a resolver to confirm the path actually carries traffic. If one ISP\u0026rsquo;s resolver stops answering, its routes withdraw and that table fails over to the other — symmetric both ways. A netwatch job pinging each link is the second opinion, catching a path that\u0026rsquo;s \u0026ldquo;up\u0026rdquo; at the interface but dead on the internet.\nI\u0026rsquo;m not publishing the classifier ratios, the resolver targets, or the failover distances — they\u0026rsquo;re tuned to these two links. The shape is the point: load-balance on the happy path, automatic failover on the unhappy one, and a tunnel that doesn\u0026rsquo;t know or care which ISP it\u0026rsquo;s riding.\nWhy Traefik and not Nginx Proxy Manager # Nginx Proxy Manager gives you a UI for one-off proxies; Traefik gives you a system. With Traefik, a Docker container with the right labels is routable the moment it starts — no UI to click, no file to regenerate, no reload to forget. It watches the Docker socket through the docker provider and a directory of YAML through the file provider; both reload dynamically. One language — routers, middlewares, services — describes everything. If you only ever run one service, Nginx Proxy Manager is fine. At five services, Traefik wins.\nWhy Traefik is HTTP-only # Traefik listens on :80 and only :80. Cloudflare terminates TLS at the edge; Traefik never holds a certificate and never has to renew one. That\u0026rsquo;s one less moving part and one less way for a service to break at 3 a.m. because a cert expired.\n# traefik/compose.yml (excerpt — sanitized) command: - --entrypoints.web.address=:80 - --entrypoints.web.forwardedHeaders.trustedIPs=\u0026lt;router-tunnel-ip\u0026gt;/32,\u0026lt;cloudflare-edge-ranges\u0026gt; - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.docker.network=proxy - --providers.file.directory=/dynamic - --providers.file.watch=true - --accesslog=true - --accesslog.format=json - --metrics.prometheus=true The trade-off is real. By terminating TLS at Cloudflare you trust Cloudflare\u0026rsquo;s edge as your TLS boundary; the hop from edge to origin runs over the tunnel, itself an encrypted connection back to Cloudflare. Fine in practice — but a trust decision you\u0026rsquo;re making. If you don\u0026rsquo;t want that trust, run your own certs on Traefik and drop the tunnel.\nRequest flow # sequenceDiagram participant B as Browser participant CF as Cloudflare edge participant T as cloudflared (router container) participant TR as Traefik (gateway VM) participant BE as Backend VM B-\u003e\u003eCF: HTTPS request CF-\u003e\u003eCF: TLS terminate · DDoS filter CF-\u003e\u003eT: forward over tunnel (CF-Connecting-IP set) T-\u003e\u003eTR: HTTP :80 · Host header preserved TR-\u003e\u003eTR: match router · apply middleware tier TR-\u003e\u003eBE: forward to container port BE--\u003e\u003eTR: response TR--\u003e\u003eT: response T--\u003e\u003eCF: response CF--\u003e\u003eB: HTTPS response The browser speaks HTTPS to Cloudflare\u0026rsquo;s edge. Cloudflare terminates TLS, applies DDoS and CDN logic, and forwards the request — with CF-Connecting-IP set — over the outbound tunnel. The router\u0026rsquo;s cloudflared hands it to Traefik on port 80, HTTP-only. Traefik reads the Host header, matches the router, applies the middleware tier, and forwards to the backend container on whichever VM it lives on.\nObservability — and why Alloy # Prometheus stores metrics, Loki stores logs, Grafana draws both, Alertmanager pages when something breaks. The interesting piece is the agent.\nFor a long time the standard answer was Promtail for logs and node_exporter for host metrics — two agents per machine, two configs, two failure modes. Promtail reached end-of-life in March 2026. Alloy is the Grafana-agent successor and does both jobs in one process: host metrics through prometheus.exporter.unix, Docker containers through discovery.docker, logs to Loki through its loki.source.* components. One agent, one config, one thing to debug. Every VM runs the same Alloy config; the only per-host variable is the hostname label.\nflowchart LR subgraph EACH[\"each VM in the fleet\"] AL[Alloy agentprometheus.exporter.unixdiscovery.dockerloki.source.*] end subgraph GW[\"gateway VM (also runs Alloy)\"] PROM[Prometheus] LOKI[Loki] GRAF[Grafana] AM[Alertmanager] end AL --\u003e|metrics · remote_write| PROM AL --\u003e|Docker stdout · journal| LOKI AL --\u003e|Traefik access log · gateway only| LOKI PROM --\u003e GRAF LOKI --\u003e GRAF GRAF --\u003e AM Once logs from every VM land in one place with consistent labels, debugging changes shape. Three real LogQL queries I run weekly:\n{container=\u0026#34;\u0026lt;app-name\u0026gt;\u0026#34;} |~ \u0026#34;panic|fatal\u0026#34; {job=\u0026#34;traefik-access\u0026#34;} |~ \u0026#34;5..\u0026#34; {job=\u0026#34;docker\u0026#34;, host=\u0026#34;\u0026lt;vm\u0026gt;\u0026#34;} |= \u0026#34;error\u0026#34; The first catches a crash in any named container. The second surfaces every 5xx in Traefik\u0026rsquo;s access log — with the real client IP, because we recover it below. The third pulls every error line from a given VM\u0026rsquo;s Docker containers. Try that with docker logs over SSH on five machines.\nRate-limit and security-header tiers # Every route chains two middlewares: a security-headers tier and a rate-limit tier. I define a handful of named tiers and assign each route one, so the policy is declarative — a route says \u0026ldquo;auth\u0026rdquo; or \u0026ldquo;media\u0026rdquo; and inherits a coherent posture instead of per-route knob-twiddling. They range from a strict tier for anything that handles credentials, through balanced defaults, to a deliberately open one for ephemeral dev environments that shouldn\u0026rsquo;t be constrained.\nThe honest story behind why tiers exist at all: a service was inexplicably slow once, and for a week I assumed the backend was at fault. It wasn\u0026rsquo;t. The default tier was rate-limiting it, and a bursty client blew through the cap the moment the page loaded. Moving that one route to a higher tier fixed it in a single edit. Lesson, plainly: when a service is slow and the code has no culprit, check whether your reverse proxy is throttling it before you touch the app. I wasted a week learning that.\nI\u0026rsquo;m not publishing the exact values or which route is on which tier. The tiers exist; that\u0026rsquo;s the architectural point.\nRecovering the real visitor IP # Behind a Cloudflare Tunnel, every request reaching Traefik has the router\u0026rsquo;s tunnel interface as its source. Cloudflare fixes this by adding CF-Connecting-IP, but Traefik has to be told to trust it. That\u0026rsquo;s --entrypoints.web.forwardedHeaders.trustedIPs: it whitelists the router\u0026rsquo;s tunnel interface and Cloudflare\u0026rsquo;s edge ranges, so Traefik reads CF-Connecting-IP as the client IP in access logs.\nGotcha: if you forget the tunnel interface in trustedIPs, every access log shows the router and per-client rate limits become useless. One-line mistake, and it\u0026rsquo;s silent. Verify after every Traefik restart — the IP in the access log should be the remote client, not the router.\nThe management plane — Tailscale # Everything above is the public path: browser → Cloudflare → tunnel → Traefik → service. None of it gives me a shell on a box. For SSH and management I use a separate Tailscale mesh across the two nodes and the VMs — a parallel admin plane with nothing to do with the request path.\nThis is what keeps SSH off the public internet without me ever opening port 22. Nodes join the tailnet and reach each other directly over WireGuard. I SSH into any VM through its tailnet address, never through Cloudflare, never through port-forwarding. The two networks are deliberately separate: a compromise of the public path doesn\u0026rsquo;t expose SSH, and a compromise of the tailnet doesn\u0026rsquo;t expose the public services.\nTailscale also runs as a container on the router itself, advertising the LAN subnet into the tailnet — so the whole LAN is reachable from one router-side node, and the router\u0026rsquo;s own management interfaces are never exposed to the WAN. I manage the router over the tailnet too; SSH, Telnet, FTP, and the web UI are all WAN-disabled, reachable only from the LAN and the tailnet.\nAdding a service # Two paths. Add traefik.* labels to the service\u0026rsquo;s own docker-compose.yml (preferred when the service is Docker-based and the compose lives in its own repo), or add a 4-line route entry in traefik/dynamic/routes.yml (preferred when the backend is external to Docker or you want routing centralized). Deploy the service, allow the gateway VM\u0026rsquo;s IP through UFW on the host, and the per-VM Alloy agent picks up the new container automatically through Docker discovery — no Prometheus restart. Tunnel and DNS need no changes; the wildcard already covers the new subdomain.\nLocking the LAN down # Every app VM sits behind UFW, and only the gateway VM is allowed to reach app ports. The internet-facing surface is exactly the router, which only runs cloudflared outbound. Sanitized example:\nsudo ufw allow from \u0026lt;gateway-ip\u0026gt; to any port \u0026lt;service-port\u0026gt; That opens the listed ports to the gateway and nothing else. The gateway itself opens 80 only to the router\u0026rsquo;s tunnel interface. The monitoring ports are LAN-bound; for remote access I go through Tailscale. Nothing exposes Grafana to the public internet, ever.\nWhat it actually costs # No invented savings table. Hardware I already own (the two nodes plus the router), a domain per year, the Cloudflare free tier (tunnel and DNS cost nothing at my traffic), and electricity. The software stack — Traefik, Prometheus, Loki, Grafana, Alloy, cloudflared, Immich, Vaultwarden, Coder — is open source and costs zero. If you don\u0026rsquo;t already have hardware, that\u0026rsquo;s the one real upfront line item. Everything after that is your time.\nFAQ # Is Cloudflare Tunnel safe for a homelab? # It\u0026rsquo;s outbound-only — your machine initiates a long-lived connection to Cloudflare\u0026rsquo;s edge; no inbound port has to be opened. Your home IP never appears in public DNS for the tunneled hostnames. The trust shift is real: instead of trusting your own edge to terminate TLS and resist direct attacks, you trust Cloudflare\u0026rsquo;s. For most homelabbers that\u0026rsquo;s a strict improvement.\nWhy no TLS on Traefik? # Cloudflare terminates TLS at its edge, and the hop from edge to origin runs over the tunnel — itself an encrypted connection back to Cloudflare. Traefik sits LAN-side and only sees unencrypted HTTP from a source it already trusts. Adding TLS on Traefik would mean managing cert renewals for no security gain in my threat model. If I dropped the tunnel, I\u0026rsquo;d put TLS on Traefik the same day.\nAlloy vs Promtail? # Promtail reached end-of-life in March 2026. Alloy is the Grafana-agent successor: it does everything Promtail did for logs and replaces node_exporter for host metrics — one agent per VM instead of two. The migration is mostly mechanical; loki.source.* components in Alloy map cleanly onto Promtail\u0026rsquo;s scrape configs.\nCan I run this on a single box? # Yes. Collapse Traefik, monitoring, and apps onto one machine and you lose blast-radius isolation, but you also lose three VMs of overhead. For a personal setup with a handful of services, one box is a legitimate starting point. What you give up is the ability to break one thing without touching the others — and the option to put the tunnel on the router, which only makes sense once the router is separate from the workloads.\nWant this behind your product? # This is the same infrastructure that ships and monitors the apps I build for clients. The pipeline that builds and deploys your app, the reverse proxy that routes its traffic, and the observability stack that catches the regression before your users do — I bring all three. If you want that behind your product rather than a black box you can\u0026rsquo;t see into, look at what I offer and let\u0026rsquo;s talk.\n","date":"19 July 2026","externalUrl":null,"permalink":"/insights/self-hosted-zero-trust-homelab/","section":"Insights","summary":"\u003cp\u003eMost homelab guides tell you to forward ports 80 and 443 on your router and put your home IP in DNS. This one opens zero inbound ports. The whole fleet — photos, passwords, CI, dev environments, this site — sits behind a single outbound tunnel that terminates on the router itself. No port to forward, no public IP to leak.\u003c/p\u003e","title":"Zero-Trust Homelab: Cloudflare Tunnel, Traefik, No Ports","type":"insights"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/android-tv/","section":"Tags","summary":"","title":"Android-Tv","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/auto-route/","section":"Tags","summary":"","title":"Auto-Route","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/clean-architecture/","section":"Tags","summary":"","title":"Clean-Architecture","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/categories/deep-dive/","section":"Categories","summary":"","title":"Deep-Dive","type":"categories"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/expo/","section":"Tags","summary":"","title":"Expo","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/expo-router/","section":"Tags","summary":"","title":"Expo-Router","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/flutter/","section":"Tags","summary":"","title":"Flutter","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/json-ld/","section":"Tags","summary":"","title":"Json-Ld","type":"tags"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. Kahf Kids is an Islamic kids\u0026rsquo; education app I built in Flutter and ran in production for about eighteen months across Android, iOS, and Web: curated YouTube videos, Quran, books, games, courses — all behind a parent-controlled PIN. This is the architecture write-up. Not the marketing page — the engineering one.\nThe interesting part of Kahf Kids isn\u0026rsquo;t any single screen. It\u0026rsquo;s that the hot path is a YouTube WebView wrapped in a Flutter overlay that enforces the parental-control boundary, running on five-year-old Mali-G52 and PowerVR GE8320 GPUs with two to three gigabytes of RAM. When the hot path is a platform view under an input-capturing overlay on cheap hardware, every architectural decision lives or dies on whether you can reason about the boundary in isolation. If playback state leaks into the overlay, or the overlay reaches into the WebView\u0026rsquo;s gesture tree, the surface area you can\u0026rsquo;t test grows — and that\u0026rsquo;s where the ANRs eventually live.\nThe architecture is what made the boundary reason-able at all. Playback state sits in application/. The overlay sits in presentation/. The route guard sits in core/presentation/routes/. Each of those can be held in my head one at a time. That separability is what let me change the overlay three times chasing the ANR without rewriting playback, and it\u0026rsquo;s what made the eventual migration scope clean to draw. The full failure-mode investigation is in the platform-view ANR deep dive; this piece is about the shape that made the boundary tractable in the first place.\nThe product-level case study — what shipped, why it ran, why I migrated — is in the Kahf Kids Flutter app deep dive. This is the architecture deep dive.\nThe shape: feature-sliced, layers per feature # Kahf Kids is feature-sliced. lib/ carries thirty-plus feature folders — parental_control, kids_webview, player, quran, games, website_blocking, books, blogs, course, quizzes, playlists, kahf_id, onboarding, calculator, search, history, settings, and a long tail. Each slice owns its surface end to end: UI, state, and any helpers it needs. A change inside parental_control can\u0026rsquo;t reach sideways into player; the blast radius of a bug stays inside the slice that introduced it.\nThe per-feature layer shape isn\u0026rsquo;t uniform, and I want to be honest about that because the pillar earned a review correction on the same point. The shape a slice carries is the shape it needs:\nFull three-layer (application/ + presentation/ + shared/): parental_control, player. These are the slices with real state and real UI — a notifier for the schedule, a page that consumes it, a local helper for a permission mixin. Heavier slices that add infrastructure/: games, kahf_id, onboarding. They do platform work — HTML5 game packaging, identity flows, OS permissions — that the lighter slices don\u0026rsquo;t. Lightweight content slices that collapse to presentation/ only: kids_webview, quran, books, blogs. They render content sourced from elsewhere; no local state worth naming. Two-layer (presentation/ + shared/): website_blocking, quizzes. UI plus a small helper module. Application + presentation, no shared/: playlists. State and UI, nothing local worth extracting. No feature carries a separate domain/ layer. This is not textbook four-layer Clean Architecture — it\u0026rsquo;s a pragmatic shape: the layers a feature actually needs, and no more. The Crewlix HR platform runs the full four-layer shape because HRM domain logic earns it; Kahf Kids doesn\u0026rsquo;t. The contrast is spelled out in the Crewlix clean-architecture deep dive.\nThe reason this matters in practice is the boundary. The WebView overlay sits in the player slice. Playback state lives in player/application/. The route guard lives in core/presentation/routes/. The parental-control policy lives in a separate plugin and is read through parental_control/application/. Four pieces, four files, each reason-able in isolation. That\u0026rsquo;s the whole point of the slicing.\nLayers: what each one owns # application/ — state and orchestration # application/ is where Riverpod providers live. Providers are declared with annotations and code-generated by riverpod_generator; notifiers own the state for their slice and the UI watches a provider and rebuilds when state changes. No setState sprawl, no global singletons.\nA usage-limit notifier from the parental-control slice, redacted for length and with the dev logging stripped:\n@riverpod class UsageLimitNotifier extends _$UsageLimitNotifier { @override FutureOr\u0026lt;List\u0026lt;Schedule\u0026gt;\u0026gt; build() async { return ref .read(parentalControlManagerProvider) .getSchedulesByType(ScheduleType.usageLimit); } Future\u0026lt;void\u0026gt; createSchedule({ required String scheduleId, required String name, required int limitSeconds, }) async { await ref .read(parentalControlManagerProvider) .createUsageLimitSchedule( scheduleId: scheduleId, scheduleName: name, usageLimitSeconds: limitSeconds, ); ref.invalidateSelf(); } } The provider is the entire public surface of the slice. The UI calls createSchedule; the notifier talks to the platform plugin; the state invalidates and rebuilds. Three responsibilities, one place. That\u0026rsquo;s the layer\u0026rsquo;s job.\npresentation/ — UI and view state # presentation/ holds the pages, widgets, and — when the slice needs it — a presentation/models/ subfolder for view-specific state. Consumer widgets read providers from application/; they don\u0026rsquo;t reach into platform packages directly. The parental-control page reads usageLimitNotifierProvider; the player view reads autoplayNotifierProvider and the YouTube controller provider. The layer knows about Riverpod and Flutter; it doesn\u0026rsquo;t know about the OS accessibility service except through the notifier in application/.\nshared/ — helpers local to the slice # shared/ is the per-slice helper folder: a permission widget mixin in parental_control/shared/, a providers barrel file, a small extension. This is the part that gets misread most often. shared/ is not a global utility layer. It\u0026rsquo;s local to the slice. The rule: if player needs a helper, the helper lives in player/shared/, not in core/shared/. The slice stays self-contained; core/ only gets things genuinely used by three or more slices.\ninfrastructure/ — platform work, only when earned # The heavier slices — games, kahf_id, onboarding — add infrastructure/. This is where platform-specific work lives: HTML5 game packaging, identity token exchange, permission flows that talk to native APIs. Lightweight slices don\u0026rsquo;t carry this layer because they don\u0026rsquo;t need it. parental_control, despite being the most behaviorally complex slice, doesn\u0026rsquo;t have infrastructure/ — its platform work lives in a separate Flutter plugin, and the slice calls the plugin\u0026rsquo;s API through the notifier.\nNavigation: Auto Route, code-generated and type-safe # Routes are declared once and code-generated by auto_route, so navigation is compile-time safe. Pushing a route with the wrong argument type is a build error, not a runtime crash on a kid\u0026rsquo;s phone. The router itself is one file — core/presentation/routes/app_router.dart — declaring every page and deep-link path in the app.\nA redacted slice of the router, showing the parental-control route and the player route — the two that carry the most architectural weight in this app:\n@AutoRouterConfig() class AppRouter extends RootStackRouter { @override RouteType get defaultRouteType =\u0026gt; RouteType.material(enablePredictiveBackGesture: true); @override List\u0026lt;AutoRoute\u0026gt; get routes =\u0026gt; [ // ...mobile-only and web-only routes gated on kIsWeb... AutoRoute( page: DashboardRoute.page, path: \u0026#39;/\u0026#39;, children: [ AutoRoute( page: ParentalControlRoute.page, path: \u0026#39;app-guard\u0026#39;, ), AutoRoute( page: PlayerRoute.page, path: \u0026#39;video/:id\u0026#39;, maintainState: false, ), // ...the rest of the deep-link tree... ], ), ]; } The router is a single declarative tree. The parental-control page hangs off the dashboard shell at /app-guard; the player hangs off the same shell at /video/:id. maintainState: false on the player route is deliberate — every navigation to a video rebuilds the player from scratch, so the overlay state can never inherit stale playback state from the previous video. That single flag is part of the boundary contract.\nModels: Freezed, end to end # Every data model crossing the DTO → model → notifier → UI vertical is a Freezed class — immutable, with a generated copyWith, JSON serialization via json_serializable, and union support when a model has discrete states. A redacted slice of the video model:\n@freezed abstract class Video with _$Video { const factory Video({ @JsonKey(name: \u0026#39;video_id\u0026#39;) required String videoId, @JsonKey(name: \u0026#39;video_url\u0026#39;) required String videoUrl, required String category, required String title, @JsonKey(name: \u0026#39;channel_title\u0026#39;) required String channelTitle, required String thumbnail, required String duration, @JsonKey(name: \u0026#39;published_at\u0026#39;) required DateTime publishedAt, @JsonKey(name: \u0026#39;related_videos\u0026#39;) required List\u0026lt;String\u0026gt; relatedVideos, @JsonKey(name: \u0026#39;is_custom_video\u0026#39;, defaultValue: false) required bool isCustomVideo, }) = _Video; factory Video.fromJson(Map\u0026lt;String, dynamic\u0026gt; json) =\u0026gt; _$VideoFromJson(json); } The @JsonKey(name: ...) annotations are how the server\u0026rsquo;s snake_case keys map to Dart fields without polluting the model. The defaultValue flags are how the model survives a backend that occasionally drops a field — a real concern on a kids\u0026rsquo; app where the content feed is partially curated and partially dynamic.\nThe case study: the WebView overlay as architecture\u0026rsquo;s hardest test # The parental-control video flow is where every architectural choice pays off or breaks. The shape is precisely the worst-case one for a Flutter app: a YouTube WebView plays the video, a Flutter overlay rendered above it captures the surface and blocks every exit path, and Riverpod holds the playback state somewhere underneath both.\nThe boundary in code, from the player overlay (redacted):\nreturn PointerInterceptor( intercepting: kIsWeb \u0026amp;\u0026amp; !isRunningOnTv, child: GestureDetector( onTap: isRelatedVideosVisible ? null : toggleOverlay, onVerticalDragUpdate: isLocked ? null : handleDragUpdate, onVerticalDragEnd: isLocked ? null : handleDragEnd, child: MouseRegion( onHover: kIsWeb ? (_) =\u0026gt; showOverlay() : null, onExit: kIsWeb ? (_) =\u0026gt; hideOverlay() : null, child: Stack(children: [ /* ...overlay layers... */ ]), ), ), ); PointerInterceptor from pointer_interceptor is the load-bearing widget here. Without it, the WebView — a native platform view — would swallow the gesture tree and the Flutter overlay\u0026rsquo;s GestureDetector would never fire. With it, the overlay intercepts taps and drags on the Flutter side and the WebView\u0026rsquo;s controls stay hidden behind it. flutter_inappwebview provides the surface; a forked youtube_player_iframe exposes the player hooks the overlay needs. The YouTube controller params explicitly disable the in-player controls (showControls: false, pointerEvents: PointerEvents.none) — the WebView is a video surface, not a UI.\nThe architecture is what makes this reason-able. The overlay sits in player/presentation/. Playback state sits in player/application/. The route guard sits in core/presentation/routes/app_router.dart. The parental-control policy sits in a Flutter plugin and is read through parental_control/application/. Four pieces, four files. None of them knows how the others are implemented. Each of them is independently testable.\nHere\u0026rsquo;s the part that matters for the rest of the cluster. That overlay sits in the platform-view hot path by construction. A Flutter widget rendered above a native view is the worst-case shape for the GPU context-synchronization failure mode that eventually showed up as foreground ANRs on Mali and PowerVR handsets. The architecture made the boundary testable and the migration scope-able — it did not, and could not, fix the underlying platform-view contract. The full failure-mode investigation — the device matrix, the stack traces, the mitigations, and why they were bounded — is in the platform-view ANR deep dive. When you read that piece, the thing to hold in mind is: the overlay code above is what was sitting on the hot path when the ANR fired. The boundary let me change it; the boundary couldn\u0026rsquo;t save it.\nTakeaways: how to slice a Flutter app # Start with three layers, drop the ones you don\u0026rsquo;t use. The textbook four-layer Clean Architecture adds a domain/ layer most apps don\u0026rsquo;t need. Start with application/ + presentation/ + shared/, add infrastructure/ only when there\u0026rsquo;s real platform work, and don\u0026rsquo;t add domain/ unless the domain logic is heavy enough to earn its own pure-Dart module. HRM, finance, anything with non-trivial business rules — yes. A kids\u0026rsquo; content app — no. Slice on the feature, not on the layer. A state/ folder at the root that holds every provider in the app is the wrong shape. The provider for parental-control schedules belongs in parental_control/application/, next to the UI that consumes it. The blast radius of a change stays inside the slice. Keep shared/ per-slice until you have three callers. Resist the upward pull to core/ the moment two slices need a helper. Two is duplication; three is a pattern; extract on three. core/ is a junk drawer the day you stop enforcing that rule. A WebView overlay is testable in isolation when the layer boundary holds. If playback state lives in application/ and the overlay reads it through a provider, you can write a widget test that injects a fake provider and exercises the overlay\u0026rsquo;s gesture logic without spinning up the WebView. If the overlay reaches directly into platform code, that testability is gone. Extract a package when a boundary earns it — not only for a second app. On Crewlix I extracted crewlix_core, crewlix_auth, crewlix_people, and crewlix_feed because a second production app consumed them. Kahf Kids keeps a different cut: in-repo Dart packages — couchkeys (a D-pad TV keyboard), equran and quran (Quran text and audio), youtube_streams, flutter_drawing_board, device_type, kahf_kids_analytics — plus forked youtube_player_iframe and flutter_web_auth, and a parental_control_manager plugin. There was no second app; the pressure that earned extraction here was a clean test boundary and a swap surface inside the one app. Same principle, different trigger: extract on pressure — a second consumer, a test boundary, a swap point — not on prediction. Layering is overkill when the slice is one screen with no state. The quran reader is a presentation/ folder and that\u0026rsquo;s it. Forcing it into a three-layer template would have added two empty folders and zero value. The shape serves the feature; the feature doesn\u0026rsquo;t serve the shape. If you\u0026rsquo;re scoping a Flutter architecture review # The decisions in this piece — feature-sliced Clean Architecture, the per-feature layer shape, the boundary that lets a WebView overlay be tested in isolation — are the decisions I make on every Flutter build I take on. If you\u0026rsquo;re scoping a Flutter architecture review, weighing whether your slice shape is holding under new features, or staring at a platform-view failure mode you can\u0026rsquo;t patch from pubspec.yaml, get in touch. I do this work.\nFrequently Asked Questions # How do you slice a Flutter app into features? Each feature — parental_control, player, quran, games, and the rest — owns its own surface end to end under its own folder, with its own application/, presentation/, and shared/ layers (and infrastructure/ only when there\u0026rsquo;s real platform work). A change inside parental_control can\u0026rsquo;t reach sideways into player; the blast radius of a bug stays inside the slice that introduced it.\nWhy does Kahf Kids skip the domain/ layer? Because the content doesn\u0026rsquo;t earn it. Textbook four-layer Clean Architecture adds a pure-Dart domain/ module for heavy business rules — finance, HRM, anything with non-trivial logic. A kids\u0026rsquo; content app doesn\u0026rsquo;t have that weight, so the layers a slice actually needs are application/, presentation/, shared/, and infrastructure/ when platform work demands it. The Crewlix HR platform runs the full four-layer shape because HRM domain logic earns it.\nHow does the WebView overlay test in isolation? When playback state lives in player/application/ and the overlay reads it through a Riverpod provider, you can write a widget test that injects a fake provider and exercises the overlay\u0026rsquo;s gesture logic without spinning up the WebView. If the overlay reaches directly into platform code, that testability is gone — which is the whole point of holding the layer boundary.\n","date":"18 July 2026","externalUrl":null,"permalink":"/insights/kahfkids-flutter-clean-architecture/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003eKahf Kids\u003c/a\u003e is an Islamic kids\u0026rsquo; education app I built in Flutter and ran in production for about eighteen months across Android, iOS, and Web: curated YouTube videos, Quran, books, games, courses — all behind a parent-controlled PIN. This is the architecture write-up. Not the marketing page — the engineering one.\u003c/p\u003e","title":"Kahf Kids Flutter Clean Architecture: Riverpod, Auto Route","type":"insights"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. This is the architecture deep dive for the React Native / Expo rebuild of Kahf Kids, the Islamic kids\u0026rsquo; education app I migrated off Flutter after roughly eighteen months in production. The product is the same — curated video, Quran, books, games, courses, and a doctor-consultation feature (Healthcare / DocTime), all behind a parent-controlled PIN — and the device matrix is the same: low-end Android handsets with Mali and PowerVR GPUs that the Flutter platform-view hot path was ANR-ing on. The architecture underneath is new. This piece is the engineering write-up of that new shape: one React Native / Expo codebase and a shared monorepo package set targeting five platforms — Android, iOS, Web, Android TV, and web TV.\nThe decision to migrate is documented separately; the Flutter architecture I migrated from is documented separately too. This spoke is what sits underneath the shipping product now: the monorepo layout, the mobile branch, the web branch, and the TV branch — including the forked spatial-navigation layer that turned the TV target from a Flutter dead-end into a production-ready path.\nTwo structural wins drove the call. The platform-view handshake that was producing foreground ANRs on the Mali/PowerVR device matrix does not exist in React Native\u0026rsquo;s view tree — the ANR investigation has the full failure-mode analysis. And the TV target that Flutter could not get production-grade works in React Native because the spatial-navigation layer is ours to build, not a closed engine\u0026rsquo;s to maybe-ship. That second win is the whole reason this spoke is the densest of the cluster.\nThe contract carried, the constraint added # The parental-control contract is the one the Flutter build enforced: a child watching a video cannot leave that flow, and the parent\u0026rsquo;s trust lives on low-end Android hardware in emerging markets. That contract comes with two pieces of OS-level machinery that have to be wired through sanctioned paths — Android\u0026rsquo;s AccessibilityService and Apple\u0026rsquo;s Screen Time framework — and the migration had to keep both intact. It did. The Android accessibility path with its prominent-disclosure flow and the iOS Screen Time path (FamilyControls, ManagedSettings, DeviceActivity) survived store review on the rebuild the same way they survived it on the Flutter build. The migration reasoning is in the migration write-up.\nThe new constraint was the living room. Flutter\u0026rsquo;s Android TV support was not production-grade — focus handling, D-pad navigation, platform-view behavior on TV chipsets were all unreliable. For a kids\u0026rsquo; product where meaningful engagement belongs on the family TV, that was a structural miss, not a nice-to-have. The rebuild had to treat TV as a first-class target from day one, not a roadmap item. That requirement is what pushed the architecture toward a monorepo with a dedicated TV shell, instead of a single app with TV views bolted on.\nMonorepo + shared internal packages # The codebase is a monorepo. One mobile-and-web shell, one TV shell for the living room, and twelve shared internal packages wired in as local file: dependencies:\ndomain — pure business logic. Entities, value objects, validation. No React, no platform code. Identical on every target. data and local-data — server-state and on-device persistence. TanStack Query for server state, Drizzle ORM over SQLite for local. api — axios-based API client, shared between mobile and TV. player — the curated-video player contract both apps implement. analytics and crash-reporting — Openpanel and Firebase Crashlytics abstractions, so a fix to event schema ships to both apps. i18n — locale resources and i18next config. quran-ui — Quran reader components shared between mobile and TV. startup — app-initialization sequence shared across shells. brand-assets — logos, colors, fonts. tv-space-navigation — TV-only. The forked spatial-navigation package (covered below). Most of those packages ship to both apps. The split is asymmetric and deliberate: the mobile shell pulls eleven of them, the TV shell pulls twelve — the same eleven plus tv-space-navigation. A fix in domain or player ships to mobile and TV together. The view layer and the navigation layer are where the two apps diverge: Expo Router file-based routes in the mobile shell, a separate TV route tree in the TV shell (with (main), watch, playlists, quran branches and a TV-specific sidebar focus system). Drizzle for the local DB, TanStack Query for server state, Hookform with @hookform/resolvers for the parent-settings flows. The state contract is shared; the screen shapes are not.\nMobile branch — the mobile shell # The mobile shell runs Expo ^57.0.1 on React Native 0.86 in TypeScript (app version 4.0.0). Expo Router gives file-based, type-safe routes — a typo in a route name is a build error, not a runtime crash on a kid\u0026rsquo;s phone. The route tree is file-based, with _layout files composing the navigation hierarchy and the parental-control route guard gating the kid-facing flows behind the parent PIN.\nAnimations are Reanimated, configured with static feature flags so the worklet UI-update path is deterministic across the cheap-device matrix. The flags I set, in package.json under reanimated.staticFeatureFlags: ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS, IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS, FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS, USE_SYNCHRONIZABLE_FOR_MUTABLES, USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS. These are public Reanimated API names, and the combination is what stops an animated player overlay from dropping a frame on a Helio-class SoC while a video is decoding underneath it.\nBuilds run through EAS locally — eas-cli ... --local — with development, preview, and production profiles for both Android and iOS, plus --clear-cache variants for the cases where a stale Gradle or CocoaPods cache is fighting a native-module change. Local builds matter here: the signing material never leaves the workstation, and the build runs on hardware I control. Six custom native modules (the parental-control module, plus callkit-telecom-lite, home-shortcuts, launch-intent, rich-notifications, video-fullscreen-orientation) are wired in through Expo autolinking. Patches ride patch-package, drawing from a shared patches folder at the repo root plus a local patches folder per app — so a fix to an upstream dependency that both apps need lives once at the root.\nParental controls split by OS # The parental-control module splits cleanly along the OS-sanctioned boundary:\nAndroid — AccessibilityService. The AppBlockerService watches package-launch events and blocks apps against the parent\u0026rsquo;s blocklist and schedule. Kotlin. This is the OS-sanctioned path for app blocking, and the prominent-disclosure flow is what survives Google Play\u0026rsquo;s accessibility-policy review. iOS — Screen Time. FamilyControls for authorization, ManagedSettings to apply the shield, DeviceActivity to monitor schedules. Swift. Apple gives you one path; this is it. Two implementations, one parental-control contract. Neither side fakes the other OS\u0026rsquo;s API.\nNative modules and the package set # The native-module story is the part of the rebuild that did not have a clean off-the-shelf answer either. A dedicated modules directory carries six custom Expo native modules, autolinked through Expo autolinking. The parental-control module is detailed above; the other five cover OS hooks that the npm ecosystem either does not expose or wraps in a way that fights the rest of the app.\nparental-control (Android + iOS) — the AccessibilityService + Screen Time contract above. callkit-telecom-lite (Android + iOS) — VoIP incoming-call notifications, iOS CallKit + Android Telecom. A stripped fork of the public expo-callkit-telecom npm package. Media and WebRTC removed, because this app\u0026rsquo;s call media does not run native — it runs in the Healthcare WebView (below). Registered under the upstream name ExpoCallKitTelecom so the AndroidManifest service declarations and the JS requireNativeModule(...) calls stay byte-for-byte valid against the upstream API. This is the second \u0026ldquo;fork and trim\u0026rdquo; beat on the rebuild — same shape as the tv-space-navigation fork, different domain. video-fullscreen-orientation (Android + iOS) — locks the screen orientation while a video plays fullscreen. VideoFullscreenOrientationModule on both sides (Kotlin + Swift). The orientation libraries on npm either overreach (forcing orientation app-wide) or underdeliver (not exposing the fullscreen-only lock this app needs). home-shortcuts (Android) — Android home-screen dynamic shortcuts. The launcher route is a dedicated screen. iOS pinning is a different contract; this module is Android-only on purpose. launch-intent (Android) — Android intent and deep-link routing. Exposes isLaunchedFromHistory and isFreshDeepLinkIntent so the JS side can distinguish a cold-start deep link from a warm resume. rich-notifications (Android) — rich push notifications plus a tap-event emitter (NotificationTapEventEmitter, RichNotificationsModule). Needed for the notification payloads the marketing and retention flows push. Healthcare / DocTime — the surface the cluster has not named # Kahf Kids is not only the curated-video / Quran / books / games / courses surface. It also ships a doctor-consultation feature — Healthcare / DocTime — that puts a parent in a video call with a clinician from inside the same app, behind the same parental PIN. The Flutter side is a DocTime feature; the React Native side is a Healthcare feature screen. The call media runs in a Healthcare WebView rather than native WebRTC, because the telehealth vendor\u0026rsquo;s SDK is a web bundle and re-hosting it native was not worth the maintenance. The native callkit-telecom-lite module is what makes that WebView usable as a phone call: it wires the incoming-call system integration (CallKit on iOS, Telecom on Android) so a consult request rings the phone\u0026rsquo;s native incoming-call UI, and the WebView only handles the in-call media once the parent picks up. That division is why the module is a stripped fork — the upstream expo-callkit-telecom carries its own WebRTC media layer, and that layer is dead code in this app.\nTV native module — tv-voice-search # The TV shell carries its own native module: tv-voice-search, a TV-shell native module. Kotlin TvVoiceSearchModule for Android TV, plus a TvVoiceSearchModule.web.ts stub so the same TS import compiles for web TV (where the voice-search system integration is the browser\u0026rsquo;s, not the OS\u0026rsquo;s). The pattern — Kotlin implementation for Android TV, web stub for web TV — is the same env-flag TV-gating pattern the rest of the TV shell uses.\nThe package set — why the monorepo exists # The twelve shared packages listed above are the reason the codebase is a monorepo. Mobile consumes eleven of them; TV consumes twelve (the same eleven plus tv-space-navigation). The shared layer is everything below the view — domain, data, player, i18n, and the rest — and the view layer is where the two shells diverge: Expo Router in the mobile shell, a separate TV route tree in the TV shell. A bug fixed in domain ships to mobile and TV together; a screen-shape change stays in the shell that owns the screen. The view diverges; everything below it does not. The callkit-telecom-lite fork above is the same pattern as the tv-space-navigation fork below — an upstream was almost right, the delta was ours to define, and a forked module under our control shipped faster than a wrapper around an upstream we did not own.\nPatches: the crash and ANR fixes we carry # The contrast that opened this piece — the Flutter platform-view ANR on the Mali/PowerVR matrix was unfixable below the app layer because it lived inside closed GPU driver blobs — has a positive version on the React Native / Expo side. The production ANRs and OEM crashes here live in open Kotlin and TypeScript we can reach, read, and patch. patch-package is how those fixes ship without waiting on an upstream release.\nThe mechanism runs in three layers. A root patches/ folder carries patches both apps need, applied with patch-package --patch-dir ../patches. Each shell has its own patches folder for app-local fixes. Patches are surgical diff files against node_modules/, regenerated on every dependency bump, each one carrying an in-source comment that explains the failure mode and the fix. Fourteen patches in total. The build and web patches (Drizzle driver types, Metro DependencyGraph, the Skia and file-system web builds) are plumbing — they exist, but they are not the story. The story is the crash, ANR, and navigation-bug patches.\nreact-native-screens+4.25.2 — ANR. ScreenContainer.setFragmentManager() runs from onAttachedToWindow, which Fabric invokes from Reanimated\u0026rsquo;s animation-frame mount flush. A synchronous fragment commit there blocks the UI thread and produced production ANRs. The patch sets needsUpdate = true and post { performUpdates() } instead of performUpdatesNow(), deferring the commit off the mount flush. react-native-gesture-handler+2.32.0 — crash on MIUI/Xiaomi. A long-standing Android framework bug: a multi-touch POINTER_UP arrives with a stale pointer index, IllegalArgumentException: invalid pointerIndex -1 fires inside ScrollView#onTouchEvent → MotionEvent#getY, and the app crashes. Tracked upstream at react-native-gesture-handler #3921 (PR #3642). The patch wraps view.onTouchEvent(event) in a try/catch, swallows the stale-pointer event, and lets the gesture end cleanly. expo-splash-screen+57.0.2 — crash on Android 12-13. Race condition: the splash exit listener can fire after the Activity pauses, hitting SurfaceControl.checkNotReleased() and crashing the app (Google issuetracker #242118185). The patch unregisters the listener before that race window can open. expo-application+57.0.0 — race on the InstallReferrer connection. getInstallReferrerAsync had an unsettled-promise race on the InstallReferrer client connection — the promise could fail to settle. The patch rewrites the path with an AtomicBoolean and a dedicated executor so the promise settles exactly once. expo-router+57.0.2 — deep-link replay on Activity recreation. Restoring Android navigation state across Activity recreation when the JS process was still alive (swipe-from-Recents, then re-open) replayed the last deep-link route once on the next open. The patch skips the restore in that path. Marked in-source as a Kahf Kids patch. react-native-reanimated+4.5.0 — log gating. The synchronouslyUpdateUIProps failed warning is gated behind BuildConfig.DEBUG, so a caught exception in the synchronous UI-prop path does not spam production logs. This ties back to the Reanimated static feature flags in the mobile branch: the flags make the synchronous path deterministic, and the patch silences the warning when the path catches an exception in dev. Three more patches round out the set without being crash fixes: react-native+0.86.0 sets allowFontScaling = false as the default for Text and TextInput, so huge system font scales on cheap devices don\u0026rsquo;t break the kids\u0026rsquo; UI layout; expo-in-app-updates+0.12.0 is a native Kotlin patch to the in-app-updates module; @bam.tech/react-native-keyevent-expo-config-plugin+1.0.52 adds the #import \u0026quot;AppDelegate.h\u0026quot; guard so the TV keyevent plugin configures correctly.\nWhy patch-package and not fork-everything? The forked packages on this rebuild — tv-space-navigation below, callkit-telecom-lite above — carry changes too large for a patch file: extended public APIs, removed subsystems. The crash and ANR fixes are the opposite shape: surgical, upstream-trackable, a handful of lines per patch. patch-package keeps each fix attached to a specific upstream version, regenerates against the new version on a bump, and fails loudly if the underlying code has moved. That is the difference between an open-source ecosystem where the source is reachable and a closed one where it is not — the ANR investigation on the Flutter side is the negative-case write-up. The migration held for many reasons; this openness is one of them.\nWeb branch + the no-Skia constraint # The web build is expo export --platform web, gated by a verify-no-skia-web-bundle.mjs script in a shared metro-config package that runs as the last step of web:build. The gate enforces what expo.install.exclude already declares in package.json: the web bundle must not contain @shopify/react-native-skia or @shopify/flash-list. Either one in the web bundle is a build failure, not a warning.\nThe reason is bundle size on emerging-market networks. Skia is a heavy native dependency; shipping it to a browser — to a parent on intermittent 3G/4G trying to load a kid\u0026rsquo;s app — is the wrong trade. The TV build includes Skia (next section) because a TV is on Wi-Fi rendering against a known chipset; the web build excludes it because a phone on a flaky network is the worst-case delivery target. The constraint is encoded at the install layer (expo.install.exclude) and re-asserted at the build layer (the verify script), so the gate holds even if a transitive dependency tries to pull either package back in.\nTwo supporting scripts round out the web branch. subset-icon-fonts.js subsets the icon-font families to the glyphs the app actually uses — a meaningful reduction when the full icon set spans multiple families. And a custom youtube-player-web build plus upload script handles the curated-video player on web. Apple targets are wired through @bacons/apple-targets.\nThe TV branch + the forked spatial-navigation layer # This is the section that did not have a Flutter answer. TV is gated by env flags at build time: EXPO_TV=1 produces Android TV and Apple TV builds, and EXPO_PUBLIC_TV_BUILD=true produces the web-TV build. The EAS profiles encode this directly — development_tv, preview_tv, and production_tv extend the base profiles with EXPO_TV: \u0026quot;1\u0026quot; in the env block, so the same EAS command surface produces mobile and TV artifacts from one config. The Apple TV target pulls react-native-tvos (the 0.86-stable line) through an npm alias rather than mainline React Native, because Apple TV needs the react-native-tvos fork\u0026rsquo;s TV focus primitives.\nThe TV shell shares the same data layer as mobile — Firebase, Openpanel, Drizzle, TanStack Query — and the same domain, player, and analytics packages. The TV build includes @shopify/react-native-skia ^2.6.4, in deliberate contrast to the web no-Skia gate: the TV is on Wi-Fi rendering against a known chipset, and Skia earns its weight there. The TV route tree carries the living-room surfaces: a (main) shell with browse, search, playlists, favourites, history, quran, and settings routes; a watch/ branch with the TV video player; a playlists/ branch; and a quran/ sub-tree (reader, bookmarks, note, search, tajweed-rules) for the TV Quran experience. Watchtime-limit enforcement runs on TV the same way it runs on mobile — same domain package, different UI.\nThe fork — tv-space-navigation, built on @bam.tech/lrud # Spatial navigation on TV is D-pad-driven: up/down/left/right/enter, with the LRUD (Left/Right/Up/Down) model the TV community has converged on. The package that powers it is a shared tv-space-navigation package. It is a fork of bamlab/react-tv-space-navigation — public, MIT-licensed — modernized for the current Expo line and extended for Kahf Kids TV flows. Built on @bam.tech/lrud ^8.0.2 as the spatial-index engine, with @bam.tech/react-native-keyevent-expo-config-plugin wiring the physical remote keys through Expo\u0026rsquo;s config-plugin path.\nThe fork rationale is plain. The upstream was almost right, but lagged the Expo generation we were shipping on and didn\u0026rsquo;t expose the hooks the Kahf Kids TV flows needed — sidebar focus trapping, grid-as-browse, parental-overlay lock. Forking was cheaper than wrapping, and the MIT license made it straightforward. The internal structure carries components/, context/, hooks/, helpers/, types/, a SpatialNavigator.ts core, and a configureRemoteControl.ts entry point for the remote-key contract.\nThe public API surface is the part the TV app builds against:\nSpatialNavigationRoot and SpatialNavigationNode — the root and per-focusable primitives that compose the spatial tree. SpatialNavigationVirtualizedList and SpatialNavigationVirtualizedGrid — the grid is what powers the YouTube-TV-style browse: rows of cards, D-pad moves cell to cell, the grid virtualizes so a long catalog doesn\u0026rsquo;t render flat. useLockSpatialNavigation — focus trapping. Used when a parental-overlay or modal is open, so a D-pad press can\u0026rsquo;t escape the modal into the underlying UI. configureRemoteControl (also exposed as SpatialNavigation.configureRemoteControl) — the D-pad/remote-key entry point. This is where the TV app wires physical keys to LRUD directions. SpatialNavigationDeviceTypeProvider — branches TV vs web-TV vs other, so the same component tree tunes its focus behavior to the device class. DefaultFocus context, useSpatialNavigatorFocusableAccessibilityProps for a11y, and a Directions re-export from @bam.tech/lrud. The D-pad wiring in practice, redacted from the TV remote-control focus module:\nimport { Directions, SpatialNavigation } from \u0026#39;../tv-space-navigation\u0026#39;; import RemoteControlManager from \u0026#39;./remote-control-manager\u0026#39;; import { SupportedKeys } from \u0026#39;./supported-keys\u0026#39;; const KEY_TO_DIRECTION: Record\u0026lt;SupportedKeys, Directions | null\u0026gt; = { [SupportedKeys.Up]: Directions.UP, [SupportedKeys.Down]: Directions.DOWN, [SupportedKeys.Left]: Directions.LEFT, [SupportedKeys.Right]: Directions.RIGHT, [SupportedKeys.Enter]: Directions.ENTER, // media keys (play / pause / rewind / ...) are null — handled elsewhere }; SpatialNavigation.configureRemoteControl({ remoteControlSubscriber: (callback) =\u0026gt; RemoteControlManager.addKeydownListener((key) =\u0026gt; { callback(KEY_TO_DIRECTION[key]); return false; }), remoteControlUnsubscriber: (listener) =\u0026gt; RemoteControlManager.removeKeydownListener(listener), }); That is the whole contract: physical key → LRUD direction (or null for non-spatial keys) → the @bam.tech/lrud engine → the right SpatialNavigationNode gets focus. The grid browse, the sidebar focus system, the watchtime-limit overlay, the parental PIN modal — all of them compose out of that primitive set. This is the layer Flutter TV could not give us production-grade, and the React Native / Expo + forked-LRUD stack gives us without rewriting the data layer or the domain. The same domain and player packages that ship to mobile ship to TV unchanged.\nOutcome + takeaways # Five targets from one shared package set: Android, iOS, Web from the mobile shell; Android TV, Apple TV, web TV from the TV shell. Video playback is stable across the device matrix that was ANR-ing under Flutter — the platform-view handshake that produced the failure mode does not exist in this architecture, so the class of failure is gone, not just reduced. The parental-control boundary holds on both mobile platforms and survived store review. The TV path is production-ready without a separate codebase or a separate domain layer. The web bundle stays under the no-Skia gate.\nNumbered takeaways from this build:\nFork an upstream when it is one PR away from your contract. The spatial-navigation upstream was almost right; the delta was Expo-generation currency plus the focus hooks the TV flows needed. Wrapping it would have meant maintaining the wrapper and the upstream\u0026rsquo;s surface at the same time. Forking under MIT was cheaper, and the diff is now ours to ship on our timeline. Gate TV builds by env, not by repo. EXPO_TV=1 and EXPO_PUBLIC_TV_BUILD=true in the EAS profile env block produce TV artifacts from one config surface. A separate repo would have forced a second copy of the shared packages and let the domain layer drift; env-flag gating keeps the share real. Encode the web-bundle constraint twice. expo.install.exclude keeps Skia and Flash List out of the install graph; verify-no-skia-web-bundle.mjs re-asserts it at build time. One gate is a policy; two gates is a constraint that survives a transitive-dependency surprise. Share the domain, diverge the view. Twelve shared internal packages, eleven consumed by both apps. The view and navigation layers are where mobile and TV differ; everything below that is common. A bug fixed in domain is fixed once for five targets. TV includes Skia; web excludes it. Same codebase, different delivery constraint. The TV is on Wi-Fi against a known chipset; the web build targets a phone on an emerging-market network. The dependency decision is per-target, not per-codebase. Write the native module when the npm package fights you. Six custom Expo native modules under a dedicated modules directory cover OS integrations that off-the-shelf wrappers either do not expose or wrap wrong — AccessibilityService app blocking, CallKit/Telecom VoIP integration, fullscreen-orientation lock, home shortcuts, deep-link routing, rich push. The Healthcare / DocTime split (native call system integration, web call media in a WebView) is what made the callkit-telecom-lite fork worth the maintenance. If you\u0026rsquo;re scoping a React Native / Expo multi-target build # The decisions in this piece — the monorepo split between mobile and TV shells, the env-flag TV gating, the forked spatial-navigation layer, the web no-Skia gate — are the decisions I make on every React Native / Expo build that has to hit more than one target. If you\u0026rsquo;re scoping a TV app build, weighing whether your Expo codebase can extend to Android TV or web TV without a rewrite, or deciding whether to fork an upstream package or wrap it, get in touch. I do this work.\nFrequently Asked Questions # Can Expo target Android TV? Yes. Expo does not add first-class TV support, but it does not block it either. The Kahf Kids TV build uses EXPO_TV=1 at build time to gate the TV target, with @react-native-tvos/config-tv and the react-native-tvos fork handling Apple TV focus primitives. Android TV works through native D-pad focus and remote-key handling wired through @bam.tech/react-native-keyevent-expo-config-plugin, with the EAS profiles (development_tv, preview_tv, production_tv) extending the base profiles with the EXPO_TV env.\nWhat is spatial navigation in React Native? D-pad-driven focus management for TV UIs — up, down, left, right, enter, with an LRUD (Left/Right/Up/Down) engine tracking which focusable node currently has focus. The Kahf Kids TV app uses a forked react-tv-space-navigation package built on @bam.tech/lrud, exposing SpatialNavigationRoot, SpatialNavigationVirtualizedGrid for YouTube-TV-style browse, useLockSpatialNavigation for focus trapping inside parental overlays, and configureRemoteControl as the D-pad entry point.\nDid you fork react-tv-space-navigation? Yes. The internal tv-space-navigation package is a fork of bamlab/react-tv-space-navigation — public, MIT-licensed — modernized for the current Expo line and extended for Kahf Kids TV flows. Forking was cheaper than wrapping: the upstream was almost right, and the delta was Expo-generation currency plus the focus hooks the TV product needed (sidebar focus, grid browse, modal lock).\nHow do you keep the Expo web bundle small? Two layers. expo.install.exclude in package.json keeps @shopify/react-native-skia and @shopify/flash-list out of the web install graph. A verify-no-skia-web-bundle.mjs script re-asserts the constraint at build time and fails the build if either package reaches the web bundle. A subset-icon-fonts.js script further reduces the icon-font payload to only the glyphs the app uses.\nWhy did you write custom Expo native modules? Six modules under a dedicated modules directory cover OS integrations that off-the-shelf npm packages either do not expose or wrap in ways that fight the rest of the app: parental-control (Android AccessibilityService + iOS Screen Time), callkit-telecom-lite (VoIP incoming-call notifications, forked from the public expo-callkit-telecom with media stripped for the Healthcare WebView), video-fullscreen-orientation, home-shortcuts, launch-intent, and rich-notifications. The TV shell ships its own tv-voice-search module — Kotlin for Android TV, a web stub for web TV. All are wired in through Expo autolinking.\nWhat is the Healthcare / DocTime feature? A doctor-consultation product surface inside Kahf Kids. The Flutter side is a DocTime feature; the React Native side is a Healthcare feature screen. The call media runs in a Healthcare WebView because the telehealth vendor\u0026rsquo;s SDK is a web bundle, and the native callkit-telecom-lite module wires the incoming-call system integration (CallKit on iOS, Telecom on Android) so a consult request rings the phone\u0026rsquo;s native call UI.\n","date":"18 July 2026","externalUrl":null,"permalink":"/insights/building-kahfkids-rn-expo-architecture/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. This is the architecture deep dive for the React Native / Expo rebuild of \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003eKahf Kids\u003c/a\u003e, the Islamic kids\u0026rsquo; education app I migrated off Flutter after roughly eighteen months in production. The product is the same — curated video, Quran, books, games, courses, and a doctor-consultation feature (Healthcare / DocTime), all behind a parent-controlled PIN — and the device matrix is the same: low-end Android handsets with Mali and PowerVR GPUs that the Flutter platform-view hot path was ANR-ing on. The architecture underneath is new. This piece is the engineering write-up of that new shape: one React Native / Expo codebase and a shared monorepo package set targeting five platforms — Android, iOS, Web, Android TV, and web TV.\u003c/p\u003e","title":"Kahf Kids React Native / Expo Architecture: Mobile, Web, TV","type":"insights"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. This is the SEO spoke for Kahf Kids, the Islamic kids\u0026rsquo; education app I migrated from Flutter to React Native / Expo after about eighteen months in production. The product runs curated video, Quran, books, games, and courses behind a parent-controlled PIN, and the web build ships out of the same React Native / Expo codebase as the mobile apps — documented across the cluster. This piece is narrow on purpose: the engineering decision that got a client-rendered kids\u0026rsquo; video app actually indexed by Google — meta-injection into the SPA shell, not full server-side rendering.\nThe hard truth about a kids\u0026rsquo; video catalog on the web is this: ship it as a pure client-rendered SPA and it is invisible. A crawler arrives at /video/{id}, the server returns the same empty index.html it returns for every URL, the JS bundle downloads and renders in a headless browser if you are lucky — and even then, the per-URL meta tags, the canonical URL, the structured data, and the sitemap entry are either missing or generic. Thousands of videos, zero indexable pages. Link previews in WhatsApp and Telegram show the app\u0026rsquo;s default title, not the video\u0026rsquo;s. Google\u0026rsquo;s video rich results never fire because there is no VideoObject markup. That is the default outcome for a React Native / Expo web build, and it is what this architecture had to fix.\nThe fix is split across two layers that cooperate. The backend generates a canonical sitemap tree — every video and playlist URL, paginated, with priorities and lastmods. A thin Fastify server that runs in front of the Expo web build injects per-route meta into the SPA\u0026rsquo;s index.html on every request — title, description, Open Graph, Twitter card, and schema.org VideoObject JSON-LD — pulled live from the backend\u0026rsquo;s metadata API. Crawlers and share-sheet fetchers get rich, per-URL, indexable HTML. Users get the SPA. No full SSR.\nThe SEO problem — why a SPA is invisible, and why SSR is overkill # A client-rendered SPA is invisible to crawlers for a reason that is structural, not a bug. The server returns the same index.html for every URL — that is the point of client-side routing. The meta tags inside that shell are the app\u0026rsquo;s defaults, not the route\u0026rsquo;s content. Googlebot does render the JS bundle in a secondary pass, but only for the routes it has already crawled, and only after a delay that can stretch to days. Worse, the messengers — WhatsApp, Telegram, Slack, Discord, iMessage — do not render JS at all; they fetch the HTML, read the Open Graph tags, and build the link preview from that. Send a /video/{id} link in WhatsApp and the recipient sees the app\u0026rsquo;s default og:title, not the video\u0026rsquo;s. That is the actual user-facing failure, not just a Google ranking issue.\nThe textbook fix is full server-side rendering — Next.js, Remix, a React server renderer. For a content catalog of thousands of videos with low per-page interactivity, full SSR is the wrong trade. You pay for a JavaScript runtime on the server, a render pipeline per request, hydration mismatch bugs, and infrastructure that scales with crawl traffic, not user traffic. The per-URL metadata — title, description, og:image, canonical, VideoObject JSON-LD — is the only thing that needs to be server-visible. The interactive video player does not. So the question becomes: how do you make just the metadata server-visible, for every URL, without paying for a full render server?\nThe SEO mechanism — meta-injection into the SPA shell # This is the load-bearing section. Two layers cooperate: the backend generates the sitemap tree; the Fastify server in front of the SPA does the per-request meta injection.\nThe paginated sitemap tree # The backend serves GET /api/sitemaps/{filename} — the source of truth for what the crawler discovers. It dispatches by filename and builds the sitemap tree from the in-memory content catalog:\nindex.xml — a sitemap index pointing to static.xml, videos-index.xml, playlists-index.xml. Cached 24h. static.xml — a single urlset with the homepage URL, priority 1.0, changefreq=daily. Cached 24h. videos-index.xml — a sitemap index of videos-1.xml … videos-{N}.xml, paginated at a fixed page size. playlists-index.xml — same shape, paginating the playlist catalog. videos-{page}.xml / playlists-{page}.xml — paginated urlsets. Each \u0026lt;url\u0026gt; carries \u0026lt;loc\u0026gt; (/video/{id} or /playlists/{id}), \u0026lt;priority\u0026gt; (0.8 for videos, 0.6 for playlists), \u0026lt;changefreq\u0026gt;weekly\u0026lt;/changefreq\u0026gt;, and — for videos — \u0026lt;lastmod\u0026gt; derived from published_at. The point is that every video and playlist URL in the catalog is in a sitemap. As the content catalog updates, the next sitemap rebuild picks up the new URLs and the new lastmods, automatically. There is no separate sitemap-build step to forget. The sitemap tree is generated from the same catalog the API serves from — single source of truth.\nThe Expo Fastify server: per-request meta injection # The web build runs behind a Fastify server (expo-app/server/) — the same server that serves the static bundle. It is small, it is TypeScript, and it does the SEO work the SPA cannot. Five services:\nCrawlerDetector — keeps a list of eighteen known crawler and share-sheet user agents (Googlebot, bingbot, yandex, facebookexternalhit, Facebot, Twitterbot, LinkedInBot, WhatsApp, TelegramBot, Slackbot, discordbot, Pinterestbot, redditbot, Applebot, SkypeUriPreview, applebot-extended, google-read-aloud, whatsapp/2). I want to be honest about how it is used: it does not gate the meta-injection. It tags the response with a crawler-flag header for observability, that is it. The architecture deliberately does not bet on UA sniffing for correctness — see the wiring note below. MetaInjector — reads the SPA\u0026rsquo;s index.html into memory once at boot, then exposes two injectors. injectHomepageMeta() produces the homepage HTML — title, description, OG tags, Twitter card, and a WebSite JSON-LD block. injectContentMeta(content, 'video' | 'playlist') produces the per-URL HTML. For a video, the OG type becomes video.other and the JSON-LD block is a schema.org VideoObject — name, description, thumbnailUrl, uploadDate, contentUrl, embedUrl, and a publisher Organization (the channel name when present, fallback to the app). For a playlist, the JSON-LD is an ItemList with numberOfItems. Image falls back to a default og-image when the video has no thumbnail. The injector strips the base shell\u0026rsquo;s existing SEO/OG/twitter/title tags before injecting, so the per-route set is the only one in the document — duplicate og tags break strict parsers, and WhatsApp in particular drops the whole preview when it sees two og:image sets. KahfKidsApiClient — the backend client the injector uses to fetch the video or playlist record by id. SitemapGenerator — proxies the backend\u0026rsquo;s sitemap endpoints through the web origin, with a 1-hour in-memory cache (CACHE_DURATION_MS = 60 * 60 * 1000) and a regex (^[a-z]+(-[a-z0-9]+)?$) that validates the filename before it is forwarded. So /sitemap.xml on the web origin hits backend /api/sitemaps/index.xml; /sitemaps/videos-1.xml hits backend /api/sitemaps/videos-1.xml. The web origin is the canonical public face; the backend is upstream. The regex is not paranoia — without it the proxy would forward arbitrary filenames to the backend, and a malformed request would surface as a 500 rather than an empty urlset. When the backend is unreachable, the proxy returns a valid empty \u0026lt;urlset\u0026gt; with a comment rather than an error XML, so a transient backend blip does not poison the cached sitemap. HtmlCache — caches the injected HTML per content id, so a second fetch for the same video does not hit the backend. The wiring in index.ts is the design point I want to underline. The / route always returns injected homepage meta. The setNotFoundHandler matches /video/{id} and /playlists/{id} and injects per-content meta on every request, regardless of user agent — not just when a crawler is detected. The comment in source says why: the iOS share sheet\u0026rsquo;s on-device preview fetch uses a Safari-style UA that UA sniffing would miss, and so do half the messenger unfurlers. Injecting for every fetcher is cheap — the result is cached per content id — and it is correct by default. Crawler detection becomes an observability signal, not a correctness gate.\nThat is the whole mechanism. A crawler arrives at /video/{id}. The Fastify setNotFoundHandler matches the route, the MetaInjector fetches the video record from the backend, strips the base shell\u0026rsquo;s SEO tags, injects the per-URL title/OG/Twitter/VideoObject-JSON-LD set, caches the rendered HTML, and returns it. The crawler reads a fully-formed, per-URL HTML document with VideoObject structured data. A real user hits the same URL, gets the same HTML, and the SPA hydrates around the meta into the interactive video player. Same URL, same HTML, different consumers. No full SSR.\nOutcome # Thousands of video and playlist URLs, each indexable with correct per-URL meta and a VideoObject rich-result payload. Link previews work in WhatsApp, Telegram, Slack, Discord, iMessage, and the rest of the unfurler matrix — not because each one was special-cased, but because the per-URL OG tags are correct by default. The sitemap tree covers every URL in the catalog and stays fresh with the content store, no separate build step. The web shell stays a client-rendered SPA — no server JS runtime, no hydration, no render pipeline scaling with crawl traffic.\nThe layering is what I want to underline as the engineering outcome. The backend owns the catalog and the sitemap tree; the Fastify server owns the per-request HTML assembly; the SPA owns the interactive player. Three responsibilities, three layers, no overlap. A new video lands in the catalog → the next sitemap rebuild includes it → the next crawl discovers it → the next share-sheet fetch returns its per-URL meta. No deploy orchestrates those steps; they fall out of the architecture. The server layer this all lives in is the web branch of the RN/Expo rebuild, documented separately.\nTakeaways # Meta-injection beats SSR when the per-URL metadata is the only server-visible thing. A video catalog does not need a React render server. It needs correct title/OG/JSON-LD per URL, served from a thin Fastify layer that reads the SPA shell once and rewrites \u0026lt;head\u0026gt; per route. The interactive player does not need to be server-rendered; the meta does. Paginate the sitemap tree from the content catalog, not from a separate build step. The sitemap endpoints derive their urlsets from the same catalog the API serves from. New content appears in the sitemap on the next request, with the right lastmod. A fixed page size is the only knob. Do not bet correctness on UA sniffing. Detecting crawlers feels like the elegant answer; it breaks the first time a share sheet shows up with a Safari UA. Inject per-route meta for every fetcher, cache per content id, and treat crawler detection as observability — never as the gate. Strip the base shell\u0026rsquo;s SEO tags before injecting. Two og:image sets in one document is a real bug, not a stylistic preference. WhatsApp drops the preview; other parsers pick one arbitrarily. The injector\u0026rsquo;s stripBaseSeoTags step is what makes the per-route set canonical. Use VideoObject JSON-LD for video rich results. Google\u0026rsquo;s video rich results key off VideoObject — name, description, thumbnailUrl, uploadDate, contentUrl, embedUrl. Without it, a video URL is just a web page. With it, the URL is eligible for the video carousel and the rich result. Proxy the sitemap through the web origin, with a cache and a regex. The web origin is the canonical face crawlers see; the backend is upstream. A 1-hour cache absorbs crawl bursts; a filename regex stops malformed requests before they reach the backend; an empty-urlset fallback keeps a transient backend blip from poisoning the cache. If you\u0026rsquo;re scoping an SEO architecture for a client-rendered app # The decisions in this piece — the meta-injection layer in front of an SPA, the paginated sitemap tree, the per-route VideoObject JSON-LD — are the decisions I make on any content-heavy web product that ships client-rendered. If you are weighing meta-injection against full SSR, trying to get a React Native / Expo web build actually indexed, or repairing broken link previews across the messenger matrix, get in touch. I do this work.\nFrequently Asked Questions # How is a React Native / Expo web app SEO-friendly? Not by rendering server-side. The web build is a normal Expo web SPA — what makes it indexable is a Fastify server in front of it that intercepts /video/{id} and /playlists/{id} requests, fetches the record from the backend, and injects per-route title, Open Graph, Twitter card, and schema.org VideoObject JSON-LD into the SPA\u0026rsquo;s index.html. Crawlers see a fully-formed HTML document per URL; users hydrate the same HTML into the interactive app.\nHow does Kahf Kids generate sitemaps? The backend serves GET /api/sitemaps/{filename}. The sitemap tree is index.xml → static.xml (homepage, priority 1.0), videos-index.xml (a sitemap index of videos-{N}.xml), playlists-index.xml (same for playlists). Each paginated file carries a fixed page size of URLs with priority (0.8 video / 0.6 playlist), changefreq=weekly, and lastmod from published_at. The Expo Fastify server proxies these at /sitemap.xml and /sitemaps/:filename.xml with a 1-hour in-memory cache.\nWhat is meta injection vs SSR? SSR runs the React tree on the server and returns the rendered DOM per request — correct interactive HTML, but you pay for a JS runtime, a render pipeline, and hydration. Meta injection reads the SPA\u0026rsquo;s static index.html once, fetches the route\u0026rsquo;s metadata from the backend, and rewrites only the \u0026lt;head\u0026gt; — title, OG, Twitter, canonical, JSON-LD. The body is the SPA shell. Crawlers get correct per-URL meta and structured data; users get the SPA. No JS runtime on the server.\nHow are videos indexed for Google rich results? Per-URL VideoObject JSON-LD. When the Fastify server handles /video/{id}, the MetaInjector injects a VideoObject block with name, description, thumbnailUrl, uploadDate, contentUrl, embedUrl, and publisher. Combined with the per-URL sitemap entry and the canonical link tag, this makes the video URL eligible for Google\u0026rsquo;s video rich results and the video carousel — without server-rendering the player.\nWhy inject meta on every request, not just for detected crawlers? Because UA sniffing misses real fetchers. The iOS share sheet\u0026rsquo;s on-device preview fetch uses a Safari-style user agent; half the messenger unfurlers use unpredictable UAs; new crawlers appear. Betting correctness on a UA list means link previews silently break the first time an unrecognized fetcher arrives. Injecting per-route meta for every request is cheap — the result is cached per content id — and correct by default. Crawler detection stays in the code as an observability signal (it tags the response with which crawler was spotted), not as the gate that decides whether to inject.\n","date":"18 July 2026","externalUrl":null,"permalink":"/insights/kahfkids-seo-architecture/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. This is the SEO spoke for \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003eKahf Kids\u003c/a\u003e, the Islamic kids\u0026rsquo; education app I migrated from Flutter to React Native / Expo after about eighteen months in production. The product runs curated video, Quran, books, games, and courses behind a parent-controlled PIN, and the web build ships out of the same React Native / Expo codebase as the mobile apps — \u003ca\n  href=\"/insights/building-kahfkids-rn-expo-architecture/\"\u003edocumented across the cluster\u003c/a\u003e. This piece is narrow on purpose: the engineering decision that got a client-rendered kids\u0026rsquo; video app actually indexed by Google — meta-injection into the SPA shell, not full server-side rendering.\u003c/p\u003e","title":"Kahf Kids SEO Architecture: Meta Injection + Sitemaps","type":"insights"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/meta-injection/","section":"Tags","summary":"","title":"Meta-Injection","type":"tags"},{"content":"This policy explains what data this site collects, why, and how long it stays. It applies to mjashem.com only — any client engagement is governed by the separate contract between us.\nWhat I collect # Contact form submissions. When you use the contact form, the name, email, project type, and message you send are delivered to my inbox (mjashem@hotmail.com) via a third-party form provider. I keep these messages for as long as needed to respond to and close out the enquiry, then delete them on request. Privacy-first analytics. I run privacy-first, cookieless analytics (no Google Analytics, no cross-site tracking, no fingerprinting). Aggregate counts — pages viewed, rough geography at country level, referrer — are used only to understand which writing is useful. No individual visitor is identified. Server logs. The hosting provider keeps short-lived access logs (IP, user agent, timestamp) for abuse and uptime monitoring. These rotate on the provider\u0026rsquo;s default schedule. What I do not do # No advertising trackers. No ad networks. No remarketing pixels. No third-party social embeds that drop cookies before you click \u0026ldquo;accept.\u0026rdquo; No selling, renting, or sharing data with anyone, ever. Cookies # This site does not set marketing cookies. The only client-side storage it uses is:\nappearance — your light/dark theme preference. Stored locally in your browser; never sent anywhere. A consent record if you dismiss the cookie banner (where applicable). If you clear site data, both are removed and you\u0026rsquo;ll be asked again next visit.\nYour rights # You can, at any time, email mjashem@hotmail.com to:\nAsk what data I hold about you (from a contact form submission). Request deletion of your data. Request export of your data in a portable format. Object to processing. I respond within 30 days, usually much faster.\nChildren # This site is not directed at children under 13 and I do not knowingly collect data from them. The Kahf Kids project referenced in case studies is a client product — its own privacy policy governs that app.\nChanges # If this policy changes, the date above updates and a summary of the change is posted here. Material changes get a 30-day notice via the same contact email I have on file for you.\nContact # Questions? Email mjashem@hotmail.com.\n","date":"18 July 2026","externalUrl":null,"permalink":"/privacy/","section":"Mohammad Jashem — Senior Full-Stack Mobile Engineer","summary":"\u003cp\u003eThis policy explains what data this site collects, why, and how long it stays. It applies to \u003cstrong\u003emjashem.com\u003c/strong\u003e only — any client engagement is governed by the separate contract between us.\u003c/p\u003e","title":"Privacy Policy","type":"page"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/react-native/","section":"Tags","summary":"","title":"React-Native","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/riverpod/","section":"Tags","summary":"","title":"Riverpod","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/seo/","section":"Tags","summary":"","title":"Seo","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/sitemap/","section":"Tags","summary":"","title":"Sitemap","type":"tags"},{"content":"","date":"18 July 2026","externalUrl":null,"permalink":"/tags/spatial-navigation/","section":"Tags","summary":"","title":"Spatial-Navigation","type":"tags"},{"content":"These terms govern your use of mjashem.com. By accessing the site you agree to them. Any paid engagement is governed by a separate signed contract — these terms do not override that contract.\nThe content # Writing. Blog posts, insights, and case studies are © Mohammad Jashem. You\u0026rsquo;re welcome to quote short excerpts with attribution and a link back to the original. Republishing a full article, in any form, requires written permission. Code samples. Snippets in articles are released under MIT unless the article says otherwise. Use them in your own projects freely — no attribution required, no warranty offered. Case studies. Client names and product details are shared with the client\u0026rsquo;s consent. The architecture decisions and lessons are mine to write about; the underlying product IP remains the client\u0026rsquo;s. What this site is not # Not a solicitation. Reading this site does not create a client relationship. A client relationship exists only after both of us sign a contract. Not advice. The writing reflects my opinion and experience at time of writing. It is not engineering, legal, or financial advice for your specific situation. Apply your own judgment, or hire someone. Not always current. Mobile platforms move fast. Older articles describe decisions made at a point in time and may not reflect current best practice. Contacting me # When you submit the contact form or email me directly, you consent to me reading the message, replying to it, and keeping the thread for as long as needed to do business with you. See the Privacy Policy for the data side of this.\nClient engagements # For any paid work — consulting, development, migration, CI/CD setup — the scope, deliverables, timeline, and payment terms are defined in a separate contract. Nothing on this site is a binding offer for that work.\nThird-party links # The site links to external resources (docs, libraries, other writing). I\u0026rsquo;m not responsible for the content or practices of those external sites.\nLimitation of liability # This site is provided as-is. To the maximum extent permitted by law, I am not liable for any direct, indirect, incidental, or consequential damages arising from your use of the site or reliance on its content.\nChanges # If these terms change, the date above updates and a summary of changes is posted here. Continued use after a change constitutes acceptance.\nContact # Questions? Email mjashem@hotmail.com.\n","date":"18 July 2026","externalUrl":null,"permalink":"/terms/","section":"Mohammad Jashem — Senior Full-Stack Mobile Engineer","summary":"\u003cp\u003eThese terms govern your use of \u003cstrong\u003emjashem.com\u003c/strong\u003e. By accessing the site you agree to them. Any paid engagement is governed by a separate signed contract — these terms do not override that contract.\u003c/p\u003e","title":"Terms of Service","type":"page"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. Crewlix is an HR management app I built in Flutter and shipped to Google Play and the App Store: attendance, leave, employee profiles, and a social feed. This is the architecture write-up — not the marketing version, the engineering one.\nThe interesting thing about Crewlix isn\u0026rsquo;t any single screen. It\u0026rsquo;s that the architecture I chose for one HRM app turned out to generalize into a small platform. The same four Flutter packages now run a second production app. That didn\u0026rsquo;t happen by accident; it happened because the boundaries were drawn at the package level, not by convention. This post is about how those boundaries are drawn, what sits inside them, and the one feature — a feature-complete rich text editor in the social feed — where the real engineering lives.\nIf you want the product-level case study instead, it\u0026rsquo;s in the Crewlix product case study. This is the deep dive.\nThe package layout: feature slices, then extraction # Crewlix is feature-sliced Clean Architecture. Each domain is a slice, and every slice has the same four layers:\npresentation — widgets, pages, routing for the slice. application — Riverpod notifiers and providers; the use-case orchestration. domain — entities, value objects, and the business rules. Pure Dart, no Flutter. infrastructure — data sources, Retrofit API clients, DTOs, external integrations. Inside the app shell, the slices are folders: auth, time, leave, people, feed, card, alert, and the rest. Each one mirrors that four-layer shape. A change in leave can\u0026rsquo;t reach into feed because the slice\u0026rsquo;s public surface is its notifiers and models, nothing else.\nThen there\u0026rsquo;s extraction. Not every slice became a package — only four did: crewlix_core, crewlix_auth, crewlix_people, and crewlix_feed. The rule I used: a slice becomes a package when a second consumer needs it. Auth, the shared core (networking, theming, utilities, error handling), the people directory, and the feed all qualified. Time, leave, and the HR-specific slices stayed in the app shell, because they\u0026rsquo;re Crewlix\u0026rsquo;s business and no one else\u0026rsquo;s.\nThat distinction matters. Packages aren\u0026rsquo;t a vanity layering exercise; they\u0026rsquo;re the seams along which a second product can attach. Draw them at the wrong granularity and you either over-share (the second app inherits HR logic it doesn\u0026rsquo;t want) or under-share (you duplicate auth and networking in every app).\nOne platform, two apps # Here\u0026rsquo;s where the package decision paid off. A second production app — a different product, different domain — depends on crewlix_core, crewlix_auth, and crewlix_feed via path dependencies. It gets the same networking stack, the same auth flow, the same social-feed engine, including the rich text editor.\nThe dependency direction is enforced in the package graph, not in a CONTRIBUTING.md somewhere. crewlix_feed depends on crewlix_core. It cannot depend on crewlix_auth or on the app shell. If someone tries, the build fails. That\u0026rsquo;s the difference between an architecture that survives a year of new hires and one that quietly rots into a ball of mud.\nThe practical payoff: a fix to a networking bug in crewlix_core lands once and ships to both apps. The cost: a little more upfront plumbing, and the discipline to keep crewlix_core genuinely generic. The moment core grows a Crewlix-specific concept, the abstraction leaks and the second app pays for features it never asked for. Resisting that gravity is most of the ongoing work.\nState, routing, and models # Riverpod, with code generation. Providers are declared with annotations and generated by riverpod_generator, so they\u0026rsquo;re typed and the boilerplate is mechanical, not hand-written. Notifiers own the state for their slice; the UI watches a provider and rebuilds when the state changes. No setState sprawl, no global singletons.\nA leave-balance notifier looks, in shape, like this:\n@riverpod class LeaveBalanceNotifier extends _$LeaveBalanceNotifier { @override Future\u0026lt;LeaveBalance\u0026gt; build(String employeeId) =\u0026gt; ref.read(leaveRepositoryProvider).fetchBalance(employeeId); Future\u0026lt;void\u0026gt; apply(LeaveRequest request) async { state = const AsyncLoading(); state = await AsyncValue.guard( () =\u0026gt; ref.read(leaveRepositoryProvider).submit(request), ); } } AutoRoute for routing. Routes are declared once and code-generated, so navigation is compile-time safe. Pushing a route with the wrong argument type is a build error. For an HRM with deep links firing into arbitrary screens from notifications, that safety is worth more than it sounds.\nFreezed for models. Every data model is a Freezed class — immutable, with a generated copyWith, JSON serialization, and union support. A leave request that\u0026rsquo;s draft | submitted | approved | rejected becomes a sealed union; the compiler enforces that every consumer handles every state. That single pattern removes a surprising amount of bug surface.\n@freezed class LeaveRequest with _$LeaveRequest { const factory LeaveRequest.draft({ required String employeeId, required DateTime from, required DateTime to, }) = LeaveRequestDraft; const factory LeaveRequest.submitted({ required String id, required String employeeId, required String approverId, }) = LeaveRequestSubmitted; factory LeaveRequest.fromJson(Map\u0026lt;String, dynamic\u0026gt; json) =\u0026gt; _$LeaveRequestFromJson(json); } Dio + Retrofit for networking. API clients are interfaces annotated with Retrofit and generated into implementations on top of Dio. The interface is the contract; the generated client is plumbing. Serializers come from json_serializable on the Freezed models, so the whole vertical — DTO → model → notifier → UI — is typed end to end.\nThe rich text editor: making flutter_quill power a social feed # The social feed is where the engineering got real. Posts need bold, italic, headings, lists, inline @mentions, and embedded images — editable on a phone, with a toolbar, fast on long posts. Flutter doesn\u0026rsquo;t ship this. I built it on flutter_quill, whose document model is a Quill delta: a list of operations (insert \u0026quot;hello\u0026quot;, format bold, insert image). The delta is the source of truth inside the editor. Everything else is conversion.\nWhy a delta, and why that creates a pipeline # The server doesn\u0026rsquo;t store deltas. It stores posts as HTML (for rendering on the web) and Markdown (for portability). So the editor lives in delta-space and the server lives in HTML/Markdown-space, and every post crosses that boundary on the way in and out. That\u0026rsquo;s the pipeline:\nDelta → HTML: vsc_quill_delta_to_html walks the delta and emits HTML. HTML → Delta: flutter_quill_delta_from_html (the HtmlToDelta converter) parses stored HTML back into a delta when a post is opened for editing. Markdown interop: markdown_quill and html2md bridge Markdown in and out for import/export and for the markdown editor surface. The hard requirement is that the round-trip is lossless. A user edits a post, saves, opens it again, edits more, saves. If delta→HTML→delta drops a list nesting level or flattens a mention, the post silently corrupts on the second edit — and nobody notices until a user complains that their post \u0026ldquo;changed.\u0026rdquo; Getting that round-trip clean is most of the work, and it\u0026rsquo;s the part no package gives you for free.\nCustom embeds: mentions and images as first-class citizens # A mention isn\u0026rsquo;t text with an @ in front of it, and an inline image isn\u0026rsquo;t a URL in a paragraph. Both are embeds — distinct node types inside the delta, each with its own EmbedBuilder that knows how to render it, how to participate in selection, and how to move with the cursor. Because they\u0026rsquo;re embeds, not formatted text, you can\u0026rsquo;t accidentally place a cursor inside a mention or half-select an image. The editor\u0026rsquo;s behavior around them stays predictable.\nWriting those embed builders — and wiring them into both the Quill editor and the HTML converters so they survive the round-trip — is fiddly, tightly specified work. Get it wrong and mentions vanish on save; get it right and they feel native.\nPerformance: don\u0026rsquo;t reconvert on every keystroke # As a document grows, the naive approach — recompute the HTML-to-delta transform whenever the editor needs it — starts to stutter on long posts. The fix is caching: an HTML-to-delta cache keeps the expensive parse off the hot path, so typing stays responsive even on a post that\u0026rsquo;s been edited for twenty minutes. None of this is glamorous; it\u0026rsquo;s the difference between an editor that feels good and one that lags behind your thumb.\nThe shape of the conversion, simplified:\nString deltaToHtml(Document document) { final delta = document.toDelta().toJson(); final converter = QuillDeltaToHTMLConverter( delta, ConverterOptions( multiLineParagraph: true, customEmbedBuilders: [mentionEmbedBuilder, imageEmbedBuilder], ), ); return converter.convert(); } I\u0026rsquo;ll be blunt about scope: a feature-complete rich text editor in Flutter is genuinely hard, even starting from Quill. The packages get you a working editor in a day. The lossless delta↔HTML round-trip, the custom embeds, and the long-post performance take weeks. If you\u0026rsquo;re scoping one, budget for it — it is not a \u0026ldquo;drop in a widget\u0026rdquo; task.\nWhat I\u0026rsquo;d do differently # Two things, honestly.\nFirst, I\u0026rsquo;d extract the packages even later than I did. There\u0026rsquo;s a temptation, once you\u0026rsquo;ve decided on Clean Architecture, to extract everything into packages on day one. That\u0026rsquo;s premature. You don\u0026rsquo;t know which seams a second consumer will actually need until there is a second consumer. I\u0026rsquo;d keep slices in the app shell until a concrete second product forces the extraction — then the boundaries fall in the right place because the pressure is real, not theoretical.\nSecond, I\u0026rsquo;d push more of the delta↔HTML round-trip into a test harness from the start. The corruption failures are silent and only show up after a save/open cycle. A property test that round-trips a corpus of deltas through HTML and back, asserting equality, would have caught regressions I instead found by hand.\nOutcome # Crewlix is live on Google Play and the App Store. The architecture has held as modules kept getting added, and the same package set now runs a second production app — which is the real test of whether the boundaries were drawn correctly.\nIf you want the product case study, it\u0026rsquo;s in the Crewlix product case study. If you want to see how this same package platform behaves at the seven-year horizon, the long-term maintenance write-up covers that. And if you\u0026rsquo;re choosing between Flutter, React Native, and Expo for an app of this shape, the head-to-head comparison is where I lay out the trade-off Crewlix loaded on.\nWant this architecture in your app? # The decisions in this piece — feature-sliced Clean Architecture, package extraction at the right boundary, and the rich-text-editor pipeline — are the decisions I make on every Flutter build I take on. If you\u0026rsquo;re scoping a Flutter architecture review, planning an HRM or social-feed app, or weighing whether your slice boundaries will hold under a second product, get in touch. I do this work.\nFrequently Asked Questions # What is Flutter clean architecture for an HRM app? Feature-sliced Clean Architecture: each HRM domain — auth, time, leave, people, feed — is a slice with presentation, application, domain, and infrastructure layers, with dependencies enforced at the package graph. A change in leave can\u0026rsquo;t reach into feed because the slice\u0026rsquo;s public surface is its notifiers and models, nothing else.\nWhen should I extract Flutter packages from a feature slice? When a second consumer needs the slice. Crewlix extracted crewlix_core, crewlix_auth, crewlix_people, and crewlix_feed only after a second production app depended on them. Extracting earlier is premature — you don\u0026rsquo;t know which seams a second consumer will need until there is one. The right boundary falls out of real pressure, not prediction.\nHow do you handle a rich text editor in Flutter? Build on flutter_quill and accept that the editor lives in Quill delta-space while the server lives in HTML/Markdown. A delta↔HTML↔Markdown pipeline (vsc_quill_delta_to_html, flutter_quill_delta_from_html, markdown_quill, html2md) handles the boundary, with custom EmbedBuilders for mentions and image embeds, and an HTML-to-delta cache to keep long-post editing responsive.\n","date":"17 July 2026","externalUrl":null,"permalink":"/insights/building-crewlix-flutter-clean-architecture/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. \u003ca\n  href=\"/projects/crewlix-hr-app/\"\u003eCrewlix\u003c/a\u003e is an HR management app I built in Flutter and shipped to Google Play and the App Store: attendance, leave, employee profiles, and a social feed. This is the architecture write-up — not the marketing version, the engineering one.\u003c/p\u003e","title":"Crewlix Flutter Clean Architecture: HR App Deep Dive","type":"insights"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/feature-sliced/","section":"Tags","summary":"","title":"Feature-Sliced","type":"tags"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/flutter-quill/","section":"Tags","summary":"","title":"Flutter-Quill","type":"tags"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/rich-text-editor/","section":"Tags","summary":"","title":"Rich-Text-Editor","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"Ai","type":"tags"},{"content":"Review latency is the quietest killer of release cadence on a long-running app. The build ships in minutes, the pipeline promotes in minutes, and then a PR sits for a day and a half waiting for a human to look at it. I\u0026rsquo;m a senior mobile architect — I\u0026rsquo;ve maintained the NCC App for seven-plus years across three stores — and for a long stretch the bottleneck on my own pipeline was me. Adding AI code review to CI/CD is the single change that collapsed that latency. This is the write-up on how it\u0026rsquo;s wired, what it catches, what it misses, and why a human still signs off on every merge.\nIf you want the broader workflow — agentic generation, context engineering, where review fits — that\u0026rsquo;s how AI-assisted delivery works. This article is the deep dive on the review half.\nThe problem # Here\u0026rsquo;s the review loop I used to run, and the one I still see on most mobile teams. A developer opens a PR. They ping a reviewer. The reviewer is mid-task on something else, so the PR waits. Hours pass — sometimes a full day on a distributed team where the senior and the author are in opposite time zones. The reviewer comes back, leaves comments, the author addresses them, re-pings, and the cycle repeats. On the NCC pipeline I watched three-day review cycles land on changes that were, in the end, trivial.\nThe cost isn\u0026rsquo;t just the wall-clock delay — it\u0026rsquo;s the context switch. Every time a senior breaks flow to review a PR, they pay a tax on the work they were doing. And by the time feedback reaches an author who\u0026rsquo;s already moved on, the fix happens against a cold mental model, which is where subtle regressions creep in.\nReview latency compounds with release cadence. If you ship once a sprint and review takes three days, you\u0026rsquo;ve lost 30% of the sprint to queue. If you ship continuously, three days of review latency doesn\u0026rsquo;t just slow you down — it makes continuous shipping impossible, because the pipeline runs faster than the gate in front of it. The bottleneck moves, and on a mature mobile app the bottleneck is almost never the build. It\u0026rsquo;s the human reviewer\u0026rsquo;s calendar.\nWhere AI review fits in the pipeline # I run AI code review as a pipeline stage, not as a chat bot. Concretely: on PR open and on every push to an open PR, the CI fires a reviewer step. On my pipeline that step lives in Bitbucket Pipelines — separate from the Drone pipeline that builds and deploys. It runs Claude Code through a z.ai API endpoint, pulls the diff, and writes the review back to the pull request.\nThere\u0026rsquo;s a reason it\u0026rsquo;s its own stage and not folded into the build pipeline. The build pipeline answers \u0026ldquo;does this compile, sign, and ship.\u0026rdquo; The review stage answers \u0026ldquo;is this the right change.\u0026rdquo; They run on different triggers (PR versus merge), at different cadences, and they fail differently. Mixing them into one pipeline couples a judgment call to a deploy, which is the last coupling you want.\nWhat the step does, mechanically: it pulls the diff for the change, gathers the context the reviewer needs (the touched files, the surrounding code, the conventions I\u0026rsquo;ve codified for the project, the platform contracts that matter), hands that to the AI reviewer, and writes the output back to the PR. The output is inline comments on specific lines — not a single blob of prose at the bottom. A comment on line 42 means \u0026ldquo;fix this on line 42,\u0026rdquo; not \u0026ldquo;I have thoughts.\u0026rdquo;\nThe reporting surface matters. The review itself is the inline PR comments — anchored to specific diff lines, the same way a human reviewer would write them. The Google Chat notifications my team sees come from the build-and-deploy pipeline (Drone), not from the review step: they tell us a build started, passed, failed, or published. The review\u0026rsquo;s output lives on the PR, where the author and the reviewer actually act on it. Two channels, two jobs.\nThe integration shape is intentionally boring. It\u0026rsquo;s a CI step. It reads the diff. It writes comments. It doesn\u0026rsquo;t merge, push, or rewrite the branch. The blast radius of a misbehaving reviewer step is one bad comment on a PR, which a human can dismiss in two clicks. That\u0026rsquo;s a property I care about more than the choice of model behind it.\nWhat it catches well # The AI reviewer is good at pattern-matching, and most of what crosses a senior reviewer\u0026rsquo;s desk on a mobile app is patterns. Concretely, here\u0026rsquo;s what it reliably catches on my Flutter and React Native pipelines:\nMissing dispose calls and lifecycle leaks. A TextEditingController, a ScrollController, a StreamSubscription, a Riverpod ProviderContainer, a React useEffect that returns no cleanup — these are the leaks that don\u0026rsquo;t crash on the first run and show up as a memory report weeks later. The AI flags the missing teardown on the diff that introduces it, before it ever reaches a device.\nState management misuse. A Flutter widget reaching into a provider that belongs to a different feature boundary. A React Native component calling setState inside a render. A Riverpod ref.watch used where ref.read was correct, or vice versa. These are the bugs that pass a smoke test and fail in production when the user navigates a specific way.\nPlatform-channel mistakes. On Flutter, a MethodChannel call with no error path on the native side — a Swift handler that doesn\u0026rsquo;t nil-check the arguments, a Kotlin handler that swallows the exception and returns null, hiding a real failure from the Dart layer. On React Native, the same shape across the bridge — a native module that swallows an error and resolves the JS promise with null. The AI reads both sides of the channel in the same pass and flags the contract mismatch.\nArchitecture drift. The diff that reintroduces a Flutter platform view into a codebase I migrated specifically to remove platform views. The diff that adds a network call directly in a widget instead of going through the repository layer. The diff that bypasses the auth context. These are the ones that cost me the most review time as a human, because they require reading the change against the whole architecture in my head. The AI does that comparison consistently, on every PR, without getting tired.\nMissing coverage on the load-bearing paths. A PR that touches auth, payments, or the API layer and adds no test or manual-verification note. The AI doesn\u0026rsquo;t enforce 100% coverage — I don\u0026rsquo;t want that. It enforces \u0026ldquo;if you touch this path, you show how you verified it,\u0026rdquo; which is the rule I actually run manually.\nStyle and convention conformance, plus the obvious correctness bugs. Null handling, off-by-ones, unhandled error paths, the wrong comparison operator. The stuff a senior reviewer flags in thirty seconds — the AI flags it in thirty seconds too, and the author fixes it before the senior ever sees it.\nWhat it misses # The honest list, because the hype version of AI review skips this part.\nIt misses subtle race conditions. A ChangeNotifier that fires during a build frame because of an ordering quirk between two listeners. A React state update that races with a navigation event. These don\u0026rsquo;t show up in a diff read — they show up on a device, under a specific timing, and the AI has no device to run the code on.\nIt misses real-device-only failures. The Mali GPU shader jank. The Samsung A-series ANR that only appears on a specific driver version. The iOS-specific crash that only happens on a real device with a real keychain, never on the simulator. This is the category of bug I\u0026rsquo;ve spent most of my career on — I migrated Kahf Kids off Flutter over a platform-view ANR exactly like this — and it is fundamentally not something a code-reading agent can catch. It requires the device matrix.\nIt misses product-judgment calls. Whether a feature matches the product contract a student at NCC actually needs. Whether a parental-control boundary holds against a child who really wants past it. Whether a UX flow is the right one for the audience. The AI can tell you the code does what it says. It can\u0026rsquo;t tell you whether what it says is the right thing to build.\nIt misses performance on the actual device matrix. A render path that looks fine in the diff and drops frames on a five-year-old Android handset with a slow GPU. The AI reads the code; it doesn\u0026rsquo;t measure the frames.\nThis is why the senior pass is non-optional. The AI moves the obvious stuff out of the queue. The interesting stuff — the race conditions, the device-specific failures, the judgment calls — still lands on a human who has shipped this class of bug before and knows the names of the failure modes.\nHuman-in-the-loop # The workflow I actually run: the AI reviews the PR first, the author addresses what it flags, and only then does it reach me. By the time a PR hits my queue, the obvious mistakes are already fixed. What I\u0026rsquo;m reviewing is the diff that survived the AI pass — which means I\u0026rsquo;m spending my senior hours on architecture, on contract judgment, and on the subtle stuff the AI can\u0026rsquo;t see. I\u0026rsquo;m not reviewing typos.\nThe AI does not merge. That\u0026rsquo;s the line I will not move. The reviewer step can comment, it can flag, it can block a merge if I configure a required approval — but the merge decision, the release decision, and the judgment call on whether this change is safe to ship to three stores is mine. On NCC I sign off on every merge to main. On Crewlix, same. The AI collapses the latency of the first review pass; it does not collapse the responsibility of the final one.\nThis is the part that matters most for quality. If you let the AI merge, you\u0026rsquo;ve optimized for speed at the cost of judgment, and on a long-running app that bill comes due in production. The senior-in-the-loop is what keeps the quality floor high while the latency drops.\nThe result # On the NCC and Crewlix pipelines, review time went from days to effectively instant for the first pass. A diff lands, and within a minute or two the author has structured feedback they can act on. They don\u0026rsquo;t wait on me for the obvious stuff. They wait on me for the architecture and the judgment, and by the time I see the PR it\u0026rsquo;s already been through one competent review.\nThe honest framing: faster, not perfect. The AI catches more before a human looks, which means the human looks at higher-value things. It does not replace the human — the race conditions, the device matrix, and the product judgment still require a senior who\u0026rsquo;s shipped this class of app. What it replaces is the queue, and the queue is where most of the latency lived.\nIf you want the complete picture — how this stage sits alongside the build, signing, store upload, and tag-based distribution steps — that\u0026rsquo;s the complete mobile CI/CD stack. The AI review step is one stage in a longer pipeline, and it earns its place by being the stage that used to be a human\u0026rsquo;s calendar.\nSeven-plus years on NCC, three stores, zero major production incidents, and reviews that land in minutes. Removing the review queue got me there — and the AI is what made that possible.\nFrequently Asked Questions # How do you add AI code review to a CI/CD pipeline? Run it as its own pipeline stage that fires on PR open and on every push to an open PR. It pulls the diff, hands it to an AI reviewer (I run Claude Code through a z.ai endpoint), and writes inline comments anchored to specific lines — not a blob of prose. It reviews; it never merges.\nWhat does AI code review catch? Pattern-level issues a senior would flag on a read of the diff: missing dispose and lifecycle leaks, state-management misuse, platform-channel contract mistakes, architecture drift, missing verification on load-bearing paths, and obvious correctness bugs like null-handling and off-by-ones.\nWhat does AI code review miss? Anything that only shows up on a real device or in production: subtle race conditions, real-device-only failures (Mali GPU jank, driver-specific ANRs, keychain crashes), product-judgment calls, and frame drops on slow hardware. That\u0026rsquo;s why a senior still signs off on every merge.\nShould AI code review auto-merge PRs? No. Let it comment, flag, and optionally block — but keep the merge and release decision human. AI collapses the latency of the first review pass; it does not collapse the responsibility of the final one.\nWant this in your pipeline? # This is the same review-stage pattern I wire into client pipelines — an AI reviewer that runs on every PR, writes inline comments anchored to specific lines, and never auto-merges. If you want it in front of your team instead of waiting on a senior\u0026rsquo;s calendar, look at what I offer or start a conversation.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/ai-code-review-in-ci-cd/","section":"Insights","summary":"\u003cp\u003eReview latency is the quietest killer of release cadence on a long-running app. The build ships in minutes, the pipeline promotes in minutes, and then a PR sits for a day and a half waiting for a human to look at it. I\u0026rsquo;m a senior mobile architect — I\u0026rsquo;ve maintained the \u003ca\n  href=\"/projects/ncc-mobile-app/\"\u003eNCC App\u003c/a\u003e for seven-plus years across three stores — and for a long stretch the bottleneck on my own pipeline was me. Adding AI code review to CI/CD is the single change that collapsed that latency. This is the write-up on how it\u0026rsquo;s wired, what it catches, what it misses, and why a human still signs off on every merge.\u003c/p\u003e","title":"AI Code Review in CI/CD: From Slow Reviews to Instant","type":"insights"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect with 7+ years shipping Flutter, React Native, and Expo in production. I delivered the redesigned Kahf Kids React Native/Expo app in three months, 100% AI-assisted, and I run AI code review on every merge in my CI/CD pipelines. So when founders ask me what \u0026ldquo;AI-assisted mobile development\u0026rdquo; means in practice, my answer isn\u0026rsquo;t a hype pitch. It\u0026rsquo;s a workflow description.\nThis is the article I wish someone had handed me before I spent six months figuring out the difference between \u0026ldquo;AI writes my app\u0026rdquo; (a myth) and \u0026ldquo;AI does the typing, I do the engineering judgment\u0026rdquo; (the actual workflow). If you\u0026rsquo;re evaluating whether to work with an AI-native mobile architect, read this first.\nWhat \u0026ldquo;AI-assisted delivery\u0026rdquo; really means # The phrase \u0026ldquo;AI-assisted development\u0026rdquo; gets used for three different things, and conflating them is where most of the disappointment lives.\nAutocomplete on steroids. Type a line, get the next line suggested. Useful, but it\u0026rsquo;s maybe a 10–15% productivity bump on a good day. This is what most developers mean when they say they\u0026rsquo;re \u0026ldquo;using AI,\u0026rdquo; and it\u0026rsquo;s not what I\u0026rsquo;m talking about.\nVibe coding. Prompt the AI to \u0026ldquo;build me a login screen,\u0026rdquo; paste whatever comes back, ship it. This works for throwaway prototypes and breaks the moment the code has to satisfy a platform contract, a performance budget, or a security review. It\u0026rsquo;s the workflow that produces the liability-flavored output executives are correctly worried about.\nAgentic delivery with context engineering. This is the actual workflow. A human architect decomposes the system into unambiguous tasks, loads each task with the relevant architecture and constraints, and delegates generation to an AI agent while owning judgment — review, validation, integration, release quality. The AI writes code against a spec; the architect reviews every diff against the platform contracts and failure modes they already know.\nThe leap from autocomplete to agentic delivery isn\u0026rsquo;t a model upgrade. It\u0026rsquo;s a process change. The model was already capable of generating most of the code in well-shipped 2025 apps. What was missing was the discipline of feeding it the right context, decomposing the work into tasks small enough to be unambiguous, and reviewing the output like a senior engineer.\nWhen people say \u0026ldquo;AI doesn\u0026rsquo;t work for production mobile,\u0026rdquo; they\u0026rsquo;re almost always describing vibe coding. They\u0026rsquo;re right that vibe coding doesn\u0026rsquo;t work. They\u0026rsquo;re wrong that it\u0026rsquo;s the only option.\nThe Kahf Kids proof # The cleanest proof point I have is the Kahf Kids migration — a Flutter app I moved to React Native/Expo after platform-view ANRs on Mali and PowerVR GPUs made the parental-control video flow unreliable on low-end Android hardware. I delivered the redesigned production version in three months, 100% AI-assisted.\nHere\u0026rsquo;s the specific division of labor, because \u0026ldquo;AI-assisted\u0026rdquo; is meaningless without it.\nWhat the AI did: generated the React Native components, hooks, and screen wiring from task specs I wrote. Wrote the TypeScript types from interface descriptions. Filled in the repetitive boilerplate — Expo Router route registrations, config plugin scaffolding, EAS Build configuration. Produced first-draft implementations of native bridge glue code from documented API contracts. Ran refactor passes when I asked it to extract shared logic or rename across a module.\nWhat I did: product design, mobile architecture, and the platform-contract decisions that determine whether a parental-control app survives store review. I wrote the GitHub and GitLab CI workflows. I did the context engineering — decomposing the system into tasks, writing the prompts, loading the constraints. I reviewed every diff before it merged, checking it against the architecture and the failure modes I\u0026rsquo;d already shipped into production and patched. I drove every debugging session, because when an AccessibilityService blocks the wrong package on a Samsung A-series handset, the AI doesn\u0026rsquo;t know which API is lying — I do.\nThe three-month timeline isn\u0026rsquo;t a magic trick. It\u0026rsquo;s the direct result of the generation/judgment loop being tight. The AI compressed the time-to-first-draft on every task from hours to minutes. My review time per task stayed roughly constant — but I was reviewing more drafts in a week than I would have hand-written in a month. Same senior hours, more shipped code.\nIf you want the full engineering breakdown of the migration itself — the ANR signatures, the device matrix, the parental-control architecture on both platforms — it\u0026rsquo;s in the Kahf Kids case study.\nContext engineering # This is the actual skill behind \u0026ldquo;AI-assisted delivery,\u0026rdquo; and almost nobody talks about it because it\u0026rsquo;s harder to brag about than model selection.\nA bad prompt says: \u0026ldquo;Build the parental-control video player.\u0026rdquo; The output will be plausible, generic, and wrong — it\u0026rsquo;ll miss the security boundary, the WebView surface handling, and every constraint that makes the feature actually work.\nA context-engineered task says something closer to: \u0026ldquo;Implement the video playback surface capture for the parental-control player. The feature must (1) render the YouTube embed inside a WebView, (2) capture the playback surface, (3) block every exit path — no outbound links, no \u0026lsquo;open in YouTube,\u0026rsquo; no gesture that escapes the app. Constraints: a child cannot reach the open web from the player. The contract must hold on a five-year-old phone with a slow GPU. Use [these] native bridges. Don\u0026rsquo;t introduce a Flutter-style platform view; the whole reason we\u0026rsquo;re on RN is to remove that surface. Here\u0026rsquo;s the existing navigation structure.\u0026rdquo;\nThe difference is night and day. The first prompt produces code I\u0026rsquo;d reject in review. The second produces code I\u0026rsquo;d accept with minor edits.\nThe mechanics of context engineering, as I actually practice them:\nDecompose until unambiguous. If a task has more than one reasonable interpretation, split it. Vagueness at the spec stage becomes rework at the review stage. Load the architecture. The agent needs the system context — what\u0026rsquo;s load-bearing, what\u0026rsquo;s replaceable, where the contracts live. I pull the relevant files and constraints into the task context, not the agent\u0026rsquo;s general memory. Specify the failure modes you already know. If you\u0026rsquo;ve shipped this kind of feature before and know three ways it breaks, tell the agent. It won\u0026rsquo;t repeat the failure, and it often produces defensive code you wouldn\u0026rsquo;t have bothered to write by hand. Prompt against constraints, not vibes. \u0026ldquo;Make it fast\u0026rdquo; is vibes. \u0026ldquo;Render path must stay under 8ms on a MediaTek Helio G85\u0026rdquo; is a constraint. Constraints produce reviewable code. Review the diff, not the prose. The agent\u0026rsquo;s explanation of its own code is marketing. Read the actual change. The last point is the one most teams skip. They read the AI\u0026rsquo;s summary, nod, and merge. That\u0026rsquo;s how liabilities get shipped. The summary is not the code.\nForcing yourself to write context-engineered task specs has a second-order benefit I didn\u0026rsquo;t expect: it surfaces your own architectural vagueness. If you can\u0026rsquo;t write an unambiguous spec for a feature, you don\u0026rsquo;t actually understand the feature yet. The discipline makes you a better architect, independent of the AI.\nAI code review in CI/CD # Generation is half the workflow. Review is the other half, and it\u0026rsquo;s where I see teams lose the most time.\nThe traditional review loop: a developer opens a PR, pings a reviewer, waits hours or days for a human to get back to them, addresses the comments, re-pings, merges. On a small team the senior architect is the bottleneck — every PR queues on their calendar. On a distributed team the latency compounds across time zones. I\u0026rsquo;ve watched three-day review cycles on changes that were trivial.\nI run AI code review on every merge in CI/CD. Every PR gets an automated first-pass review against the codebase\u0026rsquo;s conventions, the platform contracts, and the architectural patterns I\u0026rsquo;ve codified — before a human ever looks at it. For the implementation details, read how I added AI code review to CI/CD for instant reviews. The headline result: review time went from days to instant for the first pass, and my human review time concentrated on the small fraction of changes that actually warrant senior judgment.\nWhat this changes in practice: juniors get immediate feedback on style and obvious mistakes, so by the time a PR reaches me it\u0026rsquo;s already been through one competent review. I stop reviewing typos and start reviewing architecture. The AI catches the diff that reintroduces a platform view into a codebase I migrated specifically to remove platform views. It catches the missing error path. It catches the test that asserts on the wrong thing. It does this in seconds, on every change, without getting tired or context-switching.\nThe honest caveat: AI code review is a force multiplier on a senior engineer, not a replacement for one. The agent flags the diffs that match patterns it has been trained on. It doesn\u0026rsquo;t have the judgment to know that this ANR signature, on this device matrix, is the one that forces a migration. That call is still mine. But for the 80% of review work that\u0026rsquo;s pattern-matching — does this conform, does this match the contracts, did you handle the obvious edge cases — automating it is the single highest-leverage change I\u0026rsquo;ve made to my pipeline in the last two years.\nWhat stays human # I want to be precise about this, because the hype version of \u0026ldquo;AI-assisted\u0026rdquo; implies the engineer is becoming optional. They\u0026rsquo;re not. They\u0026rsquo;re becoming more senior-leveraged.\nArchitecture decisions. Which framework, which state layer, which navigation primitive, which native API — these are platform-contract decisions, and the AI doesn\u0026rsquo;t have the scar tissue to make them. I do, because I\u0026rsquo;ve shipped the wrong ones and patched the fallout. The framework call on Kahf Kids came out of a year and a half of production vitals I\u0026rsquo;d personally read; no agent infers that.\nPlatform-contract judgment. Whether a parental-control implementation survives Google Play\u0026rsquo;s accessibility policy review. Whether a Screen Time integration passes App Review. Whether a WebView-heavy flow will ANR on Mali. These are not generic engineering problems — they\u0026rsquo;re contract-specific, and the contracts are partially undocumented, partially closed-source, and partially enforced at review time by humans at Apple and Google. The AI can read the docs. It cannot read the room.\nValidation and release quality. The AI can generate tests. It cannot tell you whether those tests assert on the things that actually matter for your users. On a kids\u0026rsquo; app, the test that matters is \u0026ldquo;a child cannot reach the open web from the player.\u0026rdquo; That\u0026rsquo;s a product contract, not a unit test, and owning it is a human responsibility.\nDebugging the failure modes you already know. When the build fails on a specific Samsung handset because of a GPU driver quirk, the AI will suggest generic fixes. The fix that actually works is usually the one tied to the specific device, the specific driver version, and the specific surface — and that lives in the architect\u0026rsquo;s head, earned through prior production incidents.\nThe typing is delegated; the engineering judgment isn\u0026rsquo;t. If you take one sentence from this article, take that one.\nWhat it means for clients # For founders and engineering leaders, the practical translation:\nFaster timelines. The Kahf Kids three-month delivery would have taken six to nine months on a conventional workflow. The compression is real and it\u0026rsquo;s repeatable — within the scope of work where the architecture is clear enough to spec.\nLower cost for the same or higher quality. Generation time collapses; senior review time stays constant. That means you\u0026rsquo;re buying more shipped code per senior-hour, not less. On a fixed-scope engagement, that shows up as either a lower price or a tighter timeline. On a retainer, it shows up as more shipped value per cycle.\nHigher quality floor. Every merge is reviewed before ship — not \u0026ldquo;best-effort reviewed when the senior has time.\u0026rdquo; That raises the floor on what reaches production. The bugs that survive are the interesting ones, not the obvious ones.\nThe honest caveats, because this is the section where hype lives:\nAI output without review is a liability. Vibe-coded production code is the new technical debt — it looks like working software, passes smoke tests, and fails the first time a real user hits an edge case the model didn\u0026rsquo;t infer. There is no version of \u0026ldquo;AI-assisted delivery\u0026rdquo; that works without a senior engineer reviewing every diff. If an engagement is priced as if review isn\u0026rsquo;t happening, it isn\u0026rsquo;t happening.\nThis workflow assumes a senior in the loop. The three-month timeline worked because I\u0026rsquo;ve shipped this class of app before and knew the failure modes by name. Hand the same AI tools to a team without that experience and you get the speed without the judgment — which is the worst combination, because you ship bugs faster.\nNot every problem is equally amenable. Greenfield features with clear specs — excellent. Brownfield refactors with documented constraints — excellent. Novel platform integrations with no prior art — the AI can help, but the timeline compression narrows, because the bottleneck becomes the architect\u0026rsquo;s judgment, not code generation.\nIf you\u0026rsquo;re evaluating an engagement and the vendor can\u0026rsquo;t clearly articulate the generation/judgment split — what the AI does, what the human does, how review works — that\u0026rsquo;s a signal. The workflow is the deliverable, not the model.\nConclusion # AI-assisted mobile development, done properly, is a senior-leverage workflow: the AI compresses generation time, the architect owns judgment, and CI/CD closes the review loop on every merge. The Kahf Kids three-month delivery is the proof it works at production scale. The hard-learned caveats are the proof it isn\u0026rsquo;t magic.\nIf you want to bring this workflow to your next mobile engagement — greenfield, migration, or rescue — that\u0026rsquo;s exactly what I do. My AI-Augmented delivery service is built around this loop, and a conversation is the cheapest way to find out if your project fits it.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/ai-assisted-mobile-development/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect with 7+ years shipping Flutter, React Native, and Expo in production. I delivered the redesigned \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003eKahf Kids\u003c/a\u003e React Native/Expo app in three months, 100% AI-assisted, and I run AI code review on every merge in my CI/CD pipelines. So when founders ask me what \u0026ldquo;AI-assisted mobile development\u0026rdquo; means in practice, my answer isn\u0026rsquo;t a hype pitch. It\u0026rsquo;s a workflow description.\u003c/p\u003e","title":"AI-Assisted Mobile Development — How It Actually Works","type":"insights"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/code-review/","section":"Tags","summary":"","title":"Code-Review","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/comparison/","section":"Tags","summary":"","title":"Comparison","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/developer-tools/","section":"Tags","summary":"","title":"Developer-Tools","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/fastlane/","section":"Tags","summary":"","title":"Fastlane","type":"tags"},{"content":"This is the investigation I wish someone had handed me before I spent six weeks patching a Flutter platform-view ANR I couldn\u0026rsquo;t fix in app code. It\u0026rsquo;s the engineering detail behind our full comparison of Flutter vs React Native vs Expo and the failure mode that ended in migrating Kahf Kids off Flutter. Dev forums are full of \u0026ldquo;Flutter WebView ANR on Redmi Note\u0026rdquo; threads with no real answers — most blame the WebView, tell you to enable hybrid composition, or say clear the cache. None of that fixes this.\nI\u0026rsquo;m Mohammad Jashem, a Senior Mobile Architect. The trace below is real (redacted), the device matrix is real, and the decision it forced is documented in the Kahf Kids Flutter deep dive.\nThe symptom # The first signal was Play Console vitals. Foreground ANR rate ticking up on a narrow slice of devices, roughly eighteen months after the Kahf Kids Flutter build shipped. Not a crash — an ANR. The app froze long enough for Android\u0026rsquo;s watchdog to offer \u0026ldquo;close app.\u0026rdquo; For a parental-control product whose promise is \u0026ldquo;your child cannot get stuck or escape,\u0026rdquo; a frozen player is a broken product, not a regression.\nThe cluster was specific and reproducible:\nSamsung Galaxy A12, A10s, A03s — Mali-G52 MC2 / Mali-G57 MC2, Android 10–12, driver r32p1–r36p1. Xiaomi Redmi Note 10/11/8 (2021) — Mali variants on budget SKUs failed the same way. Realme C25s, C31, C33 — PowerVR GE8320, Android 11–12. Android TV boxes with Mali-450/470 — same failure mode, but on TV it wasn\u0026rsquo;t degraded, it was unusable. D-pad focus traversal compounds it because focus events also touch the platform view. A typical ANR trace from a Redmi Note 11 (Mali-G52 MC2, Android 11), redacted:\n\u0026#34;main\u0026#34; prio=5 tid=1 Native | sysTid=23417 nice=-10 cgrp=default sched=0/0 native: #00 pc 00000000000f9c34 /system/lib64/libc.so (__ioctl+4) #01 pc 000000000008e4ec /system/lib64/libc.so (ioctl+44) #02 pc 000000000005a134 /system/lib64/libEGL.so (eglWaitGL+88) #03 pc 000000000006cc2c /system/lib64/libflutter_engine.so #04 pc 00000000004d1d10 /libflutter_engine.so (FlutterPlatformView::OnDisplay+64) \u0026#34;FlutterRasterThread\u0026#34; prio=5 tid=3 Native | sysTid=23429 native: #00 pc 00000000000f5b90 /system/lib64/libc.so (syscall+16) #01 pc 0000000000063e8c /vendor/lib64/egl/libGLES_mali.so (MaliglesWaitFence+120) \u0026#34;1.main\u0026#34; Blocked — waiting on \u0026lt;0x0dc1c7d8\u0026gt; (android.view.HardwareRenderer) Read that trace carefully. The main thread is in eglWaitGL, parked inside libflutter_engine.so, on a fence the Mali driver (MaliglesWaitFence) isn\u0026rsquo;t clearing. The Raster thread is parked in the same call. The main thread then blocks on the HardwareRenderer lock the Raster thread holds. Five seconds and ActivityManagerService fires the ANR. No Java from your app on that stack. No Dart. No fix you can ship in pubspec.yaml.\nThe setup # Why platform views were in the hot path at all. Kahf Kids is a curated kids\u0026rsquo; video app — the core flow is a child watching a video, and the product contract is that the child cannot leave that flow. No \u0026ldquo;open in YouTube,\u0026rdquo; no outbound link, no escape gesture.\nThat contract is enforced by a custom Flutter player layer rendered on top of a YouTube WebView embed. The WebView plays the video; the Flutter overlay captures the surface, intercepts navigation intents, and blocks the exit paths. A child pressing Back or swiping up is caught by the overlay, not the WebView.\nThat architecture puts a platform view in the hot path by definition: an Android WebView is an android.view.View, and to show one inside a Flutter widget tree, Flutter must compose it into its own Skia/Impeller surface. Every WebView, map, camera preview, and AR session in Flutter pays this tax — and when the overlay above the WebView is itself Flutter-rendered, both sides of the boundary are under sustained GPU load simultaneously. That\u0026rsquo;s the worst-case shape for the failure mode below.\nRoot cause # Flutter platform views, on Android, have historically shipped in two composition modes:\nVirtual Display (legacy default, pre-v1.22). The platform view renders to an off-screen Surface, composited as a texture inside Flutter\u0026rsquo;s render tree. Pro: full GPU isolation, no surface contention. Con: touch input at native coordinates is wrong, accessibility and IME are broken, text selection is wrong. It\u0026rsquo;s a video of a View, not a View. Hybrid Composition (v1.22+, new default). Flutter asks the Android framework to composite the real native View into the same Surface Flutter is rendering to. Touch, accessibility, IME, and text all work because the real View is on stage. The cost: Flutter\u0026rsquo;s Raster thread and the platform view\u0026rsquo;s render path must synchronize on a shared GPU context. This is the path that matters for ANRs. Texture Layer Hybrid Composition (the newer mitigation). A middle ground — the platform view is composited via a TextureLayer rather than full HC, reducing (not removing) the synchronization surface. In Hybrid Composition, the handshake looks like this: Flutter\u0026rsquo;s Raster thread acquires a GPU context lock to render its own frame; the Android HardwareRenderer for the platform view also needs the GPU; the two threads agree on a fence the GPU driver must signal before either proceeds. On well-behaved drivers (Adreno on a Pixel, Apple\u0026rsquo;s GPU family) the fence clears in microseconds. On certain Mali and PowerVR driver builds — specifically the older midgard/bifrost Mali userspace blobs on the budget SoCs above, and the Rogue-series PowerVR GE8320 blobs — the fence stall is unbounded under load. The Raster thread holds its lock waiting on eglWaitGL. The main thread blocks on the HardwareRenderer lock the Raster thread holds. Five seconds. ANR.\nI want to be precise because \u0026ldquo;Flutter WebView ANR\u0026rdquo; is underspecified in every forum thread I read. This is not jank. Jank is dropped frames and a live UI. This is a context-lock: the GPU fence between Flutter\u0026rsquo;s Raster thread and the platform view\u0026rsquo;s render thread fails to signal, both threads stall, and the main thread transitively blocks on a lock held by one of them. The app is alive in memory; it cannot paint or process input — which is why am kill doesn\u0026rsquo;t always recover it cleanly and why users reboot the device.\nWhy the cheap GPUs specifically? Not because Mali and PowerVR are bad — they power hundreds of millions of functional devices. Because the driver builds the OEM shipped on these budget SKUs were old, had known fence-signaling bugs in their userspace EGL, and were never going to receive an OTA. The device is stuck on a 2019-era libGLES_mali.so for life. You cannot recompile that blob. That\u0026rsquo;s the wall.\nReproducing and debugging # The forum threads that go unsolved usually die at this step. \u0026ldquo;Can\u0026rsquo;t reproduce on my device\u0026rdquo; is the standing reply, because the engineer trying to help has a Pixel, not a Redmi Note 11. The first thing I did was stop trusting emulators and flagship devices entirely.\nReproduction rig:\nReal hardware only. A Samsung A12 (Mali-G52), a Redmi Note 11 (Mali-G57 variant), a Realme C25s (PowerVR GE8320). Bought used, plus a Pixel 4a as the \u0026ldquo;well-behaved driver\u0026rdquo; control. Long-running load. The ANR doesn\u0026rsquo;t fire on a 10-second smoke test. It fires under 5–20 minutes of continuous WebView video with the Flutter overlay active. Most reproductions came inside 15 minutes. adb shell atrace + Perfetto. atrace -c -b 32768 gfx view sched freq idle -t 30 -o /sdcard/trace.trace while reproducing, then adb pull and open in Perfetto. The UI immediately shows the Raster thread parked on MaliglesWaitFence. Classic systrace is deprecated; Perfetto handles the 20-minute capture you need here — -b 32768 minimum so the failing window survives. Android Studio GPU Inspector for the second pass, to confirm the fence stall rather than guess. On PowerVR, Imagination\u0026rsquo;s PVRTune is the only tool that reads Rogue-series counters correctly. Play Console vitals for population evidence. One device on a bench is an anecdote; ANRs clustering across thousands of sessions on the same SoC/GPU/driver is evidence. The decisive finding was negative: the Pixel 4a, on the same Flutter engine and same app build, never ANR\u0026rsquo;d across a week of bench runs. The Redmi Note 11 ANR\u0026rsquo;d inside 15 minutes. That isolates the variable to the GPU and its driver — not your Dart code, not the WebView, not the network.\nOptions considered # What I tried, in order, and what each actually did.\nForce Virtual Display mode via the legacy composition flags. ANRs dropped sharply because the surface-contention path was removed. Cost: touch input broke in edge cases, IME misbehaved, accessibility was wrong. Not acceptable for a parental-control overlay. Bandage, not a fix. Texture Layer Hybrid Composition — the engine-level mitigation Flutter shipped for this class of bug. Reduced ANR frequency on Mali by ~60–70% in our vitals. Did not eliminate it; on PowerVR the reduction was weaker. Still the best no-cost mitigation, but you can\u0026rsquo;t ship a product on \u0026ldquo;ANRs 70% less often.\u0026rdquo; Disabling Impeller and forcing Skia on the affected devices. No measurable effect; the fence stall is below the Impeller/Skia split. Throttling the overlay\u0026rsquo;s repaint — making the Flutter layer above the WebView near-static. Helped on jank, marginal on the ANR because the lock can fire on a single frame boundary. A native Kotlin player behind a MethodChannel/PlatformView, removing the WebView entirely. Works — the ANR goes away — but you\u0026rsquo;ve rewritten your core flow in native, lost iOS parity, and you\u0026rsquo;re still paying the platform-view tax on every other WebView flow (HTML5 games, books). One surface patched, structural problem standing. Avoiding platform views entirely — render the player overlay in native, the rest in Flutter. Feasible for a small app; for a video-centric product it inverts the architecture — a native app that embeds Flutter, not a Flutter app. Resolution # After six weeks of mitigations, the call was structural. The platform-view ANR lived at the intersection of three closed surfaces — Flutter\u0026rsquo;s composition logic, Android\u0026rsquo;s HardwareRenderer, and the OEM\u0026rsquo;s GPU driver blob — and on the devices dominating our emerging-market user base, that intersection was unfixable in any layer I owned.\nI migrated Kahf Kids off Flutter to React Native with Expo. The full reasoning — decision matrix, the TV-support failure that compounded this, the three-month AI-assisted delivery, the replacement architecture — is in why we migrated Kahf Kids off Flutter, and the product outcome is in the Kahf Kids Flutter deep dive. Short version: on the same device matrix that was ANR-ing before, the ANR surface disappeared — because a native WebView in React Native\u0026rsquo;s view tree is a first-class view, not a foreign view composed into a foreign renderer.\nThat\u0026rsquo;s not a Flutter-is-bad conclusion. I keep Flutter in production on the NCC app and the Crewlix HR platform because their core flows stay inside Flutter\u0026rsquo;s canvas — no load-bearing WebView under sustained load. Right tool, right product.\nTakeaways # If you\u0026rsquo;re hitting a flutter platform view anr on low-end devices, here\u0026rsquo;s the diagnostic I\u0026rsquo;d run, in order:\nPull the Play Console vitals and group ANRs by device + GPU + driver build, not by app version. If the cluster sits on Mali-G52/G57 or PowerVR GE8320 on specific OEM blobs, you\u0026rsquo;re looking at the failure mode above. Stop debugging your Dart. Reproduce on real cheap hardware, not an emulator, not a Pixel. A used Redmi Note 11 or Samsung A12 costs less than a day of engineering time. Run a 20-minute WebView session under Perfetto and look for the Raster thread parked on MaliglesWaitFence or the PowerVR equivalent. Enable Texture Layer Hybrid Composition first. It\u0026rsquo;s free, it\u0026rsquo;s the engine\u0026rsquo;s intended mitigation, and it\u0026rsquo;ll take the edge off while you decide whether the problem is structural. Distinguish jank from context-lock. Dropped frames are a tuning problem; a five-second main-thread block on a GPU fence is architectural. Don\u0026rsquo;t conflate them. Apply the smell test early. If your core flow is a flutter webview mali gpu scenario — video-heavy, map-heavy, camera-heavy — on a flutter low end device user base, treat platform views as a structural risk before you write the first line of Dart. The hardest part was admitting the problem wasn\u0026rsquo;t fixable in the layer I owned. I patched the video flow three times before I accepted that the issue lived below the app layer. Once I accepted that, the decision took an afternoon. That diligence is what I lay out in our full comparison of Flutter vs React Native vs Expo, and it\u0026rsquo;s the work I do with founders before a line of production code is written.\nThe migration that came out of this investigation is documented in migrating Kahf Kids off Flutter, and the Flutter architecture it left behind — clean-arch layers, Riverpod, the WebView overlay that sat in the hot path — is in the Kahf Kids Flutter architecture deep dive. The Expo/React Native rebuild that replaced it — including the TV spatial-navigation path — is in building the Kahf Kids RN/Expo architecture.\nIf you\u0026rsquo;re hitting the same class of Flutter platform-view ANR — a WebView, map, or camera flow freezing on Mali or PowerVR handsets you can\u0026rsquo;t reproduce on a Pixel — get in touch and I\u0026rsquo;ll tell you straight whether it\u0026rsquo;s patchable in your layer or structural.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/flutter-platform-view-anr-mali-powervr/","section":"Insights","summary":"\u003cp\u003eThis is the investigation I wish someone had handed me before I spent six weeks patching a Flutter platform-view ANR I couldn\u0026rsquo;t fix in app code. It\u0026rsquo;s the engineering detail behind \u003ca\n  href=\"/insights/flutter-vs-react-native-vs-expo/\"\u003eour full comparison of Flutter vs React Native vs Expo\u003c/a\u003e and the failure mode that ended in \u003ca\n  href=\"/insights/migrating-kahf-kids-flutter-to-react-native-expo/\"\u003emigrating Kahf Kids off Flutter\u003c/a\u003e. Dev forums are full of \u003cem\u003e\u0026ldquo;Flutter WebView ANR on Redmi Note\u0026rdquo;\u003c/em\u003e threads with no real answers — most blame the WebView, tell you to enable hybrid composition, or say clear the cache. None of that fixes this.\u003c/p\u003e","title":"Flutter Platform-View ANRs on Mali/PowerVR GPUs","type":"insights"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect with 7+ years shipping Flutter, React Native, and Expo in production. I\u0026rsquo;ve kept one Flutter app alive for seven years across three stores, I\u0026rsquo;ve built an HR platform in Flutter because Flutter was the right call for that product, and I\u0026rsquo;ve also migrated a Flutter app off Flutter when the architecture stopped fitting. So when founders ask me \u0026ldquo;Flutter or React Native or Expo for the MVP,\u0026rdquo; my answer is never \u0026ldquo;it depends.\u0026rdquo; It depends on what — and I can tell you the what in about ten minutes if you describe the product.\nThis is the comparison I wish someone had handed me before I spent six weeks patching a platform-view ANR I couldn\u0026rsquo;t fix in app code. It\u0026rsquo;s also the pillar behind two deeper write-ups: the engineering breakdown of real-world Flutter platform-view ANRs on Mali and PowerVR GPUs, and why we migrated Kahf Kids off Flutter. Read this first; drill into those when you need the failure-mode detail.\nQuick comparison # Dimension Flutter React Native Expo Rendering engine Impeller (Metal/Skia) paints its own canvas Fabric renderer to native platform views Same as RN — Expo is tooling on top of RN Android TV / tvOS Weak; no first-class TV target react-native-tvos fork; Android TV works Inherits RN; TV targets wired via prebuild Platform views Foreign view into Flutter\u0026rsquo;s renderer — GPU fence handshake (ANR risk on Mali/PowerVR) First-class native view already in the tree — no handshake Same as RN Low-end-device perf Excellent in-canvas; stalls on platform views Hermes + JSI closes the gap; competitive Same as RN; OTA hotfixes via EAS Update Time-to-MVP Excellent for conventional UI Very fast when reusing mature native modules Fastest path to TestFlight and Play Internal When to pick Conventional UI, render consistency, one Dart team Existing RN codebase, fine-grained native control New MVP, native-view-heavy flows, low-end hardware, TV roadmap The rest of this article is the reasoning behind each row.\nThe three options in 2026 # Flutter is Google\u0026rsquo;s cross-platform UI toolkit. You write Dart; the engine paints its own pixels with Skia (and now Impeller on iOS and Android); iOS, Android, Web, and desktop render identically because the framework doesn\u0026rsquo;t ask the platform for its native widgets — it draws them itself. Backed by Google, used in production by BMW, ByteDance, and Alibaba. In 2026 it\u0026rsquo;s mature: null safety shipped years ago, Impeller is the default mobile renderer, and the talent pool is real, though the senior architect end is still thin.\nReact Native is Meta\u0026rsquo;s cross-platform framework. You write JavaScript or TypeScript, and RN renders to actual platform views — UIView on iOS, View on Android — driven from a JS runtime. The 2024+ \u0026ldquo;new architecture\u0026rdquo; (Fabric renderer, Hermes engine, TurboModules, JSI) replaced the legacy bridge and closed most of the perf gap people remember from 2019. Backed by Meta, used by Microsoft, Coinbase, Shopify, and Discord. In 2026 it\u0026rsquo;s the most battle-tested JavaScript-native option.\nExpo is not a third framework — it\u0026rsquo;s the tooling and managed-build layer on top of React Native. You still write RN, but Expo gives you expo prebuild for deterministic native project generation, config plugins for native behavior without touching Xcode, EAS Build/Submit/Update for cloud builds and OTA pushes, and a curated module ecosystem. In 2026, \u0026ldquo;React Native built the Expo way\u0026rdquo; is the default starting point for most new RN apps; bare RN is the niche. When I say \u0026ldquo;Expo\u0026rdquo; in this article, I mean RN shipped the Expo way.\nArchitecture comparison # The rendering split is the load-bearing difference, and most comparison articles gloss it.\nFlutter owns the render pipeline. Dart compiles AOT to native ARM, the engine uses Impeller (built on Metal and Skia) to draw every frame itself, and there\u0026rsquo;s no JNI hop into the platform view system for normal UI. That\u0026rsquo;s why a Flutter list scrolls at 60 or 120fps on a mid-range device where a hybrid WebView app janks: the path from your widget tree to pixels is short and single-language. The trade-off is real — anything native (a WebView, a map, a camera preview, an AR session) has to cross into Flutter through a platform view, and that crossing is where the trouble lives.\nReact Native (new architecture) renders to the platform\u0026rsquo;s own views through Fabric. JS runs on Hermes, calls into native through JSI (a direct C++ surface — no serialized-JSON bridge), and TurboModules are lazily initialized. The win: a map, a camera, or a WebView is already a native view in the tree, so there\u0026rsquo;s no \u0026ldquo;foreign view inside a foreign renderer\u0026rdquo; handshake. The cost: you carry the platform view-system overhead everywhere, and your business logic runs in JS.\nExpo doesn\u0026rsquo;t change any of that rendering. What Expo changes is the build and release loop — prebuild generates the ios/ and android/ folders deterministically from app.json, config plugins patch native behavior without a Xcode fight, and EAS Build produces signed artifacts in the cloud. It\u0026rsquo;s a force multiplier on RN\u0026rsquo;s architecture, not a different architecture.\nShort version: Flutter wins on render consistency and on UI that stays inside its own canvas; RN/Expo wins when your app\u0026rsquo;s center of gravity is native views and native APIs.\nThe platform-view problem # This is the section most comparison articles skip, and it\u0026rsquo;s the one that ends in migrations.\nA platform view is any embedded native View — a WebView, a map, a camera preview, a video player, an AR session. Every cross-platform framework has to bridge these, and the bridge is where the failure modes live.\nIn Flutter, platform views require GPU context synchronization between the Impeller/Skia render thread and the platform view hierarchy. On well-behaved GPU drivers that handshake is fast and invisible. On certain Mali and PowerVR driver versions — common across the Samsung A-series, the Redmi Note line, and the entry-level MediaTek and Snapdragon SoCs that dominate emerging markets — the render thread holds a lock, the platform thread waits on the GPU, and at five seconds the system fires an ANR. Texture Layer Hybrid Composition reduces the frequency; it does not eliminate it across the device matrix.\nI have the Play Console vitals to prove it. The full engineering breakdown — the stack signatures, the device matrix, the mitigations that worked and the ones that didn\u0026rsquo;t — is in our analysis of real-world Flutter platform-view ANRs on Mali and PowerVR GPUs. If your product\u0026rsquo;s core flow is a video-heavy WebView, a map, or anything that puts a native view under sustained load on low-end hardware, read that before you commit to Flutter.\nIn React Native, native views are first-class — a MapView or WebView is just a view in the tree, no render-thread handshake. That\u0026rsquo;s why the Kahf Kids migration removed the ANR surface by leaving Flutter: the parental-control video layer that was hanging on Mali simply stopped hanging once it was a native bridge, not a platform view.\nLow-end device performance # Most of my users are on cheap phones. That\u0026rsquo;s not a complaint — it\u0026rsquo;s the dominant reality for any product shipping to South Asia, Southeast Asia, Latin America, or Africa, and increasingly for budget-tier users in mature markets. The device matrix looks like this: MediaTek Helio or entry-level Snapdragon SoCs, Mali-G52/G57 or PowerVR GE8320 GPUs, 2–3 GB of RAM, often on 3G/4G with intermittent connectivity. That hardware mix shapes every architectural decision.\nFlutter on low-end hardware is a paradox. For pure-Flutter UI — screens built from Material or Cupertino widgets, no native views in the path — it\u0026rsquo;s excellent. Impeller is tuned for these GPUs, and you\u0026rsquo;ll hit smooth frame rates where a comparable RN app is still warming up. The moment you put a platform view on screen, the paradox inverts, and the same cheap GPU that ran your Flutter list buttery-smooth stalls on the WebView next to it. I\u0026rsquo;ve watched it happen on a Redmi Note 11 in production.\nReact Native on the same hardware has historically paid a JS-bridge tax. The new architecture (Hermes + Fabric + JSI) substantially closes that gap — Hermes precompiles JS to bytecode and TurboModules skip the serialization overhead — but you\u0026rsquo;re still running a JS runtime alongside the native view system. For apps heavy on lists, animations, and gesture handlers, RN in 2026 is competitive with Flutter in a way it simply wasn\u0026rsquo;t in 2020. For apps heavy on raw computation, Flutter\u0026rsquo;s AOT Dart still wins.\nExpo inherits RN\u0026rsquo;s perf profile. What Expo adds on low-end devices is operational: EAS Update lets you push JS fixes over the air without a store review, which is a real lever when your user base is on flaky networks and you need to ship a hotfix in hours, not weeks.\nThe honest rule: if your MVP is UI-heavy and stays inside Flutter\u0026rsquo;s canvas, Flutter will feel faster on a $90 phone. If your MVP is video-, map-, or camera-heavy on that same phone, RN/Expo removes a class of failures you cannot patch in Dart.\nTV support # This was the dealbreaker for Kahf Kids, and if TV is on your roadmap it might be yours.\nFlutter\u0026rsquo;s TV story is weak. There\u0026rsquo;s no first-class Android TV or tvOS target. Focus handling, D-pad and remote navigation, and platform-view behavior on TV chipsets are all unreliable. You can make Flutter run on a TV; you cannot easily make it feel native to one. For a kids\u0026rsquo; app where a meaningful chunk of engagement happens on the family living-room screen, that\u0026rsquo;s a structural miss — and it was one of the two reasons I stopped fighting.\nReact Native has a community-maintained react-native-tvos fork for Apple TV and reasonable Android TV support. Expo doesn\u0026rsquo;t add first-class TV support, but it doesn\u0026rsquo;t block it either — you can drop to prebuild and wire in the TV targets when you actually need them, without abandoning the rest of the Expo toolchain.\nThe full reasoning — including why this combined with the platform-view ANRs forced the call on Kahf Kids — is in why we migrated Kahf Kids off Flutter. Short version: if TV is a launch target, don\u0026rsquo;t pick Flutter. If TV is \u0026ldquo;maybe in year two,\u0026rdquo; Flutter is viable and you migrate later if you have to.\nDeveloper experience, talent pool, time-to-MVP # How fast can you ship a first version, and who can you hire to build it?\nFlutter DX. Hot reload is still the best-in-class experience across all three options. Strong typing in Dart, a single language for UI and logic, and the Flutter DevTools performance overlay are genuinely pleasant to work in. The Dart talent pool is smaller than JavaScript\u0026rsquo;s, but developers cross-train fast. Time-to-MVP for a conventional app: excellent — I shipped the Crewlix HR platform on exactly this logic, because HRM software is business-logic-heavy and UI-repetitive, which is the shape where Flutter pays off fastest.\nReact Native DX. Fast Refresh is good, the JavaScript ecosystem is enormous, and you hire from a much larger talent pool. The cost is complexity in the native layers — Gradle and Xcode knowledge still matters, and the new-architecture migration is non-trivial on existing apps. Time-to-MVP for an app reusing mature native modules: very fast.\nExpo DX. This is where Expo earns its keep. npx create-expo-app, expo prebuild, EAS Build producing signed iOS and Android artifacts without you opening Xcode, and EAS Update for OTA JS pushes. For a founder shipping their first app, Expo is the fastest path from laptop to TestFlight I know of. I delivered the Kahf Kids RN/Expo rebuild in three months with an AI-assisted workflow, and the Expo toolchain was a meaningful part of how that timeline compressed.\nThe talent reality in 2026: JavaScript developers outnumber Dart developers by roughly an order of magnitude. If hiring speed matters to you, RN/Expo has the deeper bench. If you want one tight team owning one codebase with strong typing and the strongest hot-reload in the business, Flutter has the edge.\nDecision framework # Here\u0026rsquo;s my opinionated guide. I\u0026rsquo;m an architect, not a diplomat — these are real recommendations, not \u0026ldquo;it depends.\u0026rdquo;\nChoose Flutter when:\nYour UI is conventional: forms, lists, detail screens, dashboards, calendars. You want pixel-identical iOS/Android rendering without per-platform UI work. Your team is small and you want one language (Dart) for UI and logic. You\u0026rsquo;re not heavily dependent on WebView, maps, camera, or AR under sustained load. TV is not a near-term target. This is why I picked Flutter for Crewlix and have kept it for the NCC App for seven straight years — 70+ modules whose core flows live in Flutter\u0026rsquo;s canvas. Right tool, right product.\nChoose React Native (bare) when:\nYou have an existing RN codebase, a JS-heavy team, and native modules you already maintain. You need fine-grained control over the native projects that prebuild would fight you on. You\u0026rsquo;re embedding into a brownfield app where RN\u0026rsquo;s partial-screen story is better. Choose Expo when:\nYou\u0026rsquo;re starting a new cross-platform MVP and want the fastest path to TestFlight and Play Internal. Your product stresses native views (video, maps, camera) on low-end hardware. TV is on the roadmap. You want OTA updates, cloud builds, and a config-plugin ecosystem instead of hand-patching native projects. This is why I migrated Kahf Kids to RN/Expo. Right tool, right product — different product.\nIf you want the single sentence: Flutter for conventional UI and render consistency; Expo for everything that stresses native views, low-end hardware, or TV. Bare RN sits in between, for the cases where Expo\u0026rsquo;s prebuild gets in your way.\nMigration reality # Can you move if you pick wrong? Yes — but it isn\u0026rsquo;t free, and it isn\u0026rsquo;t \u0026ldquo;just rewriting the UI.\u0026rdquo;\nA Flutter → RN/Expo migration is a full rewrite of the view and state layers; only your backend, your APIs, and your product spec carry over. I\u0026rsquo;ve done it: the Kahf Kids migration was three months of focused, AI-assisted work, and the engineering reasoning is documented in why we migrated Kahf Kids off Flutter. The platform-view ANRs went away; the parental-control contract held; the device matrix that was failing started passing.\nThe reverse — RN/Expo → Flutter — is the same shape: a view-layer rewrite. The deciding factor isn\u0026rsquo;t the direction; it\u0026rsquo;s whether the architectural fit is broken. If your core flow is hanging on Mali GPUs, no amount of Dart refactor will fix that — you migrate. If your team is shipping happily and the product fits the canvas, don\u0026rsquo;t migrate on a fashion impulse.\nThe lever most founders miss: a paid architecture audit before you commit. A few hours of senior time, mapping the product against the platform contracts, will tell you which way the decision actually lands before you\u0026rsquo;ve written a line of production code. It\u0026rsquo;s the cheapest money you\u0026rsquo;ll spend on the project, and I start most engagements exactly that way.\nConclusion # All three of these stacks ship real apps to real users in 2026. The mistake isn\u0026rsquo;t picking the \u0026ldquo;wrong\u0026rdquo; one — it\u0026rsquo;s picking without knowing your product\u0026rsquo;s core flow, your device matrix, and your roadmap. Flutter is excellent for a large class of apps and I\u0026rsquo;ll keep recommending it. Expo-on-RN is excellent for a different large class, and on low-end hardware with platform views in the loop, it removes failures Flutter can\u0026rsquo;t.\nIf you\u0026rsquo;re staring at the choice and want a senior architect to map it against your actual product — the core flow, the device matrix, the stores, the timeline — that\u0026rsquo;s exactly what I do. Start a conversation.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/flutter-vs-react-native-vs-expo/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect with 7+ years shipping Flutter, React Native, and Expo in production. I\u0026rsquo;ve kept \u003ca\n  href=\"/projects/ncc-mobile-app/\"\u003eone Flutter app alive for seven years across three stores\u003c/a\u003e, I\u0026rsquo;ve \u003ca\n  href=\"/projects/crewlix-hr-app/\"\u003ebuilt an HR platform in Flutter\u003c/a\u003e because Flutter was the right call for that product, and I\u0026rsquo;ve also \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003emigrated a Flutter app off Flutter\u003c/a\u003e when the architecture stopped fitting. So when founders ask me \u0026ldquo;Flutter or React Native or Expo for the MVP,\u0026rdquo; my answer is never \u0026ldquo;it depends.\u0026rdquo; It depends on \u003cem\u003ewhat\u003c/em\u003e — and I can tell you the what in about ten minutes if you describe the product.\u003c/p\u003e","title":"Flutter vs React Native vs Expo for MVPs","type":"insights"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/flutter-migration/","section":"Tags","summary":"","title":"Flutter-Migration","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/freelance/","section":"Tags","summary":"","title":"Freelance","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/gpu/","section":"Tags","summary":"","title":"Gpu","type":"tags"},{"content":"If you\u0026rsquo;re hiring a Flutter developer in 2026, you\u0026rsquo;re doing it for one of two reasons: you want one codebase shipping to iOS and Android simultaneously, or you have a Flutter app that needs to stay alive. Both are sound decisions — the hire that goes wrong is the one where the buyer couldn\u0026rsquo;t tell a senior architect from a junior who\u0026rsquo;d shipped one tutorial app.\nI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect with 7+ years shipping Flutter, React Native, and Expo. I\u0026rsquo;ve maintained a production Flutter app for seven straight years across three stores and I\u0026rsquo;ve also migrated a Flutter app off Flutter when the architecture stopped fitting the product. I\u0026rsquo;m not here to sell you Flutter — I\u0026rsquo;m here to tell you how to hire someone who\u0026rsquo;ll actually deliver it.\nWhy Flutter in 2026 # The economic case for Flutter hasn\u0026rsquo;t changed: one Dart codebase, one team, two (or three, with Web) platforms. The rendering engine paints pixels itself, so iOS and Android look identical without per-platform UI work. For a product where the UI is conventional — forms, lists, detail screens, calendars — that\u0026rsquo;s a genuine productivity win, not a marketing line. I shipped an HR management app in Flutter on exactly that logic: HRM software is heavy on business logic and repetitive on the view layer, which is the shape where Flutter pays off.\nWhat has changed in 2026 is the talent market. Flutter is mature enough that the supply of developers is real — but the supply of developers who can architect a Flutter app, not just build screens, is still thin. Most people with \u0026ldquo;Flutter\u0026rdquo; on their CV have shipped widgets. Few have absorbed a null-safety migration, an Impeller rollout, or a major dependency breakage on a live app without breaking the release cadence. That gap is what you\u0026rsquo;re actually hiring for.\nWhere Flutter is weak, I\u0026rsquo;ll say plainly: anything that puts a platform view — an embedded native View like a WebView, a map, a camera preview — under heavy load on low-end hardware. On certain Mali and PowerVR GPU drivers, the GPU context synchronization between Flutter\u0026rsquo;s render thread and the platform view hierarchy stalls, and at five seconds the system fires an ANR. That\u0026rsquo;s not a Flutter-hater take; it\u0026rsquo;s the exact failure mode that made me migrate Kahf Kids from Flutter to React Native/Expo. If your product\u0026rsquo;s core flow is video-heavy inside a WebView on emerging-market handsets, Flutter may still be the wrong call, and you should read our head-to-head comparison of Flutter, React Native, and Expo before you commit.\nWhat a senior Flutter developer actually does (vs junior) # A junior Flutter developer writes widgets. A senior one makes decisions that determine whether the app is still shippable in three years. The difference shows up in three places.\nArchitecture. A senior decides state-management boundaries before the first screen — which approach fits the team and the product\u0026rsquo;s complexity (Riverpod for composable, testable state; Bloc for strict, event-driven flows; something simpler for a small app that doesn\u0026rsquo;t need the machinery). They draw module boundaries so feature work doesn\u0026rsquo;t leak into shared infrastructure. A junior picks a state-management library off a tutorial and copies the pattern into every screen until the codebase turns to mud.\nPlatform contracts. Flutter is cross-platform, but it runs on iOS and Android — two operating systems with different storage APIs, different background-execution rules, different permission flows. A senior knows where the abstraction leaks: that a secure token store means Android Keystore on one side and iOS Keychain on the other, that push-notification semantics differ, that background-location rules will bite you on iOS 17+. A junior trusts the plugin to handle it and discovers it didn\u0026rsquo;t.\nRelease engineering. This is the one most founders underweight. A senior sets up the signing, the staged-rollout ladder (internal → closed → open on Play, TestFlight on iOS), the store metadata, and the CI that produces a clean build on every merge. A junior hands you an .apk and calls it done. If your developer can\u0026rsquo;t ship the app to three stores without you project-managing the release, you don\u0026rsquo;t have a senior — you have a coder.\nSkills to vet # When you interview a Flutter developer, here\u0026rsquo;s what to actually probe. Don\u0026rsquo;t ask syntax trivia; ask for decisions and the reasoning behind them.\nState management — Riverpod or Bloc. Ask them to compare two approaches on a concrete feature — say, an offline-capable cart with optimistic updates. A real senior will tell you which they\u0026rsquo;d pick, why, and where the other one would\u0026rsquo;ve been better. If they can\u0026rsquo;t name a downside of their preferred tool, they don\u0026rsquo;t understand it.\nClean Architecture and module boundaries. Ask how they\u0026rsquo;d structure a medium app: data layer, domain layer, presentation layer, and where the platform-specific code lives. If the answer is \u0026ldquo;I put everything in lib/ and use providers,\u0026rdquo; you\u0026rsquo;re talking to a junior.\nTesting — unit, widget, integration. This is the single best filter. Ask what they test and what they deliberately don\u0026rsquo;t. A senior covers the load-bearing paths — auth, payments, the API contract — with unit tests, widget-tests the critical flows, and skips 100% coverage as a vanity number. On the NCC App — 70+ feature modules across three stores — that discipline is what produced a seven-year record of zero major production incidents. If a candidate has never written a widget test, walk away.\nCI/CD and release automation. Ask them to walk you through getting a build to the Play Store internal track. Fastlane, signed builds, version bumps, the lot. If they describe a manual process, they\u0026rsquo;ve never run a real release. Our write-up on mobile CI/CD and release automation covers what \u0026ldquo;good\u0026rdquo; looks like.\nPlatform channels and native interop. Ask when they\u0026rsquo;d write a platform channel rather than use a plugin. The right answer is: when the plugin doesn\u0026rsquo;t exist, doesn\u0026rsquo;t fit, or is abandoned — which on a long-lived app is a regular occurrence. Method channels, Pigeon, and knowing both Kotlin and Swift enough to read the native side are senior territory.\nPerformance profiling. Ask them to debug jank in a list. A senior will reach for the Flutter DevTools performance overlay, talk about build phases vs layout vs paint, and mention the usual culprits — expensive build methods, missing const, rebuilding more than necessary. If \u0026ldquo;jank\u0026rdquo; doesn\u0026rsquo;t trigger a concrete diagnostic answer, they\u0026rsquo;ve never profiled.\nFlutter vs React Native vs Expo # Flutter isn\u0026rsquo;t the only option, and a developer who can\u0026rsquo;t tell you when it\u0026rsquo;s the wrong one is a developer who doesn\u0026rsquo;t understand the trade-off. React Native with Expo has closed the gap on cross-platform DX, the JavaScript ecosystem is enormous, and for products that lean on platform views (video players, maps, AR), RN\u0026rsquo;s bridge model can avoid the platform-view pathology I described above. For a full breakdown — where each wins, where each breaks, and how to choose for your specific product — read our head-to-head comparison of Flutter, React Native, and Expo. The short version: choose Flutter when the UI is conventional and you want one team; consider RN/Expo when your core flow stresses platform views on low-end hardware.\nWhat it costs in 2026 # Honest ranges, because vague answers waste everyone\u0026rsquo;s time. Rates below are what I see in the market for senior, English-fluent Flutter developers delivering production-quality work in 2026. Junior rates exist below these floors; agency markups sit above the ceilings.\nBy engagement model:\nHourly freelance — flexible scope, best when requirements move. You trade predictability for access. Fixed-scope project — a defined deliverable (an MVP, a migration, a feature set) with a fixed price. Best when you can write the spec clearly. Riskier for both sides if the spec is vague. Monthly retainer — dedicated capacity, typically 40–80 hours/month. Best for ongoing maintenance and steady feature work. This is how I keep the NCC App alive — a calm, scheduled cadence instead of heroics. By region (senior Flutter, hourly USD):\nNorth America / Western Europe — $80–$150/hr. Strong communication, overlapping time zones if you\u0026rsquo;re US/EU-based, highest cost. Eastern Europe — $45–$85/hr. Deep talent pool, good English, modest time-zone overlap with Western Europe. Latin America — $40–$75/hr. Excellent US time-zone overlap, growing Flutter community. South Asia (India, Pakistan, Bangladesh) — $25–$60/hr. The most cost-effective senior talent globally; I\u0026rsquo;m in this bracket, based in Dhaka. The variance in quality is also the widest here — vet hard. Project pricing: A production MVP from a spec — architecture, build, release pipeline, three-store submission — typically lands at $12k–$40k for a senior independent. Complex apps (real-time, heavy native interop, custom rendering) run higher. If a quote is dramatically below this floor, you\u0026rsquo;re buying a junior\u0026rsquo;s learning curve, not a product.\nValue vs cost. The cheapest developer is rarely the cheapest outcome. A $30/hr developer who ships an unmaintainable codebase costs you more over two years than a $75/hr developer who ships a clean one — because the rework, the stalled releases, and the eventual rescue engagement happen on your timeline and your users. I do rescue work; I see the receipts. Pay for architecture, not hours.\nRed flags # Walk away from a candidate who shows any of these:\nNo opinion on state management. \u0026ldquo;I use whatever the project uses\u0026rdquo; sounds humble; it actually means they\u0026rsquo;ve never thought about it. No tests in the portfolio. A GitHub with 20 Flutter projects and zero test files is a portfolio of prototypes, not production code. No release process. If they can\u0026rsquo;t describe how a build gets to the Play Store internal track, they\u0026rsquo;ve never shipped for real. Generic portfolio. Twenty clones of the same to-do / weather / recipe app. You want someone who\u0026rsquo;s shipped to real users and absorbed a real OS migration. Can\u0026rsquo;t discuss failure modes. Ask them what broke on their last project and how they debugged it. \u0026ldquo;Nothing really broke\u0026rdquo; is the wrong answer. Senior engineers have scar tissue; juniors haven\u0026rsquo;t shipped enough to earn any. AI-generated everything, unreviewed. In 2026, every developer uses AI. The dangerous ones are those who ship its output without reading it. Ask how they review AI-generated code. If the answer is \u0026ldquo;I trust it,\u0026rdquo; walk. How AI changes hiring # AI-assisted delivery has redrawn what \u0026ldquo;senior productivity\u0026rdquo; means. My own loop is AI-native from the spec up — I decompose the system into unambiguous tasks, write context-rich prompts, have the AI generate code against them, and then review every diff against the architecture and the platform contracts I already know. The typing is delegated; the engineering judgment isn\u0026rsquo;t. On Kahf Kids, that loop is what let me deliver a Flutter → React Native/Expo migration in three months instead of three quarters.\nWhat this means for hiring: the senior you want in 2026 isn\u0026rsquo;t the fastest typist — it\u0026rsquo;s the person whose review and debugging skills are sharp enough to keep AI output production-grade. A developer who leans on AI to write code they can\u0026rsquo;t vet is a liability. A developer who uses AI to multiply reviewed judgment is a force multiplier. Read how AI-assisted delivery changes timelines and cost if you want the full picture of what this does to your budget and your schedule.\nHow to start # If you\u0026rsquo;ve read this far, you know what good looks like. Three concrete next steps:\nScope the work first. Write down the app, the stores (Play, App Store, Amazon), the timeline, and the device matrix you have to hit. A senior can quote against a spec; a junior needs you to guess. Run a paid architecture audit. A few hours of a senior\u0026rsquo;s time, paid, to map the system before code is written. This is the single highest-leverage spend in a mobile project. I start most engagements this way. Talk to me. I take on fixed-scope projects and ongoing retainers, and I\u0026rsquo;ll tell you honestly whether Flutter is the right call for your product — or whether you\u0026rsquo;d be better served by React Native / Expo. See what I offer or start a conversation. FAQ # How much does it cost to hire a Flutter developer in 2026? # A senior Flutter freelancer runs $25–$150/hr depending on region — South Asia ($25–$60), Eastern Europe ($45–$85), North America / Western Europe ($80–$150). A production MVP from a spec typically lands at $12k–$40k fixed-scope; ongoing maintenance runs on a $2k–$6k monthly retainer. Pay for architecture, not the cheapest hourly rate.\nShould I hire a freelancer, an agency, or a full-time developer? # Freelancers give you the best value-per-senior-dollar and direct access to the person doing the work. Agencies add overhead and a markup — useful if you need a team fast and can\u0026rsquo;t manage it yourself. Full-time makes sense once revenue occupies one person year-round; before that, it\u0026rsquo;s overkill. For most founders shipping their first production app, a senior freelancer on a fixed scope or retainer is the right shape.\nDo I need a Flutter developer, or would React Native / Expo be better? # It depends on the product\u0026rsquo;s core flow. Flutter wins on conventional UI — forms, lists, detail screens, calendars — and on rendering consistency across iOS and Android. React Native with Expo is the better fit when your core flow stresses platform views (video players, maps, AR) on low-end hardware, or when you need a larger JavaScript-native talent pool. I\u0026rsquo;ve shipped both and migrated between them; the head-to-head comparison walks through the decision.\nHow do I vet a Flutter developer without being technical myself? # Three filters that don\u0026rsquo;t require you to code. First, ask them to explain a past failure and how they debugged it — vague answers mean shallow experience. Second, ask to see tests in their portfolio — a senior has them, a junior doesn\u0026rsquo;t. Third, pay for a one-hour architecture conversation before any build — a senior gives you a clear map of state management, module boundaries, and release process; a junior talks about widgets.\nIs AI replacing Flutter developers in 2026? # No — but it is replacing the developers who only type. AI handles boilerplate; the senior\u0026rsquo;s job has shifted toward architecture, review, and debugging the hard cases AI gets wrong. A developer who ships AI output unreviewed is a liability; one who uses AI to multiply their own reviewed judgment delivers faster without dropping quality. The full picture is in how AI-assisted delivery changes timelines and cost.\nCan one Flutter developer ship to iOS, Android, and Web? # Yes — that\u0026rsquo;s the core promise of Flutter, and I\u0026rsquo;ve done it. But \u0026ldquo;can\u0026rdquo; and \u0026ldquo;will do well\u0026rdquo; differ. iOS requires a Mac, an Apple Developer account, and knowledge of App Store review; Android requires signing keys and Play Console fluency; Web support in Flutter is real but has trade-offs for SEO and for apps not designed for it. A senior Flutter developer with release-engineering experience will handle all three; confirm they\u0026rsquo;ve actually shipped to each before you assume it.\nHow long does a Flutter MVP take? # For a focused MVP — architecture, core flows, release pipeline, three-store submission — expect 8–16 weeks with a senior full-time. Less if the spec is tight; more if there\u0026rsquo;s heavy native interop or platform-view work. AI-assisted delivery compresses this, which is how I delivered the Kahf Kids migration in three months. If someone quotes 2–3 weeks for a \u0026ldquo;full app,\u0026rdquo; they\u0026rsquo;re selling a prototype, not a product.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/hire-flutter-developer-2026/","section":"Insights","summary":"\u003cp\u003eIf you\u0026rsquo;re hiring a Flutter developer in 2026, you\u0026rsquo;re doing it for one of two reasons: you want one codebase shipping to iOS and Android simultaneously, or you have a Flutter app that needs to stay alive. Both are sound decisions — the hire that goes wrong is the one where the buyer couldn\u0026rsquo;t tell a senior architect from a junior who\u0026rsquo;d shipped one tutorial app.\u003c/p\u003e","title":"Hire a Flutter Developer in 2026","type":"insights"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/hiring/","section":"Tags","summary":"","title":"Hiring","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/long-term/","section":"Tags","summary":"","title":"Long-Term","type":"tags"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect, and for the last 7+ years I\u0026rsquo;ve been the maintainer of the NCC App, the student-assistance app for Northwest Career College. It started on Flutter 1.x in 2019. The current build runs on Flutter 3.41.x stable, with Dart 3, Impeller, and Material 3. Same codebase. Same store listings — Google Play, App Store, Amazon Appstore. Zero major production incidents across the entire window.\nThis is the write-up I wish I\u0026rsquo;d had in 2019. Not \u0026ldquo;Flutter is great, just upgrade.\u0026rdquo; The actual migrations — the ones that bit — and the discipline that kept the app shippable. If you\u0026rsquo;re maintaining a Flutter app at the multi-year horizon, or building one you intend to keep, this is the practice.\nThe span # Seven years is a long time in any framework. In Flutter it\u0026rsquo;s an era. I started on Flutter 1.2-ish, when StatefulWidget was the default answer to everything, platform channels were the only bridge to native, and null safety was a twinkle in the Dart team\u0026rsquo;s eye. The current build runs on Flutter 3.41.x, with Dart 3\u0026rsquo;s records and patterns, the Impeller rendering backend, Material 3 theming, and a toolchain that doesn\u0026rsquo;t resemble 2019\u0026rsquo;s at all.\nWhat \u0026ldquo;long-term\u0026rdquo; actually means for a Flutter codebase is this: every assumption you baked in at the start — the widget composition patterns, the state management choice, the way you talked to the platform — has been invalidated at least once by a framework release. Not all at once. One breaking change per quarter, forever, for the life of the app.\nWeb frameworks can sit on a major version for half a decade. Flutter ships a stable every few months, and every stable has the potential to break something you wrote four years ago.\nThe migrations that actually hurt # Most Flutter stables are boring in the best way. These are the ones that weren\u0026rsquo;t.\nRiverpod 2 → 3. The biggest one I\u0026rsquo;ve absorbed recently, and the most representative of what state-management migrations actually cost. Riverpod 3 changed the API surface — StateController is gone, notifiers inherit differently, auto-dispose semantics moved, and the Freezed union methods (mapOrNull, maybeWhen, map) that the whole state machine relied on stopped generating cleanly until the codegen caught up. When I ran the analyzer on the migrated codebase it returned 386 issues — 291 errors, 95 warnings — across the feature set. That is the real shape of \u0026ldquo;we upgraded the state library.\u0026rdquo; Not a blog-post-sized diff. A week of fixing union patterns, re-checking auto-dispose behavior on every provider, and confirming no feature changed behavior while its syntax did. Zero functionality changes was the rule, and the only way to hold it was one feature at a time, each one shippable on its own.\nPackages broke on every major. This is the part the framework docs don\u0026rsquo;t dwell on. A Flutter major doesn\u0026rsquo;t just move Flutter — it moves the Dart SDK, the analyzer, the code generator, and half your plugin tree. On a 76-module app, every major update was a cascade: a plugin that hadn\u0026rsquo;t published a compatible release yet, a transitive that pinned an old SDK, two packages that resolved to conflicting versions of the same underlying dependency. Resolving the conflict graph was routine engineering work — and it was the work, every quarter, before a single feature could ship on the new stable.\nAnd we redesigned the app at the same time. Here\u0026rsquo;s the part that compounded everything: the update windows were also redesign windows. A major Flutter bump usually coincided with a product decision to refresh the UI — Material 2 to Material 3 being the obvious one. So the same branch that absorbed framework churn was also moving widgets, restyling screens, and rethinking flows. Two kinds of breakage on the same diff are hard to review and hard to bisect. The discipline I learned the hard way: even when the customer wants both at once, land them in layers — framework-stable first, redesign on top of a green build — never interleaved.\nNull safety (Dart 2.12). The earlier big one. A forced, sound migration of the entire codebase. I did it in layered passes: leaf packages first (the pure Dart utilities with no widget tree), then feature modules, then the app shell last. Each layer was independently shippable, so the app never stopped releasing. The teams that got burned tried to do it on one heroic branch. That branch lives forever.\nMaterial 2 → Material 3. Less catastrophic, more annoying. MaterialApp got new semantics, component constructors changed shape, and theming moved from a flat ThemeData to a ColorScheme-driven model that expects Material You color roles. The components that earned their keep — NavigationBar, the M3 Card, the M3 AppBar — I migrated deliberately. The ones that didn\u0026rsquo;t, I left on legacy. A blind \u0026ldquo;migrate everything to M3\u0026rdquo; pass is how you ship visual regressions you don\u0026rsquo;t notice for two releases.\nImpeller. I\u0026rsquo;ll be honest about this one, because the temptation in a piece like this is to invent a dramatic Impeller war story. On this app, Impeller was largely transparent — shader-compilation jank disappeared, animations got smoother, and that was the news. The reason it was uneventful is the whole point of this article: the architecture kept rendering concerns inside the presentation layer, so when the rendering backend swapped, nothing in the domain or application layer cared. When the engine changes and your app doesn\u0026rsquo;t notice, that\u0026rsquo;s the architecture paying you back — not luck.\nDart 3 records, patterns, and sealed classes. Language features I adopted selectively. Records replaced a handful of ad-hoc tuple classes. Patterns made state-machine exhaustiveness real — switch over a sealed union of states warns at compile time if you miss a case (the Freezed unions are what make this concrete). What I didn\u0026rsquo;t do is rewrite working code to use the new hotness. A seven-year-old codebase that suddenly uses records everywhere is a PR no one can review.\nAndroid Gradle Plugin and Xcode bumps. The unsexy ones that hurt most. Every year AGP moves a major, Gradle moves a major, the NDK drifts, Java LTS moves. Every year Xcode moves a major, the deployment target ratchets up, Swift evolves, and a signing workflow that worked last quarter breaks. These aren\u0026rsquo;t Flutter changes — they\u0026rsquo;re the native toolchain Flutter sits on top of, and they break your Flutter build just the same. I budget time every quarter to absorb the drift. Skip it for a year and you have a multi-week catch-up on a codebase that no longer builds clean.\nUpgrade strategy # Breaking changes don\u0026rsquo;t stop releases. They get absorbed into the release cadence. The strategy is a calendar, not a sprint.\nOne minor Flutter bump per sprint. Every two weeks, when a new stable lands, I bump on a quiet morning, resolve the package graph, run the app across the device matrix, and ship. If something broke, it broke small.\nOne major migration per quarter at most. Null safety. Material 3. Riverpod 3. These are quarter-sized efforts. They get their own branching strategy — feature-flagged, landed in layers, promoted through the staged rollout like any other release. Never two major migrations on the same branch. The blast radius of debugging two simultaneous breaking changes is not something you want to explain to a client.\nNever ship on a Friday. Old rule. Still right. Friday ships are how you spend Saturday watching the vitals dashboards. I cut releases Monday through Wednesday, give the staged rollout 48 hours of real user traffic, and promote to 100% by Thursday — or hold for the next week.\nHow it stayed at zero major incidents # Zero major production incidents across the maintenance window. Not \u0026ldquo;few\u0026rdquo; — zero. No data loss, no auth outage, no crash loop. I\u0026rsquo;m not going to pretend that record came from a deep automated test suite. It didn\u0026rsquo;t. It came from a release pipeline that treats every change as a staged, reversible rollout — and from being honest about where a regression would actually hurt.\nManual verification on the paths that hurt a student. The load-bearing flows — auth, payments, the API contract, attendance, document upload — get eyes on them on a real device before promotion, every time they change. Not a sprawling automated suite; deliberate, scoped manual passes on the paths where a regression actually hurts. I don\u0026rsquo;t chase 100% coverage as a vanity number — I cover what would hurt a student. There\u0026rsquo;s a future where this app carries a proper widget- and integration-test layer, and that\u0026rsquo;s on the roadmap. The honest present is manual verification plus the pipeline below.\nStaged rollouts on every store. Play\u0026rsquo;s internal → closed → open → production ladder. TestFlight for iOS, then a phased App Store release across the 1% → 20% → 50% → 100% curve over a week. Amazon\u0026rsquo;s live testing group, then production. If something wobbles, it wobbles for a few hundred users, not a few hundred thousand.\nMonitoring that sees the wobble before the ticket arrives. The stores\u0026rsquo; vitals dashboards on both platforms (Play Console, App Store Connect) and an uptime monitor on the API. When something does break — a rare GPU driver crash on a specific Android minor version, an iOS release that changed push semantics — I see it in the dashboard before the support inbox fills.\nConservative dependency upgrades. I read changelogs. I read issue trackers. I wait a beat on major bumps to see who else breaks first. A package that drops a breaking major on pub.dev is not a fire drill — it\u0026rsquo;s a \u0026ldquo;next sprint, after the early adopters have filed the bugs.\u0026rdquo;\nThe release pipeline itself is half the story. The whole flow — build, sign, upload to three stores, staged rollout, vitals watch — is automated end to end. That automation is the CI/CD stack that kept it shippable, and it\u0026rsquo;s the reason a migration like Riverpod 3 could go through the same release path as a feature. Reproducible pipeline, staged rollout, rollback button. Without that, \u0026ldquo;conservative and staged\u0026rdquo; isn\u0026rsquo;t a strategy — it\u0026rsquo;s a hope.\nArchitecture that aged well # The architecture decisions that age well are not the fashionable ones. They\u0026rsquo;re the boring ones.\nClean Architecture boundaries. The app is feature-first Clean Architecture — every feature is a self-contained module with the same five layers: domain (entities, pure Dart, no Flutter), application (use cases, state notifiers), infrastructure (repositories, DTOs, API services), presentation (pages, widgets), and shared (Riverpod providers). When Material 3 lands, that\u0026rsquo;s a presentation task. When Dart 3 patterns land, that\u0026rsquo;s a domain task. The two don\u0026rsquo;t collide. I made that refactor in year two and it has held for five years since — it\u0026rsquo;s the reason Riverpod 3 was a syntax migration and not a rewrite.\nFeature modules, not a lib/screens soup. The app carries over seventy feature modules — academic (advising, attendance, class schedule, certification, externship, the financial ledger), campus services (map, safety, parking, facilities), student services (payments, profile, digital ID and QR, document upload, transcript requests, veterans\u0026rsquo; benefits), and communication (the live feed, feedback, IT support, messaging). Each module has its own five layers. A change to attendance doesn\u0026rsquo;t touch payments. Reviews stay small.\nState that stays inside its feature. The app uses Riverpod, and the reason it\u0026rsquo;s survived seven years is that the providers are feature-scoped, not app-global. A payment flow doesn\u0026rsquo;t read from the attendance store. Cross-feature communication goes through a defined interface, not a global singleton. A global store is a coupling bomb.\nOne dio client per backend. Four configured Dio clients — the main authenticated API, the WordPress CMS, the feed service, and the externship service — each with its own base URL and interceptors. Auth headers live in one interceptor (Bearer token, sign-out on 401), not copy-pasted per request.\nA shared platform, not a one-off. Auth, the feed, and core utilities live in reusable crewlix_* packages shared across multiple apps — the same packages that power the Crewlix HR app. This isn\u0026rsquo;t one app\u0026rsquo;s lib/ folder — it\u0026rsquo;s a small platform. Custom local packages extend it. When a fix lands in crewlix_core, every app on the platform gets it. The architectural walkthrough of how those package boundaries are drawn is in the Crewlix clean-architecture deep dive.\nDependency discipline # The pub.dev ecosystem moves fast and dies quietly. A package that was the standard answer in 2020 is unmaintained by 2023.\nBefore I add a dependency, it passes a simple check: is it maintained, does it have tests, how big is its transitive surface, and could I vendor it in a day if the maintainer walked away. A 200-line utility that pulls in 40 transitive packages is not a small dependency — it\u0026rsquo;s a supply-chain surface bundled as one.\nFloat on ^, lock with the lockfile. I won\u0026rsquo;t pretend I pin every dependency to an exact version. The app floats on caret ranges with pubspec.lock committed — reproducible builds without dependency-update theatre. The lockfile is the source of truth for what actually shipped; the caret ranges keep security and patch updates reachable without a manual bump on every transitive. That\u0026rsquo;s the honest tradeoff, and it has held for seven years.\nThe hidden tax on a dependency that goes quiet. I\u0026rsquo;ve watched packages work fine for two years and then break the day a platform moves underneath them — an iOS deployment-target bump that a native binding was never recompiled against, and the app crashes on launch on the new iOS. The fix is ugly: find a replacement, rip out the old integration, re-validate the paths that depended on it. That\u0026rsquo;s the cost of a dependency that went quiet. It doesn\u0026rsquo;t break today. It breaks the day the platform moves.\nThe CI/CD spine # None of the above holds without a reproducible pipeline. A merged pull request on this app builds, signs, and ships to all three stores without a human at a console — Drone CI builds and uploads Android (Google Play and Amazon), Xcode Cloud handles iOS signing inside Apple\u0026rsquo;s trust boundary, Fastlane drives the store uploads, a Bitbucket Pipelines step runs an AI code review on every change, and Google Chat reports every transition. Android release builds are obfuscated with ProGuard. A git tag is the release button for a test build — anyone on the team pushes a tag and gets a signed APK in the distribution channel. The Android leg runs in minutes, not hours; the iOS leg on Xcode Cloud is in the same ballpark — fast enough that the pipeline is never the bottleneck on a release.\nThe pipeline is what makes the seven-year horizon possible. Every migration, every breaking change, every staged rollout goes through it. The NCC App is still shippable after seven years because the discipline was never \u0026ldquo;remember to test before release.\u0026rdquo; It was \u0026ldquo;the pipeline won\u0026rsquo;t let you release without going through the stages.\u0026rdquo; That\u0026rsquo;s the difference between a maintenance practice and a maintenance prayer.\nWhat I\u0026rsquo;d tell someone starting a long-term Flutter app today # Keep a hard seam between your domain layer and your presentation layer. Group by feature, not by file type. Pick one state manager and keep state inside its feature. Build the CI/CD pipeline on day one — not \u0026ldquo;when we have time.\u0026rdquo; And when the framework changes and your app doesn\u0026rsquo;t notice, recognize that as the architecture paying you back, not as permission to stop paying attention.\nIf you\u0026rsquo;re starting a Flutter app you intend to keep — or maintaining one that\u0026rsquo;s starting to fight back — that\u0026rsquo;s the problem I solve. Get in touch, and I\u0026rsquo;ll tell you straight whether your codebase is a six-month cleanup or a two-week hardening. The case study for the app behind this piece is in the NCC App case study.\nFrequently Asked Questions # How long can a Flutter app be maintained? Seven-plus years here, and counting — Flutter 1.x in 2019 through 3.41.x today, across three stores. The deciding factor isn\u0026rsquo;t Flutter; it\u0026rsquo;s keeping a seam between your domain logic and the framework so the codebase survives version churn.\nHow do you migrate Flutter state management (Riverpod 2 to 3) on a large app? One feature at a time, each one shippable, with zero functionality changes as the rule. On this app the Riverpod 3 migration surfaced 386 analyzer issues — mostly Freezed union methods and StateController to Notifier changes — resolved feature-by-feature rather than on a single heroic branch.\nHow do you ship a Flutter app to three stores? Drone CI builds and uploads Android (Google Play and Amazon), Xcode Cloud handles iOS signing inside Apple\u0026rsquo;s trust boundary, Fastlane drives the uploads, and a git tag triggers test builds — fully automated, with Google Chat reporting every transition.\nHow do you maintain a Flutter app with zero major incidents? Not by chasing 100% test coverage. By staged three-store rollouts (internal → closed → phased production), manual verification on the load-bearing flows, store vitals and an API uptime monitor, and conservative dependency upgrades — all gated by a reproducible CI/CD pipeline.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/maintaining-flutter-app-long-term/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect, and for the last 7+ years I\u0026rsquo;ve been the maintainer of the \u003ca\n  href=\"/projects/ncc-mobile-app/\"\u003eNCC App\u003c/a\u003e, the student-assistance app for Northwest Career College. It started on Flutter 1.x in 2019. The current build runs on Flutter 3.41.x stable, with Dart 3, Impeller, and Material 3. Same codebase. Same store listings — Google Play, App Store, Amazon Appstore. Zero major production incidents across the entire window.\u003c/p\u003e","title":"Maintaining a Flutter App for 7 Years","type":"insights"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/maintenance/","section":"Tags","summary":"","title":"Maintenance","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/migration/","section":"Tags","summary":"","title":"Migration","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/mobile/","section":"Tags","summary":"","title":"Mobile","type":"tags"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect shipping Flutter, React Native, and Expo to production for 7+ years. I maintain the NCC App across Google Play, the App Store, and Amazon Appstore, and I haven\u0026rsquo;t cut a release by hand in years. Three stores, three review processes, three device matrices — all on a pipeline I built once and have kept shippable since.\nThis is the practitioner\u0026rsquo;s guide I wish existed when I was wiring this up. Not \u0026ldquo;use GitHub Actions.\u0026rdquo; The actual stack: self-hosted Drone for Android build and deploy, Fastlane for store integration, Xcode Cloud for iOS, Bitbucket Pipelines for AI code review, tag-based test-build distribution, and Google Chat reporting. This is what a production-grade mobile release process looks like.\nWhy mobile CI/CD is its own discipline # \u0026ldquo;We have GitHub Actions\u0026rdquo; is something I hear from founders every week. It is not enough. Web CI/CD and mobile CI/CD share vocabulary and almost no actual constraints. Here\u0026rsquo;s what makes mobile different.\nSigning and provisioning. A web deploy is a static bundle behind a CDN. A mobile deploy is a cryptographically signed artifact, produced from a keychain that lives in one of three places — Apple Developer, Google Play App Signing, or Amazon\u0026rsquo;s uploader key — each with its own rotation, revocation, and access-control model. Lose control of the signing identity and you have an incident, not a deploy.\nThree store review processes. App Review at Apple is a human-in-the-loop gate with policy interpretations that change week to week. Google Play has a tiered track system (internal, closed, open, production) plus a review window that tightened sharply in 2024. Amazon Appstore is its own review with its own metadata. Shipping to all three triples your release surface area.\nDevice matrices. A web build runs in a browser. A mobile build runs on a Samsung A-series handset with a Mali GPU, an iPhone 12 mini, a Pixel 6a, a foldable. The matrix isn\u0026rsquo;t theoretical — I\u0026rsquo;ve shipped the Kahf Kids parental-control app where a platform-view ANR on specific GPUs was the reason we migrated frameworks. Your CI/CD has to validate against that matrix, not against an emulator that passes everything.\nNative toolchain coupling. iOS builds require macOS runners and a pinned Xcode. Android builds require the right NDK, the right Java LTS, and Gradle versions that don\u0026rsquo;t agree with each other across majors. A mobile runner is a zoo of pinned toolchains that drift every quarter.\nIf your team is still manually running flutter build ipa on someone\u0026rsquo;s laptop and uploading it through Transporter, you don\u0026rsquo;t have a release process. You have a ritual.\nThe stack # The stack I run on NCC — and on every mobile engagement where I own the pipeline — splits the work by what each tool does best. No single tool clears all three stores — orchestrators don\u0026rsquo;t speak the store APIs, Fastlane speaks the store APIs but doesn\u0026rsquo;t schedule builds, and Xcode Cloud runs iOS on Apple infrastructure but does nothing for Android. The division of labor is forced on you.\nDrone CI orchestrates the Android leg. It runs build and deploy as a pipeline declared in a .drone.yml checked into the repo. I self-host it on Proxmox behind a Cloudflare Tunnel because it keeps my secrets, build cache, and signing material inside infrastructure I control. Drone\u0026rsquo;s container-per-step model means each stage runs in an isolated image with the exact toolchain pinned — a custom Flutter CI image for the build, the Fastlane image for the deploy step. Each step runs as its own container, so the build step and the deploy step don\u0026rsquo;t share state unless I explicitly mount it, and the toolchain (Flutter, the Android SDK, NDK, Gradle, Kotlin) is baked into the image rather than resolved at build time — a runner that resolves its toolchain at build time breaks the day a transitive dependency bumps.\nFastlane is the store integration layer. It\u0026rsquo;s the only mature open-source tool that speaks the Google Play Developer API, the App Store Connect API, and the Amazon Appstore API coherently. Fastlane handles version bumps, metadata sync, screenshot uploads, signed-build production, and track promotion. There is no second-best option.\nXcode Cloud is the iOS-specific layer. The constraint is non-negotiable: you cannot produce a signed App Store ipa without macOS and a pinned Xcode. Self-hosting a Mac runner is a maintenance liability I no longer recommend — the hardware drifts, OS updates break the runner, somebody has to reboot it. Xcode Cloud runs on Apple\u0026rsquo;s infrastructure and keeps signing inside Apple\u0026rsquo;s trust boundary: no .p12 to leak, no provisioning profile to renew. The build hook Apple requires at clone time installs the pinned Flutter version, reads the release version, and runs the iOS release build unsigned — flutter build ios --release --no-codesign — and Xcode Cloud signs the artifact inside its own workflow and pushes it to TestFlight. The same merge that kicks off Drone\u0026rsquo;s Android leg is watched by Xcode Cloud, which builds and ships the iOS leg.\nBitbucket Pipelines runs a separate stage I\u0026rsquo;ll spend a section on: an AI code review of every pull request, before a human ever looks at it.\nGoogle Chat is the reporting layer. Every pipeline event — build started, passed, failed, published — posts into a team channel with links. This is the part most teams skip and it\u0026rsquo;s the part that makes the pipeline feel real to the rest of the company.\nThe end-to-end shape: a developer opens a pull request. Bitbucket Pipelines runs the AI code review on the diff and posts inline comments. The PR merges. Drone picks up the push, builds the Android release artifacts, and hands them to Fastlane, which uploads to Google Play and Amazon and promotes to the internal track. In parallel, Xcode Cloud builds the iOS leg and ships to TestFlight. Google Chat reports every transition. A human promotes from internal to production when the vitals look clean.\nAuto-deploy to Google Play, App Store, Amazon Appstore # This is where most teams give up and do it by hand. Here is what \u0026ldquo;all from a merge\u0026rdquo; actually means.\nVersion bumps. The release version is read from a committed version file and written into the build before the upload step runs. No developer touches the version manually. That eliminates an entire category of \u0026ldquo;I shipped 1.4.2 over 1.4.3 and now the store thinks the new build is older\u0026rdquo; incidents.\nSigning. The signing keys live in the CI environment — the Google Play service-account JSON and the Amazon uploader key in Drone\u0026rsquo;s secret store, the iOS signing inside Apple\u0026rsquo;s trust boundary on Xcode Cloud. The keys never touch a developer laptop. Access is auditable and revocable. When a contractor rotates off a project, I revoke one CI secret and the surface area is closed.\nStore metadata. Fastlane\u0026rsquo;s metadata directory holds the per-store, per-locale release notes, descriptions, keywords, and screenshots — checked into git, reviewed in PRs, synced to the stores on every release. A copywriter can update the Italian release note without asking an engineer. A marketing fix becomes a one-line PR.\nScreenshots. Fastlane\u0026rsquo;s snapshot tooling generates the per-device, per-locale screenshot set on every release. New device sizes ship from Apple and Google roughly twice a year — Fastlane produces the new artwork without a manual capture-and-upload pass.\nRollout. Android goes to the internal track, then closed, then a staged production rollout (10%, 50%, 100% over a few days). iOS goes to TestFlight, then a phased App Store release. Amazon goes to the live testing group, then production. Promotion is a Fastlane command, not a UI click. If a stage shows regression in the store vitals — Play Console or App Store Connect crash data — or the API uptime monitor blips, I roll back with another command.\nThe output: a developer who merges a PR doesn\u0026rsquo;t ask anyone \u0026ldquo;what happens next.\u0026rdquo; The merge is the release trigger. Promotion is the only human gate, and it\u0026rsquo;s a deliberate one — the moment a senior engineer looks at the vitals and decides the rollout is safe.\nTag-based test-build distribution # This is the single highest-leverage thing I\u0026rsquo;ve added to my pipeline in the last three years.\nPre-pipeline, the test-build conversation goes like this: a QA engineer or stakeholder needs to test the latest build. They ask a developer. The developer stops what they\u0026rsquo;re doing, switches to a release branch, runs the build locally, signs it, uploads it to Drive or TestFlight, and sends a link. The whole thing takes twenty minutes of human time per request. On a busy project it happens several times a day.\nPost-pipeline: any developer — junior or contractor — pushes a git tag matching the configured pattern. Drone sees the tag and runs a build-only pipeline for the signed Android APK; Xcode Cloud builds the iOS leg from the same change and ships it to TestFlight. Download links for both land in Google Chat. The person who asked for the test build gets a link in the channel minutes later. No engineer was interrupted.\nThe tag carries the platform — one prefix for Android, one for iOS, one for both — and the tag name maps directly back to a git ref, so a tester reporting a bug references a build that maps to an exact commit.\nThe deeper benefit: test builds stop being a scarce resource controlled by whoever has the signing keys. A QA engineer reproducing a bug can self-serve a build at the exact commit. A product manager can pull a build for a specific feature branch. The engineering team stops being a build factory.\nGoogle Chat notifications # The reporting layer is the part of CI/CD most teams under-build. I do it because a pipeline that runs in the background and emails you when it\u0026rsquo;s done is invisible to everyone except the developer who pushed the commit. A pipeline that posts into a shared channel is visible to the whole team.\nI use a notifier that posts a structured card into a Google Chat webhook on every Drone transition — status, branch, commit, author, and links to the build artifacts. A card that says Build started: feature/payment-redesign, pushed by Sara, then Build passed: artifact uploaded to Play internal track, or Build failed: deploy-amazon step, logs linked — gives the team what it actually needs. Product managers see how often features land. QA sees which builds are testable. The on-call engineer sees a failure the instant it happens, with the failing step linked.\nSlack, Teams, or Discord all work. What matters is that the pipeline talks to a shared room the team already lives in, instead of burying its output in a dashboard no one watches.\nAI code review in the pipeline # Every pull request on this codebase is reviewed before it ships — not just by a human, by an AI code-review stage that runs against the diff. The stage lives in Bitbucket Pipelines, separate from the Drone build/deploy pipeline. On PR open and on every push to an open PR, it pulls the diff, hands it to Claude Code through a z.ai API endpoint, and writes the review back as inline comments anchored to specific lines — the same way a human reviewer would.\nI wrote about how AI code review fits in the pipeline in detail elsewhere. The short version: every PR gets an automated first-pass review against the codebase\u0026rsquo;s conventions, the platform contracts, and the architectural patterns I\u0026rsquo;ve codified — before a human ever looks at it. Review time went from days to seconds for the first pass.\nIn the context of release automation, this matters more than it sounds. A staged rollout catches what only real devices and real users surface. AI code review catches what a senior would have caught on a read of the diff — missing teardown, state reaching across feature boundaries, a network call that bypasses the repository layer, an unhandled error path. The two are complementary. A pipeline that ships staged rollouts but skips the diff review is shipping on half a safety net.\nI cover the broader workflow in AI-assisted mobile development — generation is half the loop, review is the other half. If you\u0026rsquo;re building a release pipeline in 2026 and AI code review isn\u0026rsquo;t in it, you\u0026rsquo;re leaving the cheapest quality win on the table.\nThe 7-year maintenance story # CI/CD isn\u0026rsquo;t a thing you build once. It\u0026rsquo;s a thing you maintain for the life of the app. The NCC App is the longest-running proof I have — over seven years on Flutter, from Flutter 1.x to the latest stable, across three stores, with zero major production incidents in the maintenance window.\nThe discipline behind that record is a calendar: manual verification on the load-bearing flows (auth, payments, the API contract), staged rollouts on every store, conservative dependency upgrades, and a pipeline that makes all of it reproducible. I won\u0026rsquo;t pretend a deep automated test suite is part of that list — it isn\u0026rsquo;t, and I wrote honestly about why in maintaining a Flutter app for 7 years. When Flutter went null-safe in 2021, the migration went through the same pipeline as a feature release — staged, track-promoted, rolled back once when a leaf package broke, fixed, promoted again. The pipeline is what let those migrations happen without missing a release cadence.\nThe CI/CD story is the spine of it — without a reproducible pipeline, \u0026ldquo;conservative and staged\u0026rdquo; isn\u0026rsquo;t a strategy, it\u0026rsquo;s a hope.\nA pipeline running for seven years on the same app is a different artifact than one built last quarter. It has absorbed Flutter 1 through 3.x, the App Store\u0026rsquo;s evolving review policy, Google Play\u0026rsquo;s tightening review window, the switch to the App Store Connect API, and the deprecation of half a dozen Fastlane plugins. Maintaining the pipeline is itself engineering work — the part that doesn\u0026rsquo;t show up in a \u0026ldquo;we set up CI in a day\u0026rdquo; blog post.\nSelf-hosted vs cloud tradeoffs # I self-host Drone on Proxmox behind a Cloudflare Tunnel, and I also use cloud CI — Bitbucket Pipelines for the AI review stage, Xcode Cloud for iOS — depending on what each step needs. Founders ask me which to pick. The honest answer is \u0026ldquo;it depends on three things.\u0026rdquo;\nCost. Cloud CI is priced per minute. A team running 30 mobile builds a day, each 15–25 minutes, is burning real money monthly — and macOS minutes on every cloud provider cost roughly 5–10x what Linux minutes cost. Self-hosted Drone on a box you already own amortizes to near-zero per build. The crossover is roughly when cloud monthly spend would have paid for the hardware in 6–9 months.\nControl. Self-hosted means secrets never leave your infrastructure. Signing keys, App Store Connect API keys, Play service-account JSON — all stay on hardware you control. Cloud CI secrets are stored encrypted at the provider, and for most engagements that\u0026rsquo;s fine. For banking, healthcare, or regulated markets, self-hosted is often non-negotiable.\nOperational overhead. Self-hosted means you patch the host, upgrade Drone, maintain runner images, and debug the 3am \u0026ldquo;build server is down\u0026rdquo; pages. Cloud CI means none of that. If you don\u0026rsquo;t have someone who wants to own the build infrastructure as a first-class responsibility, self-hosted will rot. I have that role; I self-host the build/deploy leg. Most small teams don\u0026rsquo;t; they should use cloud.\nThe practical recommendation: start on cloud (Bitbucket Pipelines, GitHub Actions, or Codemagic) until build volume makes the cost real. Then move high-volume, secrets-sensitive workloads to self-hosted Drone and keep the cloud runner for what it does best — Xcode Cloud for iOS, a cloud step for AI review. A hybrid setup is not a compromise — it\u0026rsquo;s the correct architecture for most mid-sized mobile teams.\nOne note on Cloudflare Tunnel: do not expose your self-hosted Drone directly to the internet. The tunnel terminates at the edge and reaches your box over an outbound connection — no inbound ports, no exposed origin IP, Cloudflare\u0026rsquo;s auth in front. This is the only sane way to expose a self-hosted CI server in 2026.\nConclusion # Mobile CI/CD is a discipline of its own because mobile is — three stores, three review processes, three device matrices, a signing story, a toolchain that drifts every quarter. The stack I run — self-hosted Drone, Fastlane, Xcode Cloud, Bitbucket Pipelines AI review, Google Chat, tag-based test distribution — is the production answer I\u0026rsquo;ve refined over seven years. It\u0026rsquo;s the reason I can maintain the NCC App for seven years with zero major incidents, and the reason every engagement I take on ships from day one instead of accumulating release debt.\nIf you want to read the adjacent pieces, AI-assisted mobile development covers the generation half of the workflow, and how AI code review fits in the pipeline covers the review half. The seven-year maintenance story lives in maintaining a Flutter app for 7 years. The NCC App case study is the project behind all of it.\nIf your release process is the bottleneck on your mobile project — manual signing, ad-hoc test builds, releases that queue on one engineer\u0026rsquo;s laptop — that\u0026rsquo;s the problem I solve. My mobile CI/CD and release automation service is built around bringing this stack to your team, and a conversation is the cheapest way to find out if your project fits.\nFrequently Asked Questions # How do you automate Flutter releases to Google Play, the App Store, and Amazon? A self-hosted Drone pipeline builds the Android release and invokes Fastlane to sign and upload to the Google Play internal track and the Amazon live-testing group. Xcode Cloud builds the iOS leg and ships to TestFlight. A merge is the trigger; promotion to production is the only human gate.\nWhat\u0026rsquo;s in a production mobile CI/CD stack? An orchestrator for Android build and deploy (I self-host Drone), Fastlane for store integration, Xcode Cloud for iOS, a cloud step for AI code review on every PR, and a chat notifier (Google Chat) for every pipeline transition. Tag-based test-build distribution lets anyone on the team self-serve a build.\nDo I need a Mac to ship a Flutter iOS app? Yes. Apple requires macOS and a pinned Xcode to produce a signed App Store build. I use Xcode Cloud instead of self-hosting a Mac runner, because it keeps signing inside Apple\u0026rsquo;s trust boundary and removes the hardware-drift liability.\nSelf-hosted or cloud CI for a mobile app? Start on cloud until build volume makes per-minute pricing expensive, then move high-volume or secrets-sensitive workloads to self-hosted behind a Cloudflare Tunnel. Keep Xcode Cloud for iOS (Apple requires macOS) and a cloud step for AI review. Hybrid is the correct architecture for most mid-sized mobile teams.\nHow do you keep releases safe without slowing them down? Staged, reversible rollouts on every store — internal track, then closed testing, then a phased production release — with store-vitals monitoring and a one-command rollback, plus AI code review on every PR\u0026rsquo;s diff. The merge is fast; the promotion is the deliberate human gate.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/mobile-ci-cd-release-automation/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect shipping Flutter, React Native, and Expo to production for 7+ years. I maintain the \u003ca\n  href=\"/projects/ncc-mobile-app/\"\u003eNCC App\u003c/a\u003e across Google Play, the App Store, and Amazon Appstore, and I haven\u0026rsquo;t cut a release by hand in years. Three stores, three review processes, three device matrices — all on a pipeline I built once and have kept shippable since.\u003c/p\u003e","title":"Mobile CI/CD \u0026 Release Automation — The Complete Stack","type":"insights"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/platform-view/","section":"Tags","summary":"","title":"Platform-View","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/release/","section":"Tags","summary":"","title":"Release","type":"tags"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/webview/","section":"Tags","summary":"","title":"Webview","type":"tags"},{"content":"I\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. This is the story of moving Kahf Kids off Flutter after roughly eighteen months in production: why the call was forced, what I rebuilt, and how it shipped in three months with an AI-assisted workflow. Not the marketing version — the engineering one.\nThe product contract is the load-bearing thing. Kahf Kids is an Islamic education and entertainment app for children — curated video, Quran, books, games, courses — all behind a parent-controlled PIN. The promise to the parent is that a child watching a video cannot leave that flow. No \u0026ldquo;open in YouTube,\u0026rdquo; no outbound link, no escape gesture. A frozen player is not a regression on a product like that — it\u0026rsquo;s a broken promise. The kid is stuck, the parent isn\u0026rsquo;t in the room, and trust evaporates. Every architectural decision lives under that contract.\nEighteen months in, Play Console vitals started showing foreground ANRs (Application Not Responding) clustered on a narrow but real slice of devices: Samsung A-series and Redmi Note handsets on Mali GPUs, Realme C-series on PowerVR GE8320s. The signature was a main thread blocked on GPU work, no Dart on the stack, no fix reachable from pubspec.yaml. Compounding it, Flutter\u0026rsquo;s Android TV support was not production-grade — focus handling, D-pad navigation, and platform-view behavior on TV chipsets were all unreliable, and the family TV was a roadmap commitment, not a \u0026ldquo;maybe in year two.\u0026rdquo;\nTwo structural failures at the platform-view layer, both on the hardware my users actually owned. The full failure-mode investigation — device matrix, stack traces, the mitigations I tried and what each one actually did — is in the platform-view ANR deep dive. This piece is the decision and the rebuild that came out of it.\nThe decision — two structural misses, one rewrite # I don\u0026rsquo;t migrate on fashion. I\u0026rsquo;d shipped eighteen months of real revenue on Flutter, and the engine does a lot right on this product — for pure-Flutter UI (the home feed, the category grids, the book reader) Impeller paints smoothly on the cheap GPUs that dominate my user base. So I ran the analysis honestly, both directions.\nThe two structural constraints weren\u0026rsquo;t fixable in app code. The ANR lived at the intersection of three closed surfaces — Flutter\u0026rsquo;s platform-view composition logic, Android\u0026rsquo;s HardwareRenderer, and the OEM\u0026rsquo;s GPU driver blob — and on the devices dominating my emerging-market user base, that intersection was unfixable in any layer I owned. The mitigations were real but bounded:\nTexture Layer Hybrid Composition — the engine-level mitigation Flutter shipped for this class of bug. Reduced ANR frequency on Mali; did not eliminate it. On PowerVR the reduction was weaker. Forcing Virtual Display mode — patched the surface contention at the cost of broken touch input and accessibility in edge cases. Unacceptable for a parental-control overlay. A native Kotlin player behind a MethodChannel — would have patched one surface while leaving the structural shape (a Flutter-rendered overlay above a native View) standing. One WebView flow patched, every other WebView flow (HTML5 games, books) still taxed. And none of those gave me a real TV path. Staying meant shipping a known structural risk on the device matrix that dominated my user base, indefinitely. For a parental-control product, \u0026ldquo;ANRs less often\u0026rdquo; isn\u0026rsquo;t a product promise — it\u0026rsquo;s a regression tempo.\nThe options I weighed against migration:\nA full native Kotlin rewrite — doubles the team and kills iOS parity. Off the table for a three-month solo delivery. Bare React Native — fine-grained native control at the cost of the Expo toolchain. A trade I\u0026rsquo;d make if prebuild were fighting me, but on a greenfield rewrite prebuild is pure upside. Kotlin Multiplatform — didn\u0026rsquo;t solve the iOS/Web side cleanly for this product, and thinner on the module ecosystem I needed. The full decision matrix — platform contracts side by side, framework tradeoffs in both directions — is in the head-to-head comparison of Flutter, React Native, and Expo. The decision rule I use is simple: if your product\u0026rsquo;s core flow is video-, map-, or camera-heavy on low-end hardware and TV is on the roadmap, Flutter is the wrong fit. If your UI stays inside Flutter\u0026rsquo;s canvas, it\u0026rsquo;s the right one. Kahf Kids was the first shape.\nHonest framing, because this isn\u0026rsquo;t an anti-Flutter verdict. Flutter was — and is — the right tool for products whose core flow stays inside Flutter\u0026rsquo;s canvas. I keep Flutter in production on the NCC app and the Crewlix HR platform because their load-bearing screens are pure-Flutter UI. Right tool, right product. Kahf Kids was different: its core flow was a WebView under a Flutter overlay, and that\u0026rsquo;s the worst-case shape for this failure mode.\nThe rebuild — Expo ^57, React Native 0.86, TypeScript # The replacement is React Native 0.86 shipped the Expo ^57 way, in TypeScript. The architecture lives in a monorepo: an expo-app/ shell, a separate tv-app/ for the living-room target, and twelve shared internal packages — analytics, api, domain, data, local-data, i18n, player, quran-ui, startup, crash-reporting, brand-assets, and tv-space-navigation — wired in as local file: dependencies. A fix in the domain or player package ships to both the mobile and the TV app. The full architecture walkthrough is in the RN/Expo rebuild deep dive; this piece is the migration logic.\nThe platform-view tax, removed # The load-bearing architectural change is the one the diagram is about. In Flutter\u0026rsquo;s composition model, a native View — a WebView, a map, a camera preview — is a foreign view composed into a foreign renderer. That\u0026rsquo;s the platform-view handshake that was stalling on Mali and PowerVR driver builds. In React Native\u0026rsquo;s view tree (Fabric renderer, JSI, TurboModules), a native View is already a first-class view. There\u0026rsquo;s no foreign-renderer handshake to stall.\nThe parental-control video flow that was hanging on the device matrix simply stops hanging — not because the new code is faster, but because the class of failure that was producing the ANR doesn\u0026rsquo;t exist in this architecture. That\u0026rsquo;s not a performance improvement; it\u0026rsquo;s a failure surface removed. The before/after is the whole story in one picture: same product contract, same device matrix, no more ANR cluster.\nParental controls split by OS # Parental controls split by OS, because the sanctioned paths are different and there\u0026rsquo;s no upside in bridging them.\nAndroid — AccessibilityService. Watches package-launch events and blocks apps on the blocklist against the parent\u0026rsquo;s schedule. The OS-sanctioned path for app blocking, and the one that survives Google Play\u0026rsquo;s accessibility-policy review with the correct prominent-disclosure flow. Not a clever workaround — the sanctioned path. iOS — Screen Time framework. The only supported way to shield and time-limit a child\u0026rsquo;s device. FamilyControls for authorization, ManagedSettings to apply the shield, DeviceActivity to monitor schedules. There is no second path; Apple gives you one. Two implementations, one parental-control contract. Neither side fakes the other platform\u0026rsquo;s API.\nThe toolchain # The toolchain decisions are the ones that made the three-month timeline possible.\nExpo Router gives type-safe routes — a typo in a route name is a build error, not a runtime crash on a kid\u0026rsquo;s phone. Config plugins handle native behavior without a hand-patched Xcode project or a fight with Gradle. expo prebuild regenerates the ios/ and android/ native projects deterministically from app.json. The native project is an artifact, not a hand-maintained source of merge conflicts. EAS Build produces signed iOS and Android artifacts in the cloud without me opening Xcode — a real lever when your user base is on flaky networks and you need a hotfix in hours, not weeks. Where the WebView still lives # The WebView didn\u0026rsquo;t disappear — it just stopped being load-bearing for the player. HTML5 games and books still render through a WebView because it\u0026rsquo;s the right tool for HTML content. The difference is architectural: the WebView is no longer the surface under the parental-control overlay in the hot path. The hot path is now a native bridge; the WebView is a content renderer elsewhere in the tree. Right tool, right place.\nThe TV path opens # The TV path opened without a rewrite. Flutter\u0026rsquo;s TV gaps were blocking a living-room target; React Native\u0026rsquo;s react-native-tvos fork handles Apple TV, and Android TV works with native D-pad focus and remote key handling. Expo doesn\u0026rsquo;t add first-class TV support, but it doesn\u0026rsquo;t block it either — prebuild lets you wire in the TV targets when you need them without abandoning the rest of the toolchain.\nThe tv-app/ shell shares the domain, player, and analytics packages with the mobile app. The full TV architecture — including the spatial-navigation package that drives D-pad focus across the living-room UI — is in the RN/Expo rebuild deep dive.\nThe delivery — three months, AI-assisted # Three months, solo architecture with AI-assisted implementation. I want to be precise about what that means, because \u0026ldquo;AI-assisted\u0026rdquo; gets used as a buzzword. The actual workflow is a tight loop:\nDecompose the system into tasks small enough to be unambiguous. Prompt with context loaded — the relevant architecture, the platform contracts, and the failure modes I\u0026rsquo;ve already shipped and patched. Generate against the spec. Review every diff — not line-by-line nitpicking, but checking it against the platform contracts, the architecture, and the known failure modes. What the AI does. Generates React Native components, hooks, and screen wiring from the task specs. Writes TypeScript types from interface descriptions. Fills in the repetitive boilerplate — Expo Router route registrations, config plugin scaffolding, EAS Build configuration. Produces first-draft native bridge glue from documented API contracts. Runs refactor passes when I ask it to extract shared logic or rename across a module.\nWhat I own. Product design and mobile architecture. The platform-contract decisions that determine whether a parental-control app survives store review on either platform. The CI workflows. Context engineering — the decomposition and the prompts. Review of every diff before it merged. Every debugging session — because when an AccessibilityService blocks the wrong package on a Samsung A-series handset, the AI doesn\u0026rsquo;t know which API is lying. I do. The parental-control boundary and release quality were never on the AI\u0026rsquo;s side of the ledger.\nThe three-month timeline isn\u0026rsquo;t a magic trick. It\u0026rsquo;s the direct result of that loop being tight. Generation time collapsed from hours to minutes per task; my review time per task stayed roughly constant — I was just reviewing more drafts in a week than I would have hand-written in a month. Same senior hours, more shipped code.\nOutcome + reflections # The production app is live on Android, iOS, and Web. Video playback is stable across the device matrix that was ANR-ing before — same content, same parental-control boundary, no more foreground hangs on the Mali and PowerVR handsets that dominated the vitals. Child-safety features are validated on both stores: the Android AccessibilityService path with its prominent-disclosure flow survived Google Play\u0026rsquo;s accessibility-policy review; the iOS Screen Time path survived App Review. The TV path is open without a rewrite.\nTwo honest reflections from the migration.\nFirst, I\u0026rsquo;d stop trusting platform views for anything load-bearing earlier than I did. I patched the video flow three times before admitting the issue was architectural — not fixable in app code. That\u0026rsquo;s roughly six weeks I\u0026rsquo;d recover on a tougher project. The lesson, stated bluntly: if your product\u0026rsquo;s core flow is a platform view on low-end hardware, design the migration path on day one, not month eighteen.\nSecond, I\u0026rsquo;d have moved to the AI-assisted workflow sooner. Not because the tools are magic — because forcing yourself to write unambiguous task specs surfaces your own architectural vagueness before the code does. The discipline of decomposition is the actual lever; the generation speed is a side effect.\nTakeaways # Distinguish jank from context-lock before you migrate. Dropped frames are a tuning problem; a five-second main-thread block on a GPU fence is architectural. The mitigations for the two are different, and only one of them is fixable in app code. The diagnostic is in the ANR deep dive. Migrate when the product contract is at risk, not when the framework is unfashionable. The parental-control promise cannot survive a player that hangs. If your core flow is video-, map-, or camera-heavy on low-end hardware and TV is on the roadmap, the structural risk is real. If your UI stays inside Flutter\u0026rsquo;s canvas, stay. The right-tool question is per-product, not per-team. I keep Flutter in production on NCC and Crewlix because their load-bearing screens are pure-Flutter UI. Kahf Kids migrated because its core flow was a platform view. The framework analysis is in the comparison. Move the WebView out of the hot path; don\u0026rsquo;t delete it. A native bridge replaces the platform view where it\u0026rsquo;s load-bearing. The WebView is still the right tool for HTML5 games and books — just no longer the surface under the parental-control overlay. Use OS-sanctioned parental-control APIs, not clever workarounds. AccessibilityService on Android with the prominent-disclosure flow, Screen Time on iOS. Both survive store review. A cleverer path that bypasses either will not. Decomposition is the lever in AI-assisted delivery, not generation speed. Three months came from writing unambiguous task specs that exposed my own architectural vagueness before the code did. The generation is a side effect. If you\u0026rsquo;re scoping a Flutter → RN/Expo migration # If you\u0026rsquo;re staring at platform-view ANRs you can\u0026rsquo;t patch from pubspec.yaml, a TV target that isn\u0026rsquo;t materializing, or a Flutter codebase structurally boxed in by its core flow — the cheapest thing you can do is scope it with a senior architect before you write a line of production code. The decision is rarely \u0026ldquo;should we migrate\u0026rdquo;; it\u0026rsquo;s \u0026ldquo;is the failure mode structural, and what does the rebuild look like.\u0026rdquo; Get in touch.\n","date":"8 July 2026","externalUrl":null,"permalink":"/insights/migrating-kahf-kids-flutter-to-react-native-expo/","section":"Insights","summary":"\u003cp\u003eI\u0026rsquo;m Mohammad Jashem — a Senior Mobile Architect. This is the story of moving \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003eKahf Kids\u003c/a\u003e off Flutter after roughly eighteen months in production: why the call was forced, what I rebuilt, and how it shipped in three months with an AI-assisted workflow. Not the marketing version — the engineering one.\u003c/p\u003e","title":"Why I Migrated Kahf Kids from Flutter to React Native/Expo","type":"insights"},{"content":"","date":"8 July 2026","externalUrl":null,"permalink":"/tags/xcode-cloud/","section":"Tags","summary":"","title":"Xcode-Cloud","type":"tags"},{"content":"","date":"4 November 2025","externalUrl":null,"permalink":"/tags/commands/","section":"Tags","summary":"","title":"Commands","type":"tags"},{"content":"","date":"4 November 2025","externalUrl":null,"permalink":"/categories/docker/","section":"Categories","summary":"","title":"Docker","type":"categories"},{"content":" Docker Commands Reference: A Mobile Dev\u0026rsquo;s Cheatsheet # As a mobile app developer, I constantly find myself searching for Docker commands. Whether I\u0026rsquo;m setting up a React Native development environment, building Flutter apps in containers, or managing CI/CD pipelines, Docker is essential—but the commands are hard to remember. For the broader why — what a Docker-backed mobile release pipeline actually looks like end-to-end — I wrote up my mobile CI/CD release-automation setup. This comprehensive reference guide covers everything from daily essentials to advanced operations, with special focus on cleanup and system management commands that every developer needs.\nIntroduction: Why Docker for Mobile Development? # Docker has become indispensable for mobile developers because it:\nStandardizes development environments across teams Simplifies CI/CD pipelines for mobile apps Manages dependencies for complex build tools Isolates build processes for different platforms Enables consistent testing environments Most Essential Docker Commands (Daily Use) # Container Operations # # Run a container docker run nginx:latest # Run in detached mode with port mapping docker run -d -p 8080:80 nginx:latest # List running containers docker ps # List all containers (including stopped) docker ps -a # Stop a container docker stop \u0026lt;container_id\u0026gt; # Start a stopped container docker start \u0026lt;container_id\u0026gt; # Restart a container docker restart \u0026lt;container_id\u0026gt; # Remove a container docker rm \u0026lt;container_id\u0026gt; # View container logs docker logs \u0026lt;container_id\u0026gt; # Follow logs in real-time docker logs -f \u0026lt;container_id\u0026gt; # Execute commands in a running container docker exec -it \u0026lt;container_id\u0026gt; /bin/bash # Inspect container details docker inspect \u0026lt;container_id\u0026gt; # Copy files to/from container docker cp \u0026lt;container_id\u0026gt;:/path/to/file ./local_file docker cp ./local_file \u0026lt;container_id\u0026gt;:/path/to/file Docker Compose # # Start all services in detached mode docker compose up -d # Stop and remove all services docker compose down # Stop and remove services, networks, and volumes docker compose down --volumes # List all running containers docker compose ps # View logs from all services docker compose logs # Follow logs in real-time docker compose logs -f # View logs for specific service docker compose logs web # Execute command in a service container docker compose exec web bash # Build all services docker compose build # Pull latest images for all services docker compose pull # Restart specific service docker compose restart web Cleanup Commands (Daily Maintenance) # # Complete system cleanup (most used) docker system prune --all --volumes --force # Remove stopped containers docker container prune --force # Remove unused images docker image prune --all --force # Remove unused volumes docker volume prune --force # Clean build cache docker builder prune --all --force # Remove all stopped containers quickly docker rm -f $(docker ps -aq) # Remove all dangling images docker rmi -f $(docker images -f \u0026#34;dangling=true\u0026#34; -q) Image Management # # Build an image from Dockerfile docker build -t myapp:latest . # Pull an image from registry docker pull nginx:latest # Push an image to registry docker push myusername/myapp:latest # List local images docker images # Remove an image docker rmi nginx:latest # Tag an image docker tag myapp:latest myusername/myapp:v1.0 # Remove all unused images docker image prune --all --force Complete Docker CLI Reference # Container Management # Lifecycle Commands # # Create and run a new container docker run [OPTIONS] IMAGE [COMMAND] [ARG...] # Common run options: docker run -d # Detached mode docker run -it # Interactive with TTY docker run -p 8080:80 # Port mapping docker run -v /host:/container # Volume mounting docker run --name mycontainer # Container name docker run --rm # Remove container on exit docker run -e ENV_VAR=value # Environment variable docker run --restart always # Restart policy # Start one or more stopped containers docker start [OPTIONS] CONTAINER [CONTAINER...] # Stop one or more running containers docker stop [OPTIONS] CONTAINER [CONTAINER...] # Restart one or more containers docker restart [OPTIONS] CONTAINER [CONTAINER...] # Pause all processes in one or more containers docker pause CONTAINER [CONTAINER...] # Unpause all processes in one or more containers docker unpause CONTAINER [CONTAINER...] # Force stop and remove a container docker kill [OPTIONS] CONTAINER [CONTAINER...] Management Commands # # List containers docker ps [OPTIONS] docker ps -a # Show all containers docker ps -q # Only display container IDs docker ps --format \u0026#34;table {{.Names}}\\t{{.Status}}\u0026#34; # Custom format # Display detailed information on one or more containers docker inspect [OPTIONS] NAME|ID [NAME|ID...] # Display a live stream of container(s) resource usage statistics docker stats [OPTIONS] [CONTAINER...] # Display the running processes of a container docker top CONTAINER [ps OPTIONS] # Update configuration of one or more containers docker update [OPTIONS] CONTAINER [CONTAINER...] Removal Commands # # Remove one or more containers docker rm [OPTIONS] CONTAINER [CONTAINER...] docker rm -f # Force removal docker rm -v # Remove anonymous volumes docker rm --link # Remove specified links # Remove all stopped containers docker container prune docker container prune --force # Remove without confirmation docker container prune --filter \u0026#34;until=24h\u0026#34; # Filter by time Image Operations # Build and Distribution # # Build an image from a Dockerfile docker build [OPTIONS] PATH | URL | - docker build -t name:tag . # Tag image docker build -f Dockerfile.dev # Use different Dockerfile docker build --no-cache # Disable cache docker build --build-arg VAR=value # Build-time variables # Pull an image or a repository from a registry docker pull [OPTIONS] NAME[:TAG|@DIGEST] # Push an image or a repository to a registry docker push [OPTIONS] NAME[:TAG|@DIGEST] # Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE docker tag SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG] # Commit a new image from a container\u0026#39;s changes docker commit [OPTIONS] CONTAINER [REPOSITORY[:TAG]] Management and Inspection # # List images docker images [OPTIONS] [REPOSITORY[:TAG]] docker images -a # Show intermediate images docker images --digests # Show digests docker images --format \u0026#34;table {{.Repository}}\\t{{.Size}}\u0026#34; # Custom format # Display detailed information on one or more images docker image inspect [OPTIONS] IMAGE [IMAGE...] # Show the history of an image docker history [OPTIONS] IMAGE docker history --no-trunc # Don\u0026#39;t truncate output docker history --format \u0026#34;table {{.ID}}\\t{{.CreatedBy}}\u0026#34; # Custom format Export/Import and Removal # # Save one or more images to a tar archive docker save [OPTIONS] IMAGE [IMAGE...] docker save -o myapp.tar myapp:latest # Load an image from a tar archive or STDIN docker load [OPTIONS] docker load -i myapp.tar # Remove one or more images docker rmi [OPTIONS] IMAGE [IMAGE...] docker rmi -f # Force removal docker rmi --no-prune # Do not delete untagged parents # Remove unused images docker image prune docker image prune -a # Remove all unused images docker image prune --filter \u0026#34;until=24h\u0026#34; # Filter by time Volume Management (Complete Coverage) # Volume Operations # # Create a volume docker volume create [OPTIONS] [VOLUME] docker volume create mydata docker volume create --opt type=tmpfs --opt device=tmpfs mytmpfs # List volumes docker volume ls [OPTIONS] docker volume ls --format \u0026#34;table {{.Name}}\\t{{.Driver}}\u0026#34; # Custom format # Display detailed information on one or more volumes docker volume inspect [OPTIONS] VOLUME [VOLUME...] # Remove one or more volumes docker volume rm [OPTIONS] VOLUME [VOLUME...] docker volume rm -f # Force removal # Remove all unused local volumes docker volume prune docker volume prune --filter \u0026#34;label!=keep\u0026#34; # Filter by label Volume Usage in Containers # # Bind mount (host directory to container) docker run -v /host/path:/container/path nginx # Named volume docker run -v myvolume:/container/path nginx # Read-only volume docker run -v myvolume:/container/path:ro nginx # Volume with options docker run --mount type=volume,src=myvolume,dst=/container/path,volume-driver=local nginx # Anonymous volume (automatically created) docker run -v /container/path nginx # Tmpfs mount (memory-only) docker run --tmpfs /tmp nginx # Multiple volumes docker run \\ -v app-data:/data \\ -v ./logs:/var/log \\ -v /tmp:tmpfs \\ nginx System and Cleanup Commands (Comprehensive Coverage) # System Information # # Display system-wide information docker system info docker system info --format \u0026#39;{{json .}}\u0026#39; # JSON output # Show Docker filesystem space usage docker system df docker system df --verbose # Detailed output # Get real time events from the server docker system events [OPTIONS] docker system events --since=\u0026#34;2024-01-01\u0026#34; # Filter by time docker system events --filter \u0026#34;event=stop\u0026#34; # Filter by event type Cleanup Commands (The Most Important Ones) # # Remove unused data (containers, networks, images, build cache) docker system prune docker system prune --all # Remove all unused images, not just dangling docker system prune --volumes # Also remove unused volumes docker system prune --force # Remove without confirmation docker system prune --filter \u0026#34;until=24h\u0026#34; # Filter by time # Remove stopped containers docker container prune docker container prune --force docker container prune --filter \u0026#34;label!=maintain\u0026#34; # Remove unused images docker image prune docker image prune --all # Remove all unused images docker image prune --filter \u0026#34;until=7d\u0026#34; # Remove unused networks docker network prune docker network prune --force # Remove unused volumes docker volume prune docker volume prune --force docker volume prune --filter \u0026#34;label!=keep\u0026#34; # Clean build cache docker builder prune docker builder prune --all docker builder prune --filter \u0026#34;until=24h\u0026#34; Advanced Cleanup Strategies # # Remove all containers (running and stopped) docker rm -f $(docker ps -aq) # Remove all images docker rmi -f $(docker images -q) # Remove all dangling images docker rmi -f $(docker images -f \u0026#34;dangling=true\u0026#34; -q) # Remove all unused volumes docker volume rm $(docker volume ls -q -f \u0026#34;dangling=true\u0026#34;) # Remove all exited containers docker rm $(docker ps -q -f \u0026#34;status=exited\u0026#34;) # Comprehensive cleanup script docker system prune --all --volumes --force docker builder prune --all --force Network Management # Network Operations # # Create a network docker network create [OPTIONS] NETWORK docker network create mynet docker network create --driver bridge mynet docker network create --subnet=192.168.0.0/16 mynet # Connect a container to a network docker network connect [OPTIONS] NETWORK CONTAINER docker network connect mynet mycontainer # Disconnect a container from a network docker network disconnect [OPTIONS] NETWORK CONTAINER # List networks docker network ls [OPTIONS] docker network ls --format \u0026#34;table {{.Name}}\\t{{.Driver}}\u0026#34; # Custom format # Display detailed information on one or more networks docker network inspect [OPTIONS] NETWORK [NETWORK...] # Remove one or more networks docker network rm NETWORK [NETWORK...] # Remove all unused networks docker network prune docker network prune --force Security and Scanning Commands # # Docker Scout - Analyze images for vulnerabilities docker scout cves [IMAGE] docker scout cves nginx:latest # Generate SBOM (Software Bill of Materials) docker sbom [OPTIONS] IMAGE docker sbom --format spdx-json nginx:latest # Docker Trust - Sign and verify images docker trust sign IMAGE:TAG docker trust verify IMAGE:TAG docker trust inspect --pretty IMAGE:TAG # Security scanning with Docker Desktop docker scan [OPTIONS] IMAGE docker scan nginx:latest Build Tools and Advanced Commands # # Docker Buildx - Advanced build features docker buildx build [OPTIONS] PATH docker buildx build --platform linux/amd64,linux/arm64 . # Multi-platform docker buildx build --push . # Build and push # Builder management docker buildx create --name mybuilder docker buildx use mybuilder docker buildx ls docker buildx rm mybuilder # Docker Bake - Complex builds with bake files docker bake [OPTIONS] [TARGET] docker bake -f docker-bake.hcl mytarget # Docker Init - Create starter files (interactive, prompts for language/framework) docker init # New commands (Docker v27+) docker mcp [OPTIONS] COMMAND # Model Control Protocol docker model [OPTIONS] COMMAND # Docker Model Runner Docker Compose Commands (v2 Latest) # Core Commands # # Create and start containers docker compose up [OPTIONS] [SERVICE...] docker compose up -d # Detached mode docker compose up --build # Build images before starting docker compose up --force-recreate # Force recreate containers # Stop and remove containers, networks, volumes docker compose down [OPTIONS] docker compose down --volumes # Remove named volumes docker compose down --rmi all # Remove all images docker compose down --remove-orphans # Remove containers for services not defined # List containers docker compose ps [OPTIONS] [SERVICE] docker compose ps -a # Show stopped containers docker compose ps --format \u0026#34;table {{.Name}}\\t{{.Status}}\u0026#34; # Custom format # View output from containers docker compose logs [OPTIONS] [SERVICE] docker compose logs -f # Follow log output docker compose logs --tail=100 # Show last N lines docker compose logs --timestamps # Show timestamps Build and Pull Commands # # Build or rebuild services docker compose build [OPTIONS] [SERVICE...] docker compose build --no-cache # Build without cache docker compose build --parallel # Build in parallel # Pull service images docker compose pull [OPTIONS] [SERVICE...] docker compose pull --ignore-pull-failures # Don\u0026#39;t fail if pull fails # Watch for changes and rebuild/restart docker compose watch [OPTIONS] [SERVICE...] docker compose watch --quiet # Suppress output Service Management # # Execute a command in a running container docker compose exec [OPTIONS] SERVICE COMMAND [ARGS...] docker compose exec -T service sh # Non-interactive mode # Run a one-off command on a service docker compose run [OPTIONS] SERVICE [COMMAND] [ARGS...] docker compose run --rm service command # Remove container after command docker compose run -d service command # Run in background # Scale services (`scale` was removed in Compose v2 — pass --scale on up) docker compose up --scale web=3 --scale worker=2 -d # Restart services docker compose restart [OPTIONS] [SERVICE...] # Start services docker compose start [SERVICE...] # Stop services docker compose stop [OPTIONS] [SERVICE...] docker compose stop -t 30 # Timeout before stopping Configuration Commands # # Validate and view the Compose file docker compose config [OPTIONS] docker compose config --quiet # Validate only docker compose config --services # List services docker compose config --volumes # List volumes # Convert compose file to another format docker compose convert [OPTIONS] docker compose convert --format json # Convert to JSON # Create and start containers (but don\u0026#39;t start) docker compose create [OPTIONS] [SERVICE...] # Force stop service containers docker compose kill [OPTIONS] [SERVICE...] docker compose kill -s SIGINT # Send specific signal # Pause services docker compose pause [SERVICE...] # Unpause services docker compose unpause [SERVICE...] # Display running processes docker compose top [SERVICE...] # Show resource usage statistics docker compose ps --stats Cleanup Commands for Compose # # Remove stopped containers docker compose rm [OPTIONS] [SERVICE...] docker compose rm -f # Force removal docker compose rm -s # Stop containers before removal docker compose rm -v # Remove anonymous volumes # Remove unused images, containers, networks, and volumes docker compose down --volumes --rmi all --remove-orphans # Clean up build cache docker compose build --no-cache docker system prune --all --volumes --force Latest Docker Features (2024-2025) # Docker Engine v27 Highlights # # Enhanced shell completions docker completion bash # Generate bash completion docker completion zsh # Generate zsh completion # GPU support docker run --gpus all # Use all GPUs docker run --gpus \u0026#39;\u0026#34;device=0\u0026#34;\u0026#39; # Use specific GPU New Commands # # Docker Model Runner (v27+) docker model pull MODEL # Pull a model docker model run MODEL PROMPT # Run inference docker model ls # List models docker model rm MODEL # Remove model # Docker MCP (Model Control Protocol) docker mcp start # Start MCP server docker mcp stop # Stop MCP server docker mcp status # Check MCP status Mobile Development Specific Use Cases # React Native Development # # Create React Native development container docker run -it --rm \\ -v $(pwd):/app \\ -v /app/node_modules \\ -p 8081:8081 \\ --name rn-dev \\ reactnativecommunity/react-native-cli # Run React Native app in container docker run -it --rm \\ -v $(pwd):/app \\ -p 8081:8081 \\ reactnativecommunity/react-native-cli \\ npx react-native start # Build React Native app docker run -it --rm \\ -v $(pwd):/app \\ reactnativecommunity/react-native-cli \\ npx react-native build-android Flutter Development # # Flutter development environment docker run -it --rm \\ -v $(pwd):/app \\ -v /dev/bus/usb:/dev/bus/usb \\ --privileged \\ -p 8080:8080 \\ cirrusci/flutter:latest \\ flutter devices # Run Flutter app docker run -it --rm \\ -v $(pwd):/app \\ -v /dev/bus/usb:/dev/bus/usb \\ --privileged \\ cirrusci/flutter:latest \\ flutter run # Build Flutter app docker run -it --rm \\ -v $(pwd):/app \\ cirrusci/flutter:latest \\ flutter build apk --release CI/CD for Mobile Apps # # Multi-stage build for mobile app docker build -f Dockerfile.mobile -t myapp:latest . # Build with specific platform docker buildx build --platform linux/amd64,linux/arm64 -t myapp:multi . # Security scan before deployment docker scan myapp:latest # SBOM generation for compliance docker sbom --format spdx-json myapp:latest \u0026gt; sbom.json Cleanup Best Practices Section # When to Use prune vs rm # Use docker system prune when:\nYou want to clean up everything at once You\u0026rsquo;re running low on disk space You want to remove all unused resources You\u0026rsquo;re preparing for a fresh build Use individual rm commands when:\nYou need to remove specific resources You want to preserve some resources You\u0026rsquo;re debugging specific issues You need fine-grained control Safe Cleanup Strategies # # Safe cleanup script (interactive) #!/bin/bash echo \u0026#34;=== Docker Cleanup Script ===\u0026#34; echo \u0026#34;This will remove unused Docker resources.\u0026#34; echo \u0026#34;Press Ctrl+C to cancel or wait 5 seconds to continue...\u0026#34; sleep 5 # Remove stopped containers with confirmation echo \u0026#34;Removing stopped containers...\u0026#34; docker container prune --force # Remove unused images (keep last 24h) echo \u0026#34;Removing unused images older than 24 hours...\u0026#34; docker image prune --all --force --filter \u0026#34;until=24h\u0026#34; # Remove unused networks echo \u0026#34;Removing unused networks...\u0026#34; docker network prune --force # Don\u0026#39;t remove volumes by default (ask user) read -p \u0026#34;Remove unused volumes? This may delete data! [y/N] \u0026#34; -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then docker volume prune --force fi echo \u0026#34;Cleanup complete!\u0026#34; Automated Cleanup Scripts # # Daily cleanup cron job #!/bin/bash # Add to crontab: 0 2 * * * /path/to/daily-cleanup.sh LOG_FILE=\u0026#34;/var/log/docker-cleanup.log\u0026#34; DATE=$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) echo \u0026#34;[$DATE] Starting Docker cleanup...\u0026#34; \u0026gt;\u0026gt; $LOG_FILE # System cleanup (keep last 7 days) docker system prune --all --volumes --force --filter \u0026#34;until=168h\u0026#34; \u0026gt;\u0026gt; $LOG_FILE 2\u0026gt;\u0026amp;1 # Build cache cleanup docker builder prune --all --force --filter \u0026#34;until=168h\u0026#34; \u0026gt;\u0026gt; $LOG_FILE 2\u0026gt;\u0026amp;1 echo \u0026#34;[$DATE] Docker cleanup completed.\u0026#34; \u0026gt;\u0026gt; $LOG_FILE Storage Optimization Tips # # Use .dockerignore to reduce build context echo \u0026#34;node_modules/\u0026#34; \u0026gt;\u0026gt; .dockerignore echo \u0026#34;.git/\u0026#34; \u0026gt;\u0026gt; .dockerignore echo \u0026#34;*.log\u0026#34; \u0026gt;\u0026gt; .dockerignore # Use multi-stage builds to reduce image size # Example Dockerfile: FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production FROM node:18-alpine AS runtime WORKDIR /app COPY --from=builder /app/node_modules ./node_modules COPY . . CMD [\u0026#34;npm\u0026#34;, \u0026#34;start\u0026#34;] # Optimize layer caching # Order layers from least to most frequently changed # Use .dockerignore to exclude unnecessary files Production vs Development Cleanup # Development Environment:\n# Aggressive cleanup for development docker system prune --all --volumes --force docker builder prune --all --force docker volume prune --force Production Environment:\n# Conservative cleanup for production docker container prune --force --filter \u0026#34;until=24h\u0026#34; docker image prune --force --filter \u0026#34;until=168h\u0026#34; # Never prune volumes in production without backup Complete Quick Reference Cheatsheet # Daily Essentials (Most Used) # docker ps # List running containers docker ps -a # List all containers docker run -d -p 8080:80 nginx # Run container docker logs \u0026lt;container\u0026gt; # View logs docker exec -it \u0026lt;container\u0026gt; bash # Enter container docker build -t app . # Build image docker push username/app # Push to registry docker pull nginx # Pull image docker stop \u0026lt;container\u0026gt; # Stop container docker rm \u0026lt;container\u0026gt; # Remove container docker rmi nginx # Remove image Cleanup Commands (Critical) # docker system prune --all --volumes --force # Complete cleanup docker container prune --force # Remove stopped containers docker image prune --all --force # Remove unused images docker volume prune --force # Remove unused volumes docker builder prune --all --force # Clean build cache Volume Operations # docker volume create data # Create volume docker volume ls # List volumes docker volume inspect data # Inspect volume docker volume rm data # Remove volume docker run -v data:/app/data nginx # Use volume Docker Compose Essentials # docker compose up -d # Start services docker compose down --volumes # Stop and cleanup docker compose ps # List containers docker compose logs -f # Follow logs docker compose exec service bash # Enter service container docker compose build # Build images docker compose pull # Pull images Network Operations # docker network create mynet # Create network docker network ls # List networks docker network connect mynet container # Connect container docker network disconnect mynet container # Disconnect container docker network rm mynet # Remove network System Information # docker system info # System info docker system df # Disk usage docker version # Docker version docker info # System information Advanced Operations # docker buildx build --platform linux/amd64,linux/arm64 . # Multi-platform docker scan image:tag # Security scan docker sbom image:tag # Generate SBOM docker stats # Resource usage docker top container # Container processes Conclusion # This comprehensive Docker commands reference covers everything from daily essentials to advanced operations. The key takeaways for mobile developers are:\nMaster the basics first - focus on daily commands like run, ps, logs, exec Implement regular cleanup - use docker system prune and specific prune commands Understand volumes - they\u0026rsquo;re critical for data persistence in mobile development Use Docker Compose - simplifies multi-container development environments Stay updated - Docker v27 brings new features like Model Runner and improved security Keep this guide handy as a reference, and you\u0026rsquo;ll spend less time searching for commands and more time building amazing mobile applications. Docker\u0026rsquo;s power lies in its simplicity once you understand the core commands and patterns.\nThis reference guide is continuously updated with the latest Docker features and best practices. Bookmark it for quick access during your mobile development workflow.\n","date":"4 November 2025","externalUrl":null,"permalink":"/blog/complete-docker-commands-reference-guide/","section":"Notes","summary":"\u003ch1 class=\"relative group\"\u003eDocker Commands Reference: A Mobile Dev\u0026rsquo;s Cheatsheet \n    \u003cdiv id=\"docker-commands-reference-a-mobile-devs-cheatsheet\" class=\"anchor\"\u003e\u003c/div\u003e\n    \n    \u003cspan\n        class=\"absolute top-0 w-6 transition-opacity opacity-0 ltr:-left-6 rtl:-right-6 not-prose group-hover:opacity-100\"\u003e\n        \u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700 !no-underline\" href=\"#docker-commands-reference-a-mobile-devs-cheatsheet\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\n    \u003c/span\u003e        \n    \n\u003c/h1\u003e\n\u003cp\u003eAs a mobile app developer, I constantly find myself searching for Docker commands. Whether I\u0026rsquo;m setting up a React Native development environment, building Flutter apps in containers, or managing CI/CD pipelines, Docker is essential—but the commands are hard to remember. For the broader why — what a Docker-backed mobile release pipeline actually looks like end-to-end — I wrote up \u003ca\n  href=\"/insights/mobile-ci-cd-release-automation/\"\u003emy mobile CI/CD release-automation setup\u003c/a\u003e. This comprehensive reference guide covers everything from daily essentials to advanced operations, with special focus on cleanup and system management commands that every developer needs.\u003c/p\u003e","title":"Docker Commands Reference: A Mobile Dev's Cheatsheet","type":"blog"},{"content":"","date":"4 November 2025","externalUrl":null,"permalink":"/tags/docker-compose/","section":"Tags","summary":"","title":"Docker-Compose","type":"tags"},{"content":"","date":"4 November 2025","externalUrl":null,"permalink":"/categories/mobile-development/","section":"Categories","summary":"","title":"Mobile-Development","type":"categories"},{"content":"","date":"4 November 2025","externalUrl":null,"permalink":"/tags/mobile-development/","section":"Tags","summary":"","title":"Mobile-Development","type":"tags"},{"content":"Personal learning notes — solutions I write down so I don\u0026rsquo;t forget them. Flutter, DevOps, homelab, and the occasional rabbit hole.\nFor client-facing articles on hiring, architecture, and CI/CD, see Insights.\n","date":"4 November 2025","externalUrl":null,"permalink":"/blog/","section":"Notes","summary":"\u003cp\u003ePersonal learning notes — solutions I write down so I don\u0026rsquo;t forget them. Flutter, DevOps, homelab, and the occasional rabbit hole.\u003c/p\u003e\n\u003cp\u003eFor client-facing articles on hiring, architecture, and CI/CD, see \u003ca\n  href=\"/insights/\"\u003eInsights\u003c/a\u003e.\u003c/p\u003e","title":"Notes","type":"blog"},{"content":"","date":"4 November 2025","externalUrl":null,"permalink":"/tags/reference/","section":"Tags","summary":"","title":"Reference","type":"tags"},{"content":"","date":"3 November 2025","externalUrl":null,"permalink":"/tags/bitbucket/","section":"Tags","summary":"","title":"Bitbucket","type":"tags"},{"content":"","date":"3 November 2025","externalUrl":null,"permalink":"/categories/ci-cd/","section":"Categories","summary":"","title":"Ci-Cd","type":"categories"},{"content":"","date":"3 November 2025","externalUrl":null,"permalink":"/tags/deployment/","section":"Tags","summary":"","title":"Deployment","type":"tags"},{"content":" Why I Switched from Woodpecker to Drone for Flutter CI/CD # As a mobile developer managing multiple Flutter apps, I invested heavily in building a self-hosted CI/CD pipeline to automate deployments to Google Play Store, Apple App Store, and Amazon Appstore. The goal was simple: automate my nightly builds so I could sleep better while my apps get deployed automatically. What started as a promising setup with Woodpecker CI eventually became a source of constant frustration and sleepless nights, leading me to migrate to Drone CI. This is the technical story of that migration and the countless hours I lost troubleshooting issues that were supposed to save me time.\nThe Initial Promise of Woodpecker CI # When I first set up my CI/CD infrastructure, Woodpecker CI seemed like the perfect choice. As a community fork of Drone CI, it promised:\nOpen-source transparency with active community development Container-native architecture that works well with Docker Simple YAML configuration similar to docker-compose Multi-platform support including Bitbucket integration Lightweight resource usage suitable for my VPS setup The initial setup was straightforward, and my Flutter apps were building successfully. I configured automated pipelines for:\n# .woodpecker.yml example steps: build-android: image: cirrusci/flutter:stable commands: - flutter pub get - flutter build apk --release - flutter build appbundle --release deploy-play-store: image: ghcr.io/fastlane/tools:latest commands: - fastlane deploy_to_play_store when: branch: main The Reality: Critical Issues with Woodpecker # Webhook Reliability Problems with Bitbucket # The first major issue I encountered was missed webhook calls from Bitbucket. My builds would fail to trigger randomly, leaving me wondering if my deployments were actually running. After extensive debugging, I discovered this was a known issue documented in GitHub issue #5642: \u0026ldquo;Bitbucket webhook fails to trigger build sometimes.\u0026rdquo;\nThe symptoms were consistent:\nPush events would sometimes not trigger any build Pull request webhooks were frequently missed No error messages or logs indicating webhook reception failures Manual intervention required to trigger builds This was particularly problematic for my Flutter deployment pipeline, where I relied on automated nightly builds to deploy to multiple app stores. Missing a webhook meant missed deployments, directly impacting my release schedule.\nMemory Issues and Performance Degradation # As my Flutter project complexity grew, I began experiencing memory-related panic errors. The Woodpecker server would occasionally crash with out-of-memory errors, even though my VPS had 8GB RAM allocated. Investigation revealed several underlying issues:\nMemory Leak Issues (GitHub issue #4228): Woodpecker CI exhibited memory leak problems, especially when running on container-based environments Database Performance Bottlenecks: Since version 2.7, the agents table experienced massive load, causing performance degradation Resource Spikes: Flutter builds, which are already memory-intensive, would cause cascading failures across the system The memory issues were particularly frustrating because they manifested randomly. A build that worked fine one day would fail the next due to memory constraints, requiring manual restarts of the Woodpecker services.\nImpact on Flutter App Deployment Pipeline # My CI/CD setup was designed to handle the complete Flutter deployment workflow:\nAutomated Flutter builds for both Android (AAB/APK) and iOS (IPA) Code signing and certificate management for App Store deployments Multi-platform distribution to Google Play, Apple App Store, and Amazon Appstore Nightly builds with automated testing and deployment However, with Woodpecker\u0026rsquo;s unreliability, I found myself:\nLosing countless hours sitting at my desk waiting for builds that never triggered Missing sleep while manually checking if deployments happened throughout the night Manually triggering builds when webhooks were missed, often at 2 AM Restarting services when memory issues occurred, sometimes multiple times per night Manually deploying to multiple stores - a process that takes hours when done for multiple apps The irony was brutal: Woodpecker was supposed to save my sleep by handling deployments that happen at night. Instead, it destroyed my sleep. Deploying multiple Flutter apps to Google Play Store, Apple App Store, and Amazon Appstore manually can take hours - especially when you have to coordinate releases across different time zones and handle various store-specific requirements.\nWith Woodpecker, I had to sit and wait, constantly checking if anything went wrong. The automation I built included Google Chat notifications to clients when deployments completed successfully. But with Woodpecker\u0026rsquo;s unreliability, those notifications became meaningless because I couldn\u0026rsquo;t trust the underlying deployment process.\nThis defeated the entire purpose of having an automated CI/CD pipeline. Instead of \u0026ldquo;set it and forget it,\u0026rdquo; I had \u0026ldquo;set it and babysit it\u0026rdquo; - all night long.\nThe Migration to Drone CI # After months of frustration with Woodpecker, I decided to evaluate Drone CI as an alternative. The decision was influenced by several technical factors:\nDrone\u0026rsquo;s Superior Architecture # Drone CI, despite being the original project that Woodpecker forked from, offered several advantages:\nMature Codebase: Longer development history with more battle-tested stability Better Container Isolation: Improved resource management and security boundaries Superior Webhook Handling: More reliable webhook processing with better timeout management Production-Grade Reliability: Designed for enterprise environments with higher uptime requirements Bitbucket Integration Excellence # Drone\u0026rsquo;s Bitbucket Cloud integration proved to be significantly more reliable:\nDedicated Provider Support: Official Bitbucket Cloud provider with comprehensive documentation Robust Webhook Processing: Better error handling and retry mechanisms Consistent Event Triggers: Reliable build triggering for all repository events Enhanced Logging: Detailed webhook logs for debugging integration issues Resource Management Improvements # Drone\u0026rsquo;s container-native architecture provided better resource utilization:\nEfficient Memory Usage: No memory leaks or resource spikes Predictable Performance: Consistent build times regardless of concurrent workloads Better Scalability: Ability to handle multiple Flutter builds without system degradation The Migration Process # Pipeline Configuration Conversion # Converting from Woodpecker to Drone was straightforward since Drone\u0026rsquo;s YAML format is a superset of Woodpecker\u0026rsquo;s. Here\u0026rsquo;s a comparison:\nWoodpecker Configuration:\nsteps: build-flutter: image: cirrusci/flutter:stable commands: - flutter pub get - flutter test - flutter build appbundle --release when: branch: main Drone Configuration:\nkind: pipeline type: docker name: flutter-deployment steps: - name: build-flutter image: cirrusci/flutter:stable commands: - flutter pub get - flutter test - flutter build appbundle --release when: branch: - main - name: deploy-play-store image: ghcr.io/fastlane/tools:latest commands: - fastlane deploy_to_play_store depends_on: - build-flutter when: branch: - main Setup and Configuration # The Drone setup process involved:\nInstalling Drone Server: Docker-based installation with SQLite backend (simple and lightweight) Configuring Bitbucket OAuth: Setting up Bitbucket Cloud integration with proper webhook configuration Migrating Build Secrets: Transferring sensitive data like API keys and certificates Updating DNS and SSL: Pointing my CI domain to the new Drone instance Testing Pipeline Validation: Running sample builds to ensure everything worked correctly Results and Benefits # Immediate Improvements # The switch to Drone provided immediate benefits that directly addressed my pain points:\n100% Webhook Reliability: No more missed build triggers from Bitbucket - every single push now triggers a build Stable Resource Usage: Consistent memory utilization without leaks or spikes - no more 2 AM service restarts Better Build Performance: Faster build times due to improved resource management Finally Getting Sleep: Reliable automated deployments that work consistently without my intervention Trust in Notifications: Google Chat notifications to clients now mean something because the underlying process is reliable Long-term Benefits # Over several months of using Drone for Flutter CI/CD:\nZero Sleepless Nights: No more waking up to check if deployments happened Consistent Deployments: Every push to main branch successfully deploys to app stores Time Saved: Regained countless hours that were spent manually troubleshooting and deploying Better Monitoring: Enhanced logging and monitoring capabilities Scalability: Ability to add more Flutter projects without performance degradation Client Confidence: Reliable delivery timeline and notifications they can trust Technical Recommendations for Flutter CI/CD # Based on my experience, here are my recommendations for setting up reliable Flutter CI/CD:\nInfrastructure Planning # Allocate Adequate Resources: Flutter builds require significant memory (4GB+ recommended) Use SSD Storage: Faster I/O operations improve build times Network Reliability: Ensure stable internet connectivity for dependency downloads Backup Strategy: Regular backups of CI/CD configuration and build artifacts Pipeline Best Practices # Multi-stage Builds: Separate build, test, and deployment stages Artifact Management: Proper storage and versioning of build artifacts Security Considerations: Secure handling of API keys and certificates Dependency Caching: Cache Flutter and Gradle dependencies to speed up builds Platform Choice Criteria # When choosing a CI/CD platform for Flutter mobile development:\nWebhook Reliability: Critical for automated deployments Resource Management: Efficient memory and CPU utilization Mobile App Support: Native support for Flutter, Android, and iOS build processes Integration Capabilities: Strong integration with app stores and deployment tools Conclusion # The migration from Woodpecker to Drone CI transformed my Flutter deployment pipeline from a source of constant stress to a reliable, automated system. While Woodpecker CI showed promise as an open-source solution, the documented issues with Bitbucket integration and memory management made it unsuitable for production mobile app deployments.\nDrone CI provides the stability and reliability needed for automated Flutter app deployments to multiple app stores. The webhook reliability issues that plagued Woodpecker are completely resolved, and the memory management problems are eliminated. Most importantly, I can now sleep better at night knowing that my CI/CD pipeline will work consistently without manual intervention.\nFor any developer building a serious Flutter CI/CD pipeline, especially one targeting multiple app stores, I strongly recommend considering Drone CI over Woodpecker. The reliability and peace of mind are worth the migration effort.\nIf you\u0026rsquo;re setting up a Flutter CI/CD pipeline, the full production stack — Drone, Fastlane, Xcode Cloud, tag-based test builds — is in the complete mobile CI/CD guide. And if you\u0026rsquo;ve hit similar CI reliability walls, get in touch — happy to share what the migration actually took.\n","date":"3 November 2025","externalUrl":null,"permalink":"/blog/why-i-switched-from-woodpecker-to-drone/","section":"Notes","summary":"\u003ch1 class=\"relative group\"\u003eWhy I Switched from Woodpecker to Drone for Flutter CI/CD \n    \u003cdiv id=\"why-i-switched-from-woodpecker-to-drone-for-flutter-cicd\" class=\"anchor\"\u003e\u003c/div\u003e\n    \n    \u003cspan\n        class=\"absolute top-0 w-6 transition-opacity opacity-0 ltr:-left-6 rtl:-right-6 not-prose group-hover:opacity-100\"\u003e\n        \u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700 !no-underline\" href=\"#why-i-switched-from-woodpecker-to-drone-for-flutter-cicd\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\n    \u003c/span\u003e        \n    \n\u003c/h1\u003e\n\u003cp\u003eAs a mobile developer managing multiple Flutter apps, I invested heavily in building a self-hosted CI/CD pipeline to automate deployments to Google Play Store, Apple App Store, and Amazon Appstore. The goal was simple: automate my nightly builds so I could sleep better while my apps get deployed automatically. What started as a promising setup with Woodpecker CI eventually became a source of constant frustration and sleepless nights, leading me to migrate to Drone CI. This is the technical story of that migration and the countless hours I lost troubleshooting issues that were supposed to save me time.\u003c/p\u003e","title":"Why I Switched from Woodpecker to Drone for Flutter CI/CD","type":"blog"},{"content":"","date":"3 November 2025","externalUrl":null,"permalink":"/tags/woodpecker/","section":"Tags","summary":"","title":"Woodpecker","type":"tags"},{"content":"","date":"1 September 2024","externalUrl":null,"permalink":"/categories/case-study/","section":"Categories","summary":"","title":"Case-Study","type":"categories"},{"content":"","date":"1 September 2024","externalUrl":null,"permalink":"/tags/case-study/","section":"Tags","summary":"","title":"Case-Study","type":"tags"},{"content":" Context # Kahf Kids is an Islamic education and entertainment app for children — curated YouTube videos, games, Quran, books, and courses behind a parent-controlled PIN — alongside a doctor-consultation surface (DocTime / Healthcare) that puts a VoIP call inside the app. I architected the original Flutter build, shipped it on Android, iOS, and Web, and ran it in production for about eighteen months while the user base grew.\nThe product contract is unforgiving. A child watching a video cannot leave that flow — no outbound link, no \u0026ldquo;open in YouTube,\u0026rdquo; no escape gesture. The parent\u0026rsquo;s trust depends on that boundary holding. And most of those parents are families in emerging markets on low-end Android: MediaTek Helio and entry-level Snapdragon SoCs, Mali-G52/G57 or PowerVR GE8320 GPUs, two to three gigabytes of RAM, often on intermittent 3G/4G. A parental-control promise that survives a five-year-old phone with a struggling GPU is the actual bar. Architecture decisions live or die on that hardware.\nThis page is the hub for the Kahf Kids Flutter story — what shipped, why it ran for ~18 months, why I migrated it, and what replaced it. The spokes go deep on each piece.\nThe setup — three targets, one codebase # The Flutter build targeted three platforms from one Dart codebase: Android, iOS, and Web. Other platform folders appear in the repo because Flutter scaffolds them by default — they aren\u0026rsquo;t shipped targets, and I\u0026rsquo;m not going to claim breadth I didn\u0026rsquo;t deliver. Two Dart entry points wired the real targets: main.dart for mobile (Android and iOS), main_web.dart for the web build with the HTML renderer.\nContent features — video, Quran reader, books, courses, HTML5 games — run on all three targets. The platform-specific pieces are the ones that need OS-level hooks: parental controls and in-app payments (Play Billing, StoreKit). Those are mobile-only by design — the web app serves content, not OS-level enforcement. A child on web watches inside the curated player; a child on mobile gets the full parental-control boundary because the OS gives us the hooks to enforce it.\nThe load-bearing flow is the player. Kids watch curated video inside a locked-in player surface that blocks every exit path — no outbound link, no \u0026ldquo;open in YouTube,\u0026rdquo; no escape gesture. A child pressing Back or swiping up stays inside the app. That boundary is what enforces the parental-control promise. It\u0026rsquo;s also what put a Flutter platform view in the hot path on cheap GPUs — which becomes important later.\nThe architecture — feature-sliced Flutter # The codebase is feature-sliced. Each feature domain lives in its own folder, and most carry a common three-layer shape: one layer for state and orchestration, one for UI, one for local helpers. Heavier features add a platform-integration layer; lightweight content features collapse to a presentation layer alone. The slice grows with the feature, not to a template. A change inside one feature slice doesn\u0026rsquo;t reach sideways into another; blast radius stays inside the boundary.\nAlongside the slices, the codebase maintains its own Dart packages — some published to pub.dev, some forked or derived from upstream — covering Quran text and audio, form-factor classification, analytics, a TV-navigable keyboard, and the drawing surface. A couple of upstream packages are forked and pinned, tuned to the app\u0026rsquo;s WebView and auth flows, and a parental-control plugin owns the platform policy the features call into. Keeping these as packages, not folders inside the app, is what kept them testable in isolation and swappable when the migration came.\nI want to be honest about the shape. This isn\u0026rsquo;t a textbook four-layer Clean Architecture with separate domain/ and infrastructure/ folders on every feature — most slices don\u0026rsquo;t carry an infrastructure/ folder at all, and none carry a separate domain/ layer. It\u0026rsquo;s a pragmatic shape: the layers a feature actually needs, and no more. The Crewlix HR platform runs the full four-layer shape because HRM domain logic is heavy enough to earn it; Kahf Kids doesn\u0026rsquo;t, so it didn\u0026rsquo;t. If you want the contrast spelled out, it\u0026rsquo;s in the Crewlix clean-architecture deep dive.\nThe wiring around the slices is what you\u0026rsquo;d expect from a Flutter codebase of this generation:\nState: hooks_riverpod with riverpod_annotation and code generation, layered on flutter_hooks for widget lifecycle. Providers are typed; the boilerplate stays out of the way. Navigation: auto_route ^11.1.0 with codegen — type-safe routes, so a typo in a route name is a build error, not a runtime crash on a kid\u0026rsquo;s phone. Models: freezed + json_annotation. Immutable, copyable, union-ready. Removes a category of mutation bugs by construction. Networking: dio behind retrofit-generated API clients. Firebase: firebase_core, firebase_crashlytics, firebase_analytics, and messaging. Crashlytics is how I first saw the ANR clustering I\u0026rsquo;ll get to below. UI: cached_network_image, flutter_svg, iconsax_plus, and flash for transient UI. native_device_orientation drives the player rotation. Payments \u0026amp; i18n: flutter_inapp_purchase for the stores; easy_localization for the locale matrix. A couple of upstream packages are forked — I\u0026rsquo;ll describe them generically rather than by name. One exposes the player hooks the locked-in player surface needs (the upstream API was almost right, but the contract wasn\u0026rsquo;t quite ours). Another patches an edge case in the auth callback flow. Both are local path dependencies; both are the kind of fork you make when the upstream package is one PR away from doing what your product needs, and waiting on the maintainer isn\u0026rsquo;t on the roadmap.\nThe full architectural walkthrough — slice boundaries, the Riverpod provider patterns, the Auto Route tree, the two entry points, and how each feature\u0026rsquo;s three layers compose — is in the Kahf Kids Flutter clean-architecture deep dive.\nWhy I migrated — platform views and the TV gap # Eighteen months in, Play Console vitals started showing foreground ANRs (Application Not Responding) clustered on a narrow but real slice of low-end Android devices with specific Mali and PowerVR GPU drivers. The signature was a main thread blocked on GPU work, with no Dart on the stack. No fix in pubspec.yaml reaches a failure like that.\nThe root cause is structural. Flutter platform views — the bridge that lets you embed a native View like a WebView inside a Flutter widget tree — require GPU context synchronization between Flutter\u0026rsquo;s Impeller/Skia render thread and the platform view hierarchy. On well-behaved drivers that handshake is fast. On certain Mali and PowerVR driver builds the fence stalls; both threads park; the main thread transitively blocks on a lock one of them holds. Five seconds, and Android\u0026rsquo;s watchdog fires the ANR. The mitigation Flutter shipped (Texture Layer Hybrid Composition) reduced the frequency but didn\u0026rsquo;t eliminate it across the device matrix. Put the video WebView underneath the parental-control overlay — exactly the worst-case shape — and you hit the worst path on the worst hardware. The full engineering breakdown, with the device matrix and the mitigations I tried, is in the platform-view ANR investigation.\nThe second trigger was the living room. Flutter\u0026rsquo;s Android TV support was not production-grade — focus handling, D-pad navigation, and platform-view behavior on TV chipsets were all unreliable. For a kids\u0026rsquo; product where a meaningful chunk of engagement belongs on the family TV, that\u0026rsquo;s a structural miss.\nTwo structural failures, both at the platform-view layer, both on the hardware my users actually owned. The decision wasn\u0026rsquo;t fashion: the parental-control promise cannot survive a player that hangs, and TV was a roadmap commitment, not a \u0026ldquo;maybe in year two.\u0026rdquo; It landed on React Native with Expo. The full reasoning — including the options I weighed honestly against it (a native Kotlin player, bare RN, Kotlin Multiplatform) — is in the migration write-up.\nHonest framing, because this isn\u0026rsquo;t an anti-Flutter verdict. Flutter was — and is — the right tool for products whose core flow stays inside Flutter\u0026rsquo;s canvas. I keep Flutter in production on the NCC app and the Crewlix HR platform because their load-bearing screens are pure-Flutter UI. Kahf Kids was different: its core flow is a video WebView with a Flutter overlay, and that\u0026rsquo;s the worst-case shape for this failure mode. Right tool, right product.\nOutcome + reflections # The React Native / Expo replacement shipped in three months — solo architecture with AI-assisted implementation. Video playback is stable across the device matrix that was ANR-ing before. The parental-control boundary holds on both mobile platforms and survived store review. The TV path is open without a rewrite. The full architecture of the replacement is in the RN/Expo rebuild deep dive.\nTwo honest reflections on the Flutter build.\nFirst, I\u0026rsquo;d stop trusting platform views for anything load-bearing earlier than I did. I patched the video flow three times before admitting the issue was structural — not fixable in app code. That\u0026rsquo;s roughly six weeks I\u0026rsquo;d recover on a tougher project. The lesson, stated bluntly: if your product\u0026rsquo;s core flow is a platform view on low-end hardware, design the migration path on day one, not month eighteen.\nSecond, I\u0026rsquo;d move to the AI-assisted workflow sooner. Not because the tools are magic — because forcing yourself to write unambiguous task specs surfaces your own architectural vagueness before the code does. The three-month RN/Expo delivery was a direct result of that loop being tight. The Flutter build would have shipped cleaner and faster under the same workflow.\nFrequently Asked Questions # Why did Kahf Kids migrate off Flutter? Two structural reasons. Foreground ANRs clustered on Mali and PowerVR devices — Flutter platform views require GPU context synchronization between the Impeller/Skia render thread and the native view hierarchy, and certain closed-source driver builds on cheap Android hardware stall that fence. For a parental-control video flow, a hung player is a broken product, not a regression. Second, Flutter\u0026rsquo;s Android TV support was not production-grade. The full reasoning is in the migration write-up.\nWhat Flutter architecture did Kahf Kids use? A feature-sliced codebase. Each of thirty-plus feature domains lived in its own folder, most carrying three layers: one for state, one for UI, and one for local helpers. State was hooks_riverpod with codegen; navigation was auto_route with type-safe routes; models were freezed. Full walkthrough: the Kahf Kids Flutter clean-architecture deep dive.\nIs the Flutter app still live? The Flutter build has been superseded by the React Native/Expo rebuild. The product itself is live on Android, iOS, and Web — kahfkids.com, Google Play, App Store — but the Flutter binary is no longer the shipping artifact.\nWhat replaced the Flutter app? A React Native + Expo app, delivered in three months with an AI-assisted workflow. Native bridges replace the Flutter platform view in the video flow, eliminating the GPU context-lock surface. An AccessibilityService on Android and Apple\u0026rsquo;s Screen Time framework on iOS handle parental controls through the OS-sanctioned paths. Architecture: the RN/Expo rebuild deep dive.\nWould you ship Flutter again for a kids\u0026rsquo; app? Yes — if the core flow stays inside Flutter\u0026rsquo;s canvas. The Crewlix HR platform and the NCC app both stay on Flutter because their load-bearing screens are pure-Flutter UI. The decision rule: if your core flow is video-, map-, or camera-heavy on low-end hardware and TV is on the roadmap, Flutter is the wrong fit; otherwise it\u0026rsquo;s a strong choice. The head-to-head: Flutter vs React Native vs Expo.\nIf you\u0026rsquo;re facing a similar call — a Flutter architecture that has to hold on cheap hardware, or a migration off platform views — get in touch.\n","date":"1 September 2024","externalUrl":null,"permalink":"/projects/kahfkids-flutter-app-deep-dive/","section":"Projects","summary":"\u003ch2 class=\"relative group\"\u003eContext \n    \u003cdiv id=\"context\" class=\"anchor\"\u003e\u003c/div\u003e\n    \n    \u003cspan\n        class=\"absolute top-0 w-6 transition-opacity opacity-0 ltr:-left-6 rtl:-right-6 not-prose group-hover:opacity-100\"\u003e\n        \u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700 !no-underline\" href=\"#context\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\n    \u003c/span\u003e        \n    \n\u003c/h2\u003e\n\u003cp\u003eKahf Kids is an Islamic education and entertainment app for children — curated YouTube videos, games, Quran, books, and courses behind a parent-controlled PIN — alongside a doctor-consultation surface (DocTime / Healthcare) that puts a VoIP call inside the app. I architected the original Flutter build, shipped it on Android, iOS, and Web, and ran it in production for about eighteen months while the user base grew.\u003c/p\u003e","title":"Kahf Kids: Flutter App \u0026 RN/Expo Migration","type":"projects"},{"content":"","date":"1 September 2024","externalUrl":null,"permalink":"/tags/parental-controls/","section":"Tags","summary":"","title":"Parental-Controls","type":"tags"},{"content":"","date":"1 September 2024","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"17 January 2024","externalUrl":null,"permalink":"/tags/dart/","section":"Tags","summary":"","title":"Dart","type":"tags"},{"content":"I\u0026rsquo;ve shipped three production Flutter apps — the NCC App (seven years and counting), Kahf Kids, and Crewlix — and every one of them runs on Riverpod. Not because Riverpod is objectively best. Because after a decade of setState sprawl, Provider, Bloc, and Riverpod across real codebases, it\u0026rsquo;s the one that stopped costing me time.\nMost \u0026ldquo;Flutter state management\u0026rdquo; guides are a counter-app tour of four libraries. That\u0026rsquo;s not what you need if you\u0026rsquo;re shipping a real app. This is what I\u0026rsquo;d actually tell a team starting a Flutter product today — the decision that matters, the library I\u0026rsquo;d pick, the modern API (most tutorials still show the deprecated one), and the part nobody writes about that actually determines whether your app is fast.\nThe decision isn\u0026rsquo;t which library. It\u0026rsquo;s compile-time vs runtime. # Pick any two state-management libraries and the real difference collapses to one question: do you want the type system to catch your state bugs at compile time, or do you want the flexibility of resolving state at runtime?\nsetState is runtime, local, and invisible to the type system outside the widget. Provider is runtime resolution through InheritedWidget — Provider.of\u0026lt;T\u0026gt;() fails at runtime if T isn\u0026rsquo;t in the tree. Bloc is compile-time-ish (events and states are typed), but the wiring is runtime and the ceremony is high. Riverpod is compile-time resolution. A missing provider is a compile error. A provider used after dispose is a compile error. The graph is declared, not discovered. If you\u0026rsquo;ve ever shipped a release and had a ProviderNotFoundException surface on a user\u0026rsquo;s device in a code path your tests didn\u0026rsquo;t hit, you understand the appeal of moving that failure left into the compiler. That single property is why every app I maintain is on Riverpod. The rest is taste.\nThe libraries, honestly # setState — still correct for local widget state # setState is not a beginner\u0026rsquo;s tool you outgrow. It is the right tool for state that lives and dies inside one widget — animation controllers, a text field\u0026rsquo;s focus, a single toggle. Reaching for a global store to hold a widget-local value is a more common mistake than using setState for too long.\nclass _FilterChipState extends State\u0026lt;FilterChip\u0026gt; { bool _selected = false; void _toggle() =\u0026gt; setState(() =\u0026gt; _selected = !_selected); @override Widget build(BuildContext context) { return GestureDetector( onTap: _toggle, child: Chip(label: Text(widget.label), selected: _selected), ); } } If a piece of state is read by one widget and rewritten by that same widget, setState is finished. Stop.\nProvider — fine, but I wouldn\u0026rsquo;t start a 2026 app on it # Provider is InheritedWidget with ergonomics. It\u0026rsquo;s a perfectly good choice for a small app, and the NCC App started on Provider in 2019. The reason I wouldn\u0026rsquo;t start fresh on it today is the runtime resolution: context.read\u0026lt;T\u0026gt;() and context.watch\u0026lt;T\u0026gt;() depend on T being injected somewhere up the tree, and the compiler cannot verify that for you. On a 70-module app that runtime lookup becomes a real source of \u0026ldquo;works in dev, crashes in the field.\u0026rdquo;\nIf you\u0026rsquo;re already on Provider and it\u0026rsquo;s working, there\u0026rsquo;s no urgent reason to migrate. If you\u0026rsquo;re starting, start on Riverpod.\nBloc — powerful, ceremony-heavy, team-shaped # Bloc is the right call for a specific shape of team and product: large teams that want an auditable event trail, explicit state transitions, and a structure that survives engineer turnover. The cost is ceremony — every state change is an event class, a state class, and a handler. For a counter that\u0026rsquo;s absurd. For a payment flow touched by five engineers across three time zones, that ceremony is the documentation.\nI don\u0026rsquo;t use Bloc on the apps I own because they\u0026rsquo;re small-team and product-velocity-shaped. If I were building a regulated fintech app with a large rotating team, Bloc\u0026rsquo;s discipline would earn its keep.\nRiverpod — what I\u0026rsquo;d pick # Riverpod gives you compile-time safety, no BuildContext dependency (providers are usable in pure Dart — useful for tests, view models, and anything outside the widget tree), first-class async (FutureProvider, StreamProvider), and autoDispose for cache you don\u0026rsquo;t have to think about. The NCC App migrated Provider → Riverpod in year two and that provider layer has been stable through null safety, Material 3, the Impeller backend, and Dart 3. It\u0026rsquo;s the part of the codebase I touch least.\nThe modern Riverpod API (most tutorials show the old one) # Here\u0026rsquo;s the trap. Search \u0026ldquo;Riverpod tutorial\u0026rdquo; and most results still teach StateNotifier and StateNotifierProvider. Both are deprecated in Riverpod 2.x. If you\u0026rsquo;re writing a new app, use Notifier and NotifierProvider.\nThe old (deprecated) way # // Deprecated in Riverpod 2.x — don\u0026#39;t write this in a new app. class CounterNotifier extends StateNotifier\u0026lt;int\u0026gt; { CounterNotifier() : super(0); void increment() =\u0026gt; state++; } final counterProvider = StateNotifierProvider\u0026lt;CounterNotifier, int\u0026gt;( (ref) =\u0026gt; CounterNotifier(), ); The modern way # class Counter extends Notifier\u0026lt;int\u0026gt; { @override int build() =\u0026gt; 0; void increment() =\u0026gt; state++; } final counterProvider = NotifierProvider\u0026lt;Counter, int\u0026gt;(Counter.new); Same ergonomics, no deprecation, and it composes with code generation. With Riverpod\u0026rsquo;s codegen (what the NCC App actually uses), it\u0026rsquo;s even cleaner:\n@riverpod class Counter extends _$Counter { @override int build() =\u0026gt; 0; void increment() =\u0026gt; state++; } The pattern I use across all three production apps is a Notifier driving a sealed (Freezed) state union. Every async feature\u0026rsquo;s state is one of four cases, and the compiler enforces that the UI handles all of them:\n@freezed class FeedState with _$FeedState { const factory FeedState.initial() = _Initial; const factory FeedState.loadInProgress() = _LoadInProgress; const factory FeedState.loadSuccess({required List\u0026lt;Item\u0026gt; items}) = _LoadSuccess; const factory FeedState.loadFailure({required String message}) = _LoadFailure; } class Feed extends Notifier\u0026lt;FeedState\u0026gt; { @override FeedState build() { _load(); return const FeedState.initial(); } Future\u0026lt;void\u0026gt; _load() async { state = const FeedState.loadInProgress(); try { final items = await ref.read(feedRepositoryProvider).fetch(); state = FeedState.loadSuccess(items: items); } catch (e) { state = FeedState.loadFailure(message: e.toString()); } } } The UI does a switch over the state and the compiler tells you if a case is missing. That exhaustiveness is worth more than any library comparison.\nThe part that actually matters: where state lives, not which library holds it # Here\u0026rsquo;s the thing seven years taught me. The choice between Riverpod, Bloc, and Provider matters far less than where your state lives. The NCC App survived seven years of Flutter churn — null safety, Material 3, Impeller, Dart 3 records and patterns — without a rewrite, and the reason isn\u0026rsquo;t Riverpod. It\u0026rsquo;s that the state, the business rules, and the entities live in a layer that doesn\u0026rsquo;t know Flutter exists.\nIf your domain logic is reachable only through a Riverpod provider that lives in a presentation layer, then a Material 3 migration is a UI task and a Dart 3 migration is a syntax pass. If your domain logic is tangled into your widgets and your state holders, every framework bump is a rewrite. I wrote that whole story up in maintaining a Flutter app for 7 years.\nState management libraries are the plumbing. The architecture — the seam between your domain and the framework — is the load-bearing wall. Pick Riverpod, pick Bloc, either is fine. Get that seam right, or no library will save you.\nRebuild discipline: the performance lever nobody mentions # The second thing that determines whether your app feels fast is rebuild scope, and this is where teams misuse Riverpod. Two rules:\nWatch the smallest possible slice. ref.watch(provider) rebuilds the whole widget when the provider changes. If a provider holds an object with five fields and your widget only reads one, use select to rebuild only when that one field changes:\n// Rebuilds on any user change — wasteful. final user = ref.watch(userProvider); return Text(user.name); // Rebuilds only when name changes. final name = ref.watch(userProvider.select((u) =\u0026gt; u.name)); return Text(name); Never watch inside event handlers. ref.watch is for build. Inside onTap, onPressed, callbacks — use ref.read. watch in a handler registers a listener that outlives the intent and causes rebuilds you didn\u0026rsquo;t ask for:\n// Wrong — watch in a handler. onPressed: () =\u0026gt; ref.watch(cartProvider).addItem(), // Right — read in a handler. onPressed: () =\u0026gt; ref.read(cartProvider.notifier).addItem(), These two disciplines, on a 70-module app, are the difference between a list that scrolls at 60fps and one that stutters every time a background timer ticks. The library doesn\u0026rsquo;t fix this for you. The rebuild graph does.\nDecision matrix, for real # Situation What I\u0026rsquo;d pick Why State used by one widget only setState No reason to leave the widget Small app, solo dev, shipping fast Riverpod Compile-time safety, low ceremony 70-module app, long horizon Riverpod + Freezed + codegen Exhaustive states, survives churn Large team, regulated domain Bloc Event trail, enforced structure You\u0026rsquo;re already on Provider, it works Stay Migration cost \u0026gt; benefit The \u0026ldquo;start on Provider, graduate to Riverpod or Bloc\u0026rdquo; advice in older guides made sense in 2020. In 2026, with Riverpod\u0026rsquo;s codegen and compile-time graph, I\u0026rsquo;d start there and skip the graduation.\nWhat I\u0026rsquo;d tell a team today # Pick Riverpod. Use the modern Notifier API, not the deprecated StateNotifier. Drive your async state through sealed unions so the compiler enforces the loading/error/success cases. Spend more time on where your state lives — behind a domain seam — than on which library holds it. And get rebuild discipline right early: select for granular reads, read in handlers, watch only in build.\nThe library is a small decision. The seam and the rebuild graph are the big ones. Get those right and Flutter state management stops being a problem you think about — which, after seven years, is exactly the goal.\nIf you\u0026rsquo;re scoping a Flutter app and want the architecture right from day one — the state layer, the domain seam, the rebuild discipline — that\u0026rsquo;s the work I do. Get in touch, or read the guide to hiring a Flutter developer in 2026.\n","date":"17 January 2024","externalUrl":null,"permalink":"/blog/flutter-state-management/","section":"Notes","summary":"\u003cp\u003eI\u0026rsquo;ve shipped three production Flutter apps — the \u003ca\n  href=\"/projects/ncc-mobile-app/\"\u003eNCC App\u003c/a\u003e (seven years and counting), \u003ca\n  href=\"/projects/kahfkids-flutter-app-deep-dive/\"\u003eKahf Kids\u003c/a\u003e, and \u003ca\n  href=\"/projects/crewlix-hr-app/\"\u003eCrewlix\u003c/a\u003e — and every one of them runs on Riverpod. Not because Riverpod is objectively best. Because after a decade of \u003ccode\u003esetState\u003c/code\u003e sprawl, Provider, Bloc, and Riverpod across real codebases, it\u0026rsquo;s the one that stopped costing me time.\u003c/p\u003e","title":"Flutter State Management: What I'd Actually Pick in 2026","type":"blog"},{"content":"","date":"17 January 2024","externalUrl":null,"permalink":"/tags/state-management/","section":"Tags","summary":"","title":"State-Management","type":"tags"},{"content":"","date":"15 January 2024","externalUrl":null,"permalink":"/about/","section":"Mohammad Jashem — Senior Full-Stack Mobile Engineer","summary":"","title":"About Mohammad Jashem","type":"page"},{"content":"","date":"15 January 2024","externalUrl":null,"permalink":"/tags/architecture/","section":"Tags","summary":"","title":"Architecture","type":"tags"},{"content":"","date":"15 January 2024","externalUrl":null,"permalink":"/categories/personal/","section":"Categories","summary":"","title":"Personal","type":"categories"},{"content":" Context # Crewlix is an HR management mobile app for Android and iOS. It digitizes the daily HR operations of a modern workforce — attendance, leave, employee profiles, internal communication — behind a single login on a phone. I built it in Flutter, end to end, and shipped it to both stores.\nHRM software has a specific shape. The business logic is heavy and full of edge cases — approval hierarchies, leave policies, role-based visibility — but the UI is repetitive: forms, lists, detail screens, calendars, approval flows. There aren\u0026rsquo;t many screens where you fight the framework to render something custom. That shape matters, because it\u0026rsquo;s what made the technology choice straightforward — and it\u0026rsquo;s the shape that made one architectural decision pay off far beyond this single app.\nWhy Flutter # The honest reason Flutter fit Crewlix: an HRM app is mostly CRUD and list/detail views. There\u0026rsquo;s no video surface to lock down, no custom charting engine, no platform view doing heavy GPU work. When the view layer is this conventional, Flutter\u0026rsquo;s \u0026ldquo;everything is a widget\u0026rdquo; model is a productivity gain, not a tax. One Dart codebase, one team, both platforms, and the rendering differences between iOS and Android stop being your problem.\nThis is the same trade-off I weighed the other way on a different product — when a Flutter platform view started ANR-ing on low-end Mali GPUs, I migrated that app to React Native/Expo. The point of comparing Flutter, React Native, and Expo head-to-head isn\u0026rsquo;t to crown a winner; it\u0026rsquo;s to know which pillar of the trade-off your product actually loads on. Crewlix loads on the pillar where Flutter wins: conventional views, fast iteration, one codebase.\nScope # The shipped product covers:\nAttendance — GPS-verified check-in and check-out for office boundaries, with local draft persistence and connectivity-aware syncing. Leave management — approval workflows, balances, and policy types. Employee profiles — directory, search, role-based access, and document handling (including in-app PDF rendering). Social feed — posts, comments, mentions, media attachments, and real-time updates. This is where the rich text editor lives. Push notifications — attendance reminders, approval status, and announcements. Deep linking — Universal Links (iOS) and Android App Links route from notifications and shared links into specific screens. Architecture — one platform, two apps # Crewlix follows a feature-sliced Clean Architecture. Each HR domain is its own slice, and every slice carries the same four layers: presentation, application, domain, infrastructure. A change inside the leave slice doesn\u0026rsquo;t reach sideways into the feed slice — the blast radius of any change stays inside its boundary.\nState is Riverpod with code generation, so providers are typed and the boilerplate stays out of the way. Navigation is AutoRoute, which gives compile-time-safe routing — a typo in a route is a build error, not a runtime crash. Every data model is a Freezed class: immutable, copyable, union-ready, which removes an entire category of mutation bugs. Networking is Dio behind Retrofit-generated API clients.\nThe part that ended up mattering most is what I extracted as packages. Four reusable packages sit under the app: crewlix_core, crewlix_auth, crewlix_people, and crewlix_feed. The dependency hierarchy is enforced at the package level rather than by convention — which is the difference between \u0026ldquo;we usually don\u0026rsquo;t import the wrong thing\u0026rdquo; and \u0026ldquo;you literally can\u0026rsquo;t.\u0026rdquo;\nThat decision paid off beyond Crewlix. Those same packages now power a second production app. A fix that lands in crewlix_core ships to both products. One architecture, two apps, one source of truth for the shared core.\nIf you want the full architectural walk-through — the package boundaries, the state/routing/modeling choices, and the editor internals — it\u0026rsquo;s in the Crewlix clean-architecture deep dive.\nThe hard part — a real rich text editor in the social feed # The social feed is where the engineering got real. The requirement was a feature-complete rich text editor inside the app: bold, italic, headings, bullet and numbered lists, inline mentions, and image embeds — editable on a phone, with a toolbar, and performance that held up on long posts.\nFlutter does not ship this. I built it on flutter_quill, whose document model is a Quill delta (a list of operations), not an HTML string or a hand-rolled node tree. The real work was everything around that delta:\nA delta ↔ HTML ↔ Markdown pipeline. The server stores posts as HTML and Markdown; the editor edits a delta. vsc_quill_delta_to_html renders delta to HTML; flutter_quill_delta_from_html parses HTML back to a delta; markdown_quill and html2md bridge Markdown in and out. The round-trip has to be lossless, or a post silently corrupts on the second edit. Custom embeds for mentions and inline images. Mentions and images aren\u0026rsquo;t text with syntax bolted on — they\u0026rsquo;re first-class embed types with their own builders, so they participate in selection, cursor movement, and formatting predictably. Performance on long posts. As a document grows, recomputing the conversion on every keystroke stops being viable. An HTML-to-delta cache keeps the expensive transform off the hot path. I\u0026rsquo;ll be blunt: a feature-complete rich text editor in Flutter is genuinely hard, even starting from Quill. The packages get you part of the way in a day; the lossless round-trip, the custom embeds, and the long-post performance take weeks. If you\u0026rsquo;re scoping one, budget for it.\nOutcome # Crewlix shipped to both stores and is live.\ncrewlix.com Google Play App Store The architecture has held up as modules kept getting added — and the fact that the same core now runs a second product is the actual test of an app\u0026rsquo;s foundation. The roadmap never stops growing; the floor has to.\nIf you\u0026rsquo;re building an HRM or internal-tool mobile app and want the architecture set up right the first time — especially the hard editor and workflow pieces — get in touch.\n","date":"1 January 2023","externalUrl":null,"permalink":"/projects/crewlix-hr-app/","section":"Projects","summary":"\u003ch2 class=\"relative group\"\u003eContext \n    \u003cdiv id=\"context\" class=\"anchor\"\u003e\u003c/div\u003e\n    \n    \u003cspan\n        class=\"absolute top-0 w-6 transition-opacity opacity-0 ltr:-left-6 rtl:-right-6 not-prose group-hover:opacity-100\"\u003e\n        \u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700 !no-underline\" href=\"#context\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\n    \u003c/span\u003e        \n    \n\u003c/h2\u003e\n\u003cp\u003eCrewlix is an HR management mobile app for Android and iOS. It digitizes the daily HR operations of a modern workforce — attendance, leave, employee profiles, internal communication — behind a single login on a phone. I built it in Flutter, end to end, and shipped it to both stores.\u003c/p\u003e","title":"Crewlix HR App: Flutter Clean Architecture","type":"projects"},{"content":"","date":"1 January 2023","externalUrl":null,"permalink":"/tags/hrm/","section":"Tags","summary":"","title":"Hrm","type":"tags"},{"content":" At a glance # A student app built once to last.\n7+ years maintained, from Flutter 1.x through the latest stable 70+ feature modules in a single codebase 3 app stores — Google Play, App Store, and Amazon Appstore Solo-owned for six years, then a team Zero major production incidents across the whole window One Clean Architecture refactor, made in year two — still in place This is the project I point to when someone asks what \u0026ldquo;senior\u0026rdquo; means in mobile engineering. Not the flashiest build. The one that stays up.\nContext # The NCC App is the student-assistance app for Northwest Career College — a private career college in Las Vegas running programs across healthcare, trades, and business. Students use it for class schedules, academic advising, attendance, payments, financial aid, campus events, announcements, the campus map, and the dozen-odd services a modern campus runs on. It\u0026rsquo;s the app a student opens on the first day of class and keeps on their home screen through graduation.\nIt ships on Android (Google Play), iOS (App Store), and Amazon Appstore. Three stores, three review processes, three device matrices. That detail shapes everything that follows — you don\u0026rsquo;t ship to three stores by hand for seven years and stay sane.\nThe success metric here isn\u0026rsquo;t download counts. It\u0026rsquo;s that every student relies on the app daily, and it has to just work.\nFrom Provider to Clean Architecture # The app didn\u0026rsquo;t start clean. It started fast. The first version was Flutter with the Provider package, and features shipped quickly — which was the right call at the time. But by year two it was clear the codebase wouldn\u0026rsquo;t survive the distance. State lived wherever I\u0026rsquo;d last put it. Features reached into each other\u0026rsquo;s internals. Adding the tenth feature meant touching the first nine.\nThe real risk was never features. It was maintainability.\nSo in year two I made the call to rebuild the app to Clean Architecture — mid-flight, without stopping the release cadence. Not a rewrite on a dark branch for six months. A migration: one feature at a time, layered passes, the app shippable every sprint. The most tangled features got pulled behind proper boundaries first; the rest followed.\nThe payoff is the whole point of this article: the same architecture has held for more than five years since, absorbing Dart null safety, Material 3, the Impeller rendering backend, and Dart 3 records and patterns — none of them as rewrites, all of them as contained migrations.\nThe framework changes. The app\u0026rsquo;s job doesn\u0026rsquo;t. Keeping a seam between the two is what let one solo engineer hold this together for seven years.\nFlutter Clean Architecture, Under the Hood # This is the part engineers care about: the actual shape of the codebase, and why each choice earned its place. The pattern is feature-first Clean Architecture — every feature is a self-contained module with the same five layers.\nLayer Responsibility Why it\u0026rsquo;s there domain Entities and business rules Zero dependencies — survives framework swaps application Use cases and state notifiers Orchestrates UI and data; holds the feature\u0026rsquo;s logic infrastructure API services, repositories, DTOs Network and persistence details, kept out of the UI presentation Pages and widgets Talks only to notifiers, never to DTOs shared Riverpod providers Dependency injection for the feature The boundaries are the architecture. Presentation never touches DTOs. Infrastructure never knows about the UI. Each layer can change without the others noticing.\nState and data flow # State: Riverpod NotifierProvider driving Freezed state unions. Each feature\u0026rsquo;s state is a sealed set — initial → loadInProgress → loadSuccess → loadFailure. Exhaustive and typo-proof. Repositories: interface defined in domain, implemented in infrastructure. The UI depends on the interface, not the network. DTO → Entity: the API returns data-transfer objects; repositories convert them to domain entities. The UI never sees raw API shapes. Errors: the dartz package\u0026rsquo;s Either\u0026lt;Failure, Success\u0026gt;. Failures are values the type system tracks, not exceptions scattered through call stacks. Navigation, networking, and the rest # Navigation: auto_route generates type-safe routes, with deep linking and route observers wired for analytics. A bad route is a compile error, not a runtime crash. Networking: three dio clients — one authenticated for the main API, one for the content-management system, one authless for general calls. Auth interceptors live in one place, not copy-pasted per request. Code generation: Freezed, JSON serialization, and auto_route all run through build_runner. Boilerplate is generated, not hand-written — less drift, fewer bugs. Shared platform: auth, feed, and core utilities live in reusable shared packages — extracted once and reused across apps. This is a platform, not a one-off. Real-time and release hardening: Pusher channels carry live updates; Firebase handles messaging, analytics, and remote config; release builds are obfuscated with split debug symbols for size and symbolication. Scale: 70+ Flutter Modules, Solo-Owned # The architecture\u0026rsquo;s real test is volume. The app has over seventy feature modules, grouped by domain:\nAcademic: advising, attendance, class schedule, announcements, LMS integration, certification, externship, financial ledger. Campus services: campus map, safety, parking, facilities and maintenance requests, staff directory. Student services: payments, profile, digital ID and QR code, document upload, transcript requests, veterans\u0026rsquo; benefits. Communication: live feed, feedback, IT support, messaging, absence reporting. I maintained all of it solo for six years — one architect holding the whole graph coherent across three stores. The architecture is the only reason that\u0026rsquo;s possible: every module reads the same way, so context never resets when you move between features. A team joined over the last year, and onboarding was straightforward for exactly that reason — a mid-level engineer can pick up any module and keep it shipping, because the same pattern repeats everywhere.\nThe Apple Review Battle # Students sign in with Google. There is no \u0026ldquo;Sign in with Apple.\u0026rdquo;\nThat\u0026rsquo;s a deliberate choice with a cost. Apple\u0026rsquo;s App Store requires apps that offer third-party login to also offer Sign in with Apple, and review pushed back on the submission. The argument that won was institutional: this is an education-only app whose login is the college\u0026rsquo;s own identity system, not consumer social auth. I worked that justification through review and appeal until it was approved.\nThe app ships today, Google-only, exactly as designed — and the missing Sign in with Apple button is right there on the public App Store listing for anyone to see.\nThe transferable lesson: knowing the App Store guidelines and persisting through appeal is its own engineering skill. A rejection isn\u0026rsquo;t a no; it\u0026rsquo;s the start of a conversation about which rule actually applies.\nA note on accuracy: I\u0026rsquo;m describing the review outcome qualitatively. I\u0026rsquo;m not citing a specific guideline subsection, because the exact framing that cleared review is the kind of detail worth confirming before you generalize it.\nLong-Term Flutter Maintenance, Zero Incidents # Zero major production incidents across the maintenance window. No data loss, no auth outage, no crash loop that took the app down for a meaningful slice of users. Not \u0026ldquo;few.\u0026rdquo; Zero.\nThat record is four habits compounded:\nManual verification on the paths that hurt a student. The load-bearing flows — auth, payments, the API contract, attendance — get eyes on them on a real device before every promotion. Not a sprawling automated suite; deliberate, scoped manual passes, plus a CI-gated staged rollout that catches what a person could miss. I don\u0026rsquo;t chase 100% coverage as a vanity number — I cover what would hurt a student. Staged testing. Every release reaches real testers first — Android APKs and iOS TestFlight — before any store. That started as a manual hand-out; it\u0026rsquo;s now automated, so a git tag ships a signed build. Conservative dependency upgrades. Read the changelogs and issue trackers. Wait a beat on major bumps to see who breaks first. Monitoring. The stores\u0026rsquo; own vitals dashboards — Play Console and App Store Connect crash data — plus an uptime monitor on the API. You see a wobble before the support ticket arrives. The framework migrations ridden along the way — null safety, Material 3, Impeller, Dart 3 — are written up in detail in maintaining a Flutter app long-term.\nMobile CI/CD Pipeline Across Three Stores # A merged pull request builds, tests, signs, and ships to all three stores without a human touching a console.\nAndroid (Play + Amazon): Drone CI builds, signs with the production keystore, uploads to the internal track, and promotes on a release commit. iOS: Xcode Cloud — Apple\u0026rsquo;s own CI keeps code signing and the App Store API inside Apple\u0026rsquo;s trust boundary. That removes a whole class of certificate-handling pain. Bitbucket Pipelines runs an AI code-review step on every change. Test builds by git tag: anyone — a junior dev, QA, a product owner — pushes a tag like test-2026-07-08-feature and the pipeline emits a signed test build. The git tag is the release button. The first review pass is now effectively instant. The AI flags correctness issues — null handling, missing error paths — plus architectural drift, like a feature reaching into another feature\u0026rsquo;s internals, and dependency and security smells. What it doesn\u0026rsquo;t catch is judgment: an API whose error contract quietly changed, a widget quirk that only surfaces on a real device. That\u0026rsquo;s still the human pass, and it\u0026rsquo;s why I sign off on every merge. The AI moves the review queue; it doesn\u0026rsquo;t own the release call.\nThe full stack is written up in the complete mobile CI/CD pipeline and how AI-assisted delivery works.\nOutcome # Seven-plus years. Three stores. Seventy-plus modules. Reviews that land in minutes instead of days. Releases that ship without a human at a console. Zero major production incidents — all on one Clean Architecture chosen in year two.\nThe NCC App is the project I point to when someone asks what \u0026ldquo;senior\u0026rdquo; actually means in mobile engineering. It\u0026rsquo;s not the flashiest build. It\u0026rsquo;s the one that stays up.\nLessons # Start messy, refactor to last by year two. Ship fast, then earn the right to a long horizon by rebuilding the seams before the codebase fights back. Treat the framework as a dependency, not a foundation. Keep domain logic away from Flutter so a Material 3 migration is a UI task, not a rewrite. Fight the platform fight worth fighting. App Store policy is navigable with the right argument and persistence. Keep the human in the release decision. AI moves the queue; judgment prevents the incident. Frequently Asked Questions # Is Clean Architecture worth it for a Flutter app? Yes, for any app with a multi-year or multi-developer horizon. The upfront cost pays back the first time a framework migration is a UI task instead of a rewrite. This app\u0026rsquo;s architecture has held for more than five years.\nHow long can a Flutter app be maintained? Seven-plus years here, and counting. The deciding factor isn\u0026rsquo;t Flutter — it\u0026rsquo;s keeping a seam between your domain logic and the framework so the codebase survives version churn.\nHow do you ship a Flutter app to three stores? Drone CI builds and uploads Android (Google Play and Amazon), Xcode Cloud handles iOS signing inside Apple\u0026rsquo;s trust boundary, and a git tag triggers test builds. Fully automated.\nWhat state management does this app use? Riverpod NotifierProvider with Freezed state unions, and repositories returning Either\u0026lt;Failure, Success\u0026gt; for typed errors.\nGoogle Play · App Store\nBuilding or maintaining a Flutter app at the multi-year horizon? Get in touch — or read the guide to hiring a Flutter developer in 2026.\n","date":"1 February 2019","externalUrl":null,"permalink":"/projects/ncc-mobile-app/","section":"Projects","summary":"\u003ch2 class=\"relative group\"\u003eAt a glance \n    \u003cdiv id=\"at-a-glance\" class=\"anchor\"\u003e\u003c/div\u003e\n    \n    \u003cspan\n        class=\"absolute top-0 w-6 transition-opacity opacity-0 ltr:-left-6 rtl:-right-6 not-prose group-hover:opacity-100\"\u003e\n        \u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700 !no-underline\" href=\"#at-a-glance\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\n    \u003c/span\u003e        \n    \n\u003c/h2\u003e\n\u003cp\u003eA student app built once to last.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e7+ years\u003c/strong\u003e maintained, from Flutter 1.x through the latest stable\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e70+ feature modules\u003c/strong\u003e in a single codebase\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e3 app stores\u003c/strong\u003e — Google Play, App Store, and Amazon Appstore\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSolo-owned for six years\u003c/strong\u003e, then a team\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eZero major production incidents\u003c/strong\u003e across the whole window\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eOne Clean Architecture refactor\u003c/strong\u003e, made in year two — still in place\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThis is the project I point to when someone asks what \u0026ldquo;senior\u0026rdquo; means in mobile engineering. Not the flashiest build. The one that stays up.\u003c/p\u003e","title":"Flutter Clean Architecture: 7-Year, 70-Module Case Study","type":"projects"},{"content":"","date":"1 February 2019","externalUrl":null,"permalink":"/tags/flutter-ci-cd/","section":"Tags","summary":"","title":"Flutter-Ci-Cd","type":"tags"},{"content":"","date":"1 February 2019","externalUrl":null,"permalink":"/tags/flutter-maintenance/","section":"Tags","summary":"","title":"Flutter-Maintenance","type":"tags"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"I design, ship, and maintain production mobile apps — and the pipelines that release them. If you have a cross-platform app that has to land on the Play, App, and Amazon stores and stay alive for years, that\u0026rsquo;s my lane. Get a quote →\nMobile App Development # Flutter, React Native, Expo. End-to-end: architecture, build, and ship to Google Play, the App Store, and Amazon Appstore.\nWhat. Greenfield cross-platform apps from a spec — state-management boundaries decided before the first screen, platform contracts mapped up front, and a release pipeline wired in from week one, not bolted on at the end. I build for the low-end Android handsets and unstable networks that punish lazy architecture, so the app doesn\u0026rsquo;t fall over on the device matrix that actually ships in emerging markets.\nWho it\u0026rsquo;s for. Founders and teams who need a production app, not a prototype — something that has to pass three store reviews and survive real users on real hardware.\nOutcome. A shippable app on Android, iOS, and Web, with the structure and tooling to keep it shippable.\nLearn more → · Flutter vs React Native vs Expo →\nBackend \u0026amp; APIs # Node.js, Golang, MongoDB, Firebase, PocketBase. REST with JWT auth.\nWhat. The server half of a mobile app — designed and built by someone who thinks in both clients and services, so you don\u0026rsquo;t end up with an API that fights the app at every screen. Schemas that match how the app actually reads data, auth that survives token refresh, and endpoints that don\u0026rsquo;t fall over under the app\u0026rsquo;s real call pattern.\nWho it\u0026rsquo;s for. Mobile teams that have the client figured out (or are building it with me) and need a backend that\u0026rsquo;s built mobile-first instead of handed off to someone who\u0026rsquo;s never shipped an app.\nOutcome. A backend your app can rely on, with the auth, caching, and error contracts done properly the first time.\nLearn more →\nMobile Architecture \u0026amp; Migration # Clean Architecture, state management (Riverpod / Bloc), and platform migrations — including Flutter → React Native / Expo.\nWhat. Two kinds of engagement. The first is rescuing codebases that grew into a ball of mud — drawing the module boundaries, picking the state-management approach that fits the team, and paying down the structural debt in layered passes without stopping the release cadence. The second is a platform swap: migrating an app from Flutter to React Native / Expo (or the reverse) when the current stack has become the bottleneck. I\u0026rsquo;ve done this on a live app with the parental-control boundary intact.\nWho it\u0026rsquo;s for. Apps that have outgrown their structure, or teams that need to change platform without losing the product.\nOutcome. A codebase you can keep building on — or a migrated app that\u0026rsquo;s live on the new stack with feature parity.\nLearn more →\nCI/CD \u0026amp; Release Automation # Drone CI, GitHub Actions, Bitbucket Pipelines, Fastlane, Xcode Cloud. Auto-deploy to Play / App Store / Amazon, test-APK distribution, and store-signing.\nWhat. Apps that ship themselves. Once a merge passes review, it flows through the pipeline to all three stores without a human babysitting the build — version bumps, signing, store metadata, screenshots, and rollout handled by the pipeline. Test APKs and TestFlight builds go out automatically on every push to a branch that matters. Releases stop being an event and start being a side effect of good merges.\nWho it\u0026rsquo;s for. Teams drowning in manual releases — the ones who lose an afternoon every time someone has to rebuild a signed bundle, fight a provisioning profile, or guess at a store listing.\nOutcome. A release pipeline you can trust end-to-end, and your afternoons back.\nLearn more →\nAI-Augmented Development \u0026amp; Code Review # AI code review in the pipeline, agentic workflows, and context engineering.\nWhat. My delivery loop is built around AI from the spec up — which is why I can take on migrations and long-running maintenance work that would normally stall a single engineer. I decompose the system into tasks small enough to be unambiguous, write prompts that load the architecture and the constraints, and have the AI generate code against them. Then I review every diff against the architecture, the platform contracts, and the failure modes I already know about. The typing is delegated; the engineering judgment isn\u0026rsquo;t. On the review side, AI runs in the pipeline so every merge is reviewed before it ships — review time drops from days to effectively instant, without losing rigor.\nWho it\u0026rsquo;s for. Teams that want AI-assisted speed without giving up engineering discipline — and who\u0026rsquo;ve seen what happens when AI output ships without review.\nOutcome. Faster delivery, every merge reviewed, and an AI loop you can actually trust in production.\nLearn more →\nHow we\u0026rsquo;d work # Five steps, kept boring on purpose.\nDiscovery. You tell me what you\u0026rsquo;re building, for whom, and what\u0026rsquo;s already in place. I ask the questions that decide the architecture — device matrix, store targets, the failure modes that will actually bite. Architecture. Before any code, I map the system — state-management boundaries, platform contracts, the release pipeline. Most engagements start with a short architecture audit so we both know what we\u0026rsquo;re building before it\u0026rsquo;s built. Build. I write the code, AI-assisted and review-gated, in vertical slices that ship. You see working software on a real cadence, not a demo at the end. Ship. The pipeline takes over — merges flow to Play, App Store, and Amazon automatically once they pass review. Releases become a side effect of good merges. Maintain. After launch, I absorb SDK bumps, OS migrations, and dependency breakage on a calendar — one minor bump per sprint, one major migration per quarter at most — so the app stays alive for years without heroics. Track record # 7+ years shipping production mobile apps in Flutter, React Native, and Expo. Apps live on Google Play, the App Store, and Amazon Appstore — three stores, three review processes, three device matrices, on the same release cadence. 7+ years maintaining a production Flutter app with zero major incidents — Flutter 1.x through the latest stable, 70+ feature modules across three stores, every major migration absorbed without missing a release. CI/CD specialist — release automation across three stores, with AI code review in the loop so every build is reviewed before it ships. The details are in the case studies — Kahf Kids → (Flutter → React Native / Expo migration in 3 months) and NCC App → (7+ years, zero major incidents).\nPricing # Every project is different, so I quote per engagement rather than publishing a rate card. Tell me about yours — the app, the stores, the timeline, what\u0026rsquo;s already built — and I\u0026rsquo;ll come back with a plan and a number. No packages, no hidden tiers. Start a conversation →\nIf you have a mobile app that needs to ship — and stay shippable for years — let\u0026rsquo;s talk. Get a quote →\n","externalUrl":null,"permalink":"/services/","section":"Services","summary":"\u003cp\u003eI design, ship, and maintain production mobile apps — and the pipelines that release them. If you have a cross-platform app that has to land on the Play, App, and Amazon stores and stay alive for years, that\u0026rsquo;s my lane. \u003ca\n  href=\"/contact/\"\u003eGet a quote →\u003c/a\u003e\u003c/p\u003e","title":"Services","type":"services"},{"content":"Tell me about your project. I reply to every serious enquiry within 1–2 business days.\nName Email Project type Select… New app Existing app maintenance Migration (e.g. Flutter → React Native) CI/CD setup AI code review Other Message Send message → Other ways to reach me # Email mjashem@hotmail.com LinkedIn linkedin.com/in/mjashem ","externalUrl":null,"permalink":"/contact/","section":"Mohammad Jashem — Senior Full-Stack Mobile Engineer","summary":"\u003cp\u003eTell me about your project. I reply to every serious enquiry within 1–2 business days.\u003c/p\u003e\n\n\n\u003cform action=\"https://api.staticforms.dev/submit\" method=\"POST\" class=\"not-prose flex flex-col gap-4 max-w-xl mt-6\"\u003e\n  \u003cinput type=\"hidden\" name=\"apiKey\" value=\"sf_6445cd563a5800608d8bce1d\"\u003e\n  \u003cinput type=\"hidden\" name=\"subject\" value=\"New lead from mjashem.com\"\u003e\n  \u003cinput type=\"text\" name=\"honeypot\" style=\"display:none\" tabindex=\"-1\" autocomplete=\"off\"\u003e\n  \u003cdiv\u003e\n    \u003clabel class=\"sig-label\" for=\"name\"\u003eName\u003c/label\u003e\n    \u003cinput class=\"sig-field w-full\" type=\"text\" id=\"name\" name=\"name\" required\u003e\n  \u003c/div\u003e\n  \u003cdiv\u003e\n    \u003clabel class=\"sig-label\" for=\"email\"\u003eEmail\u003c/label\u003e\n    \u003cinput class=\"sig-field w-full\" type=\"email\" id=\"email\" name=\"email\" required\u003e\n  \u003c/div\u003e\n  \u003cdiv\u003e\n    \u003clabel class=\"sig-label\" id=\"project_type-label\" for=\"project_type\"\u003eProject type\u003c/label\u003e\n    \u003cdiv class=\"sig-select\" data-sig-select\u003e\n      \u003cselect class=\"sig-field w-full\" id=\"project_type\" name=\"project_type\" required\u003e\n        \u003coption value=\"\" selected disabled\u003eSelect…\u003c/option\u003e\n        \u003coption value=\"New app\"\u003eNew app\u003c/option\u003e\n        \u003coption value=\"Existing app maintenance\"\u003eExisting app maintenance\u003c/option\u003e\n        \u003coption value=\"Migration (e.g. Flutter → React Native)\"\u003eMigration (e.g. Flutter → React Native)\u003c/option\u003e\n        \u003coption value=\"CI/CD setup\"\u003eCI/CD setup\u003c/option\u003e\n        \u003coption value=\"AI code review\"\u003eAI code review\u003c/option\u003e\n        \u003coption value=\"Other\"\u003eOther\u003c/option\u003e\n      \u003c/select\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n  \u003cdiv\u003e\n    \u003clabel class=\"sig-label\" for=\"message\"\u003eMessage\u003c/label\u003e\n    \u003ctextarea class=\"sig-field w-full\" id=\"message\" name=\"message\" rows=\"5\" required\u003e\u003c/textarea\u003e\n  \u003c/div\u003e\n  \u003cbutton type=\"submit\" class=\"btn btn-primary\"\u003eSend message →\u003c/button\u003e\n\u003c/form\u003e\n\n\n\n\u003ch2 class=\"relative group\"\u003eOther ways to reach me \n    \u003cdiv id=\"other-ways-to-reach-me\" class=\"anchor\"\u003e\u003c/div\u003e\n    \n    \u003cspan\n        class=\"absolute top-0 w-6 transition-opacity opacity-0 ltr:-left-6 rtl:-right-6 not-prose group-hover:opacity-100\"\u003e\n        \u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700 !no-underline\" href=\"#other-ways-to-reach-me\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\n    \u003c/span\u003e        \n    \n\u003c/h2\u003e\n\n\u003cdiv class=\"sig-contact-channels\"\u003e\n  \u003ca class=\"sig-contact-channel\" href=\"mailto:mjashem@hotmail.com\"\u003e\n    \u003cspan class=\"sig-contact-channel-icon\" aria-hidden=\"true\"\u003e\n      \u003csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\u003e\u003crect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/\u003e\u003cpath d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/\u003e\u003c/svg\u003e\n    \u003c/span\u003e\n    \u003cspan class=\"sig-contact-channel-body\"\u003e\n      \u003cspan class=\"sig-contact-channel-label\"\u003eEmail\u003c/span\u003e\n      \u003cspan class=\"sig-contact-channel-value\"\u003emjashem@hotmail.com\u003c/span\u003e\n    \u003c/span\u003e\n  \u003c/a\u003e\n  \u003ca class=\"sig-contact-channel\" href=\"https://linkedin.com/in/mjashem\" target=\"_blank\" rel=\"noopener noreferrer\"\u003e\n    \u003cspan class=\"sig-contact-channel-icon\" aria-hidden=\"true\"\u003e\n      \u003csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"currentColor\"\u003e\u003cpath d=\"M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.13 1.45-2.13 2.94v5.67H9.35V9h3.41v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z\"/\u003e\u003c/svg\u003e\n    \u003c/span\u003e\n    \u003cspan class=\"sig-contact-channel-body\"\u003e\n      \u003cspan class=\"sig-contact-channel-label\"\u003eLinkedIn\u003c/span\u003e\n      \u003cspan class=\"sig-contact-channel-value\"\u003elinkedin.com/in/mjashem\u003c/span\u003e\n    \u003c/span\u003e\n  \u003c/a\u003e\n\u003c/div\u003e","title":"Work with me","type":"page"}]