Build a mobile store with Grit: generate resource → shoppable app
Point grit generate resource at your Expo app and it scaffolds typed hooks, list + detail screens, and the plumbing for a real store. We wire a Category → Product shopping flow with similar products in one sitting.

A while back we built a full-stack web store with Grit — describe your data, and
grit generate resource wrote the Go API, the React Query hooks, and an admin panel.
Today we do it on mobile. Same command, same models, but now grit generate resource also scaffolds your Expo app — and it does far more than a list. For
every resource you get a typed hook, a searchable/sortable list table with image
thumbnails, a detail screen, and full create/edit forms with a native image
picker, a searchable relationship select, and CSV import. We'll take all of that and
build a curated storefront on top — browse categories → tap a category → see its
products → open a product → scroll its similar products — writing almost none of
the plumbing.
Let's build.
1. Install or update Grit
The mobile generator has grown a lot since it landed — working image uploads, in-form previews, a permission-aware picker, searchable relationship selects, multi-image fields, and CSV import all arrived in the v3.31.6x–v3.31.75 line. Grab the latest before you scaffold.
# Install (macOS / Linux)curl -fsSL https://gritframework.dev/install.sh | sh# Install (Windows PowerShell)iwr -useb https://gritframework.dev/install.ps1 | iex# Already have Grit? Update in place:grit update
Prefer Go? go install github.com/MUKE-coder/grit/v3/cmd/grit@latest. Confirm you're
on v3.31.75 or newer (older builds are missing the upload fixes):
grit version
2. Start with a mobile project
If you're starting fresh:
grit new my-store --mobile # Go API + an Expo (React Native) appcd my-store
--mobile scaffolds an apps/api Go backend and an apps/expo React Native app
that already has auth (login/register), a themed light/dark UI, and a tab layout
wired to the API.
3. Generate the models
A product belongs to a category, so we create the parent first.
Category:
grit generate resource Category --fields "name:string,slug:slug,image:file:image"
Product — linked to Category:
grit generate resource Product --fields "name:string,slug:slug,price:int,description:text,thumbnail:file:image,category:belongs_to:Category"
Money tip:
price:intstores exactly what you type — enter1000and the record holds1000. The generated form formats the input with thousands separators as you type (1,000), and submits the plain number. UsetoLocaleString()to render it the same way.
What you just generated for mobile
Alongside the Go model/service/handler, each command writes a full CRUD experience
into apps/expo:
✓ hooks/use-<plural>.ts # typed React Query hook (list/one/mutations)✓ app/<plural>/index.tsx # searchable, sortable list TABLE + thumbnails✓ app/<plural>/[id].tsx # detail screen (hero image + fields)✓ app/<plural>/new.tsx + edit/[id].tsx # create + edit screens✓ components/resource-forms/<plural>-form.tsx# the shared form (image picker, relation select…)✓ components/ui/image-picker-sheet.tsx # permission → pick/camera → preview → use/crop✓ components/ui/relation-select.tsx # searchable single-select for belongs_to✓ lib/upload.ts + lib/images.ts # reliable uploads + device-safe image URLs✓ internal/handlers/<name>_import.go # CSV bulk import (backend, all architectures)
expo-router is file-based, so those files are the routes — /products,
/products/new, /products/:id all exist now, no registration step. Every resource
is reachable from the app's More → Resources tab, so you get an admin-style
list/create/edit/detail flow — with working image uploads, in-form previews, a
searchable relationship picker, multi-image (files) fields, CSV export/import, and
filters — before you write a line of UI.
Open apps/expo/hooks/use-products.ts and you'll find exactly what you need:
export function useProducts(search = "", filters: Record<string, string> = {}) { … } // infinite listexport function useProduct(id: string) { … } // one productexport function useCreateProduct() { … } // + update / delete mutations
The list hook uses useInfiniteQuery — pagination for free — and takes equality
filters. That second argument is the whole reason the store works, and it leans on
one nice generator detail.
Tip: For a product gallery, make the images field a
files(array) type —images:files:image— and the form gives you multi-select from the gallery with a grid of removable thumbnails. A singlefilefield stays single-select.
4. Get it running
Before we touch a single screen, let's get the whole thing live. From the project root:
pnpm install # install JS deps across the monorepodocker compose up -d # Postgres, Redis, MinIO, Mailhoggrit migrate # create the categories + products tablesgrit seed # seed the admin user: admin@example.com / admin123grit start # boot the Go API with hot reload
Then, in a second terminal, start the Expo app:
cd apps/expopnpm start # press a for Android, i for iOS, or scan the QR in Expo Go
Log in with admin@example.com / admin123. You now have a running mobile app talking to a running API — time to turn it into a store.
5. The one thing that turns a list into a store
Because Product belongs to Category, the generated Go handler makes products
filterable by their foreign key:
GET /products?category_id=<id> → only that category's products
You don't write that — grit generate resource wires it for every belongs_to
field. On the client, that's just:
useProducts("", { category_id: id })
That's the spine of the entire shopping flow. Everything below is UI on top of the generated hooks.
6. The Shop tab — a grid of categories
Create apps/expo/app/(tabs)/shop.tsx:
import { View, Text, FlatList, Pressable, ActivityIndicator } from "react-native";import { Image } from "expo-image";import { useRouter } from "expo-router";import { Ionicons } from "@expo/vector-icons";import { ScreenHeader } from "@/components/ui/screen-header";import { useTheme } from "@/lib/theme";import { resolveImageUrl } from "@/lib/images";import { useCategories, type Category } from "@/hooks/use-categories";export default function ShopScreen() {const router = useRouter();const { palette } = useTheme();const query = useCategories();const categories = query.data?.pages.flatMap((p) => p.data) ?? [];const renderItem = ({ item }: { item: Category }) => (<PressableonPress={() =>router.push({ pathname: "/shop/category/[id]", params: { id: item.id, name: item.name } })}className="flex-1 m-2 bg-white dark:bg-[#111118] border border-[#E5E7EB] dark:border-[#1f1f2b] rounded-2xl overflow-hidden">{item.image?.url ? (<Image source={{ uri: resolveImageUrl(item.image.url) }} style={{ width: "100%", height: 110 }} contentFit="cover" />) : (<View style={{ height: 110 }} className="bg-[#6c5ce7]/10 items-center justify-center"><Ionicons name="pricetags-outline" size={28} color="#6c5ce7" /></View>)}<Text className="text-[15px] font-semibold text-[#0F1018] dark:text-white px-3 py-3" numberOfLines={1}>{item.name}</Text></Pressable>);return (<View className="flex-1 bg-[#F4F4F6] dark:bg-[#0a0a0f]"><ScreenHeader title="Shop" subtitle="Browse by category" /><FlatListdata={categories}keyExtractor={(item) => item.id}renderItem={renderItem}numColumns={2}contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}refreshing={query.isRefetching}onRefresh={query.refetch}ListEmptyComponent={query.isLoading ? (<ActivityIndicator color={palette.refresh} style={{ marginTop: 40 }} />) : (<Text className="text-center text-[#6B7280] dark:text-[#9090a8] mt-16">No categories yet</Text>)}/></View>);}
Then add it to the tab bar in apps/expo/app/(tabs)/_layout.tsx:
<Tabs.Screenname="shop"options={{title: "Shop",tabBarIcon: ({ color, size }) => <Ionicons name="bag-outline" size={size} color={color} />,}}/>
ScreenHeader (shipped by the scaffold) gives you a safe-area title bar — and a back
button on any screen that passes showBack.
Why
resolveImageUrl? In dev, MinIO hands back URLs likehttp://localhost:9002/…, and on a phone/emulatorlocalhostmeans the device itself — so images silently fail to load.resolveImageUrl(inlib/images.ts, generated for you) rewrites that host to the same dev machine the app already uses for the API. Wrap every stored image URL with it. Real S3/R2 public URLs pass through untouched. The scaffold'sdocker-compose.ymlalso publishes MinIO on all interfaces (9002:9000) so your device can reach it on your LAN.
7. A category's products
Create apps/expo/app/shop/category/[id].tsx. This is where the category_id filter
earns its keep:
import { View, Text, FlatList, Pressable, ActivityIndicator } from "react-native";import { Image } from "expo-image";import { useRouter, useLocalSearchParams } from "expo-router";import { Ionicons } from "@expo/vector-icons";import { ScreenHeader } from "@/components/ui/screen-header";import { useTheme } from "@/lib/theme";import { resolveImageUrl } from "@/lib/images";import { useProducts, type Product } from "@/hooks/use-products";export default function CategoryProductsScreen() {const router = useRouter();const { palette } = useTheme();const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();const query = useProducts("", { category_id: id }); // ← the whole trickconst products = query.data?.pages.flatMap((p) => p.data) ?? [];const renderItem = ({ item }: { item: Product }) => (<PressableonPress={() => router.push({ pathname: "/shop/product/[id]", params: { id: item.id } })}className="flex-1 m-2 bg-white dark:bg-[#111118] border border-[#E5E7EB] dark:border-[#1f1f2b] rounded-2xl overflow-hidden">{item.thumbnail?.url ? (<Image source={{ uri: resolveImageUrl(item.thumbnail.url) }} style={{ width: "100%", height: 130 }} contentFit="cover" />) : (<View style={{ height: 130 }} className="bg-[#6c5ce7]/10 items-center justify-center"><Ionicons name="cube-outline" size={28} color="#6c5ce7" /></View>)}<View className="p-3"><Text className="text-[14px] font-semibold text-[#0F1018] dark:text-white" numberOfLines={1}>{item.name}</Text><Text className="text-[14px] font-bold text-[#6c5ce7] mt-1">${item.price.toLocaleString()}</Text></View></Pressable>);return (<View className="flex-1 bg-[#F4F4F6] dark:bg-[#0a0a0f]"><ScreenHeader title={name || "Category"} subtitle="Products" showBack /><FlatListdata={products}keyExtractor={(item) => item.id}renderItem={renderItem}numColumns={2}contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}onEndReached={() => { if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage(); }}onEndReachedThreshold={0.4}ListEmptyComponent={query.isLoading ? (<ActivityIndicator color={palette.refresh} style={{ marginTop: 40 }} />) : (<Text className="text-center text-[#6B7280] dark:text-[#9090a8] mt-16">No products in this category</Text>)}/></View>);}
Infinite scroll, pull-free pagination, and a category-scoped list — all from
useProducts("", { category_id: id }).
8. Product detail + similar products
Create apps/expo/app/shop/product/[id].tsx. "Similar" is just the same filter again
— other products in this product's category, minus itself:
import { View, Text, ScrollView, Pressable, ActivityIndicator, FlatList } from "react-native";import { Image } from "expo-image";import { useRouter, useLocalSearchParams } from "expo-router";import { ScreenHeader } from "@/components/ui/screen-header";import { useTheme } from "@/lib/theme";import { resolveImageUrl } from "@/lib/images";import { useProduct, useProducts, type Product } from "@/hooks/use-products";export default function ProductScreen() {const router = useRouter();const { palette } = useTheme();const { id } = useLocalSearchParams<{ id: string }>();const { data: product, isLoading } = useProduct(id);// Similar = other products in the same category.const similarQuery = useProducts("", product ? { category_id: product.category_id } : {});const similar = (similarQuery.data?.pages.flatMap((p) => p.data) ?? []).filter((p) => p.id !== id).slice(0, 10);if (isLoading || !product) {return (<View className="flex-1 bg-[#F4F4F6] dark:bg-[#0a0a0f]"><ScreenHeader title="Product" showBack /><ActivityIndicator color={palette.refresh} style={{ marginTop: 40 }} /></View>);}return (<View className="flex-1 bg-[#F4F4F6] dark:bg-[#0a0a0f]"><ScreenHeader title="Product" showBack /><ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>{product.thumbnail?.url ? (<Image source={{ uri: resolveImageUrl(product.thumbnail.url) }} style={{ width: "100%", height: 300 }} contentFit="cover" />) : null}<View className="p-6"><Text className="text-[24px] font-bold text-[#0F1018] dark:text-white">{product.name}</Text><Text className="text-[22px] font-bold text-[#6c5ce7] mt-2">${product.price.toLocaleString()}</Text>{product.category?.name ? (<View className="self-start bg-[#6c5ce7]/12 px-3 py-1 rounded-full mt-3"><Text className="text-[12px] font-medium text-[#6c5ce7]">{product.category.name}</Text></View>) : null}<Text className="text-[15px] text-[#6B7280] dark:text-[#9090a8] leading-6 mt-4">{product.description}</Text><Pressable className="bg-[#6c5ce7] rounded-full py-4 items-center mt-6"><Text className="text-white font-semibold text-[15px]">Add to Cart</Text></Pressable></View>{similar.length > 0 ? (<View className="mt-2"><Text className="text-[18px] font-bold text-[#0F1018] dark:text-white px-6 mb-3">Similar products</Text><FlatListdata={similar}horizontalkeyExtractor={(item) => item.id}showsHorizontalScrollIndicator={false}contentContainerStyle={{ paddingHorizontal: 24 }}renderItem={({ item }: { item: Product }) => (<PressableonPress={() => router.push({ pathname: "/shop/product/[id]", params: { id: item.id } })}className="w-40 mr-3 bg-white dark:bg-[#111118] border border-[#E5E7EB] dark:border-[#1f1f2b] rounded-2xl overflow-hidden">{item.thumbnail?.url ? (<Image source={{ uri: resolveImageUrl(item.thumbnail.url) }} style={{ width: "100%", height: 110 }} contentFit="cover" />) : (<View style={{ height: 110 }} className="bg-[#6c5ce7]/10" />)}<View className="p-3"><Text className="text-[13px] font-semibold text-[#0F1018] dark:text-white" numberOfLines={1}>{item.name}</Text><Text className="text-[13px] font-bold text-[#6c5ce7] mt-1">${item.price.toLocaleString()}</Text></View></Pressable>)}/></View>) : null}</ScrollView></View>);}
Because Product preloads its Category on the API, product.category?.name
renders the category chip with zero extra requests.
9. Add a few products and shop
The API and app are already running from step 4 — and you don't need Postman or an admin panel to add data, because the generated screens are a full CRUD app. Open the More → Resources tab, pick Categories → +, and the create form gives you a text input plus a native image picker (permission prompt → pick from gallery or camera → instant preview). Add a couple of categories, then do the same under Products, using the searchable Category select to link each product to its category.
Everything you upload shows up immediately — the local preview in the form, a
thumbnail in the list table, the hero image on the detail screen — thanks to the
upload + resolveImageUrl plumbing you got for free.
Now open the Shop tab and tap through: category → products → product → similar. That whole loop — pagination, relationship filtering, image handling, navigation — is running on generated hooks plus the three screens above.
The takeaway
grit generate resource isn't a backend-only tool anymore. Describe a model once and
you get a Go API, an admin panel, web hooks — and a complete mobile CRUD app:
searchable list tables with thumbnails, detail screens, create/edit forms with a real
image picker, searchable relationship selects, multi-image fields, and CSV import,
all talking to your API over typed hooks. The store you just built is mostly your
generated code; the storefront flow is the thin, fun layer on top.
Go + React. Built with Grit.
Founder of Grit and author of The Daily Grit — a 5-minute morning read on building full-stack apps with Go + React.
Build it with Grit
Go + React, batteries included. Scaffold a production-ready app in one command.
Get started