A small cross-platform (web + iOS) products catalog built as a technical challenge. The app lets the user search a product catalog, browse product details, and keep a personal wish list that persists locally on the device.
This was my first project with Ionic, and I took the opportunity to also try GraphQL — since the Platzi Fake Store API exposes a GraphQL endpoint — to learn both in a realistic setting. What I liked most about Ionic is how close the web-first DX feels to any other React project, while still giving you native-grade performance and UI on mobile.
It's also a deliberate example of the structure I use for any React project, web or mobile: the same conventions for routing, views, components, and state management. The paradigm doesn't change with the target — only the shell around it does.
| Products list (search + infinite scroll) | Product details (image carousel + wish list toggle) |
|---|---|
![]() |
![]() |
The bottom tab bar is Ionic's IonTabs, and each tab owns its own navigation stack — so tapping a product from the Products tab and from the Wish List tab pushes the same ProductDetailsView but inside different stacks, preserving the correct back-navigation context.
- Ionic React — UI framework and component library. Provides the native-feeling shell (tabs, pages, router outlets, back navigation, infinite scroll, inputs, cards, etc.) that adapts automatically to web and iOS.
- Capacitor — native runtime bridge. Packages the web build as a native iOS app and exposes device APIs. Used here for
@capacitor/preferences(persistent key-value storage for the wish list), plushaptics,keyboard,status-bar, andapp. - React 18 + TypeScript.
- Vite — dev server and build tool, with
@vitejs/plugin-reactand@vitejs/plugin-legacyfor older-browser support.
- Apollo Client — GraphQL client with in-memory cache. Configured once in
src/config/apollo-client.tsagainst the Platzi Fake Store GraphQL API (https://api.escuelajs.co/graphql) and injected viaApolloProviderat the app root. - GraphQL — all product data (list, search, details) is fetched through typed queries defined in
src/queries/products.queries.tsusingTypedDocumentNodefor end-to-end type safety between queries and components.
- Zustand — lightweight client state for the wish list (
src/lib/stores/wish-list.store.ts). Every mutation mirrors its new state to@capacitor/preferencesso the wish list survives app restarts. AuseSetupWishlisthook hydrates the store on app boot.
- React Router 5 +
@ionic/react-router— Ionic's integration that preserves page transitions, stack history, and the iOS swipe-back gesture. Routes are centralized insrc/lib/routes.tsas a typed builder, so URLs are generated — never hand-written — in components.
- Tailwind CSS for layout and utility styling on top of Ionic's components.
- Swiper for the product image carousel on the details view.
- Ionicons for icons.
- ESLint, Prettier
- Vitest + Testing Library for unit tests (
src/App.test.tsx,src/setupTests.ts). - Cypress for E2E.
- GraphQL Code Generator (
npm run compile/npm run watch).
┌──────────────────────────────────────────┐
│ IonReactRouter │
│ ┌────────────────────────────────────┐ │
│ │ IonTabs │ │
│ │ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ Products │ │ Wishlist │ │ │
│ │ │ Outlet │ │ Outlet │ │ │
│ │ └──────┬───────┘ └─────┬──────┘ │ │
│ │ │ │ │ │
│ │ └────┐ ┌────┘ │ │
│ │ ▼ ▼ │ │
│ │ ProductDetailsView │ │
│ │ (shared between both stacks) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
│ │
┌────────▼─────────┐ ┌────────▼─────────┐
│ Apollo Client │ │ Zustand store │
│ (GraphQL API) │ │ + Preferences │
└──────────────────┘ └──────────────────┘
App.tsxwires the providers (ApolloProvider), sets up Ionic, hydrates the wish-list store, and mounts two tabs: Products and Wish List.- Each tab has its own
IonRouterOutlet(products-outlet.tsx,wish-list-outlet.tsx) so each tab keeps its own navigation stack, the iOS-native way. - Both tabs route into the same
ProductDetailsView, but under their own URL prefix — so the back button always returns to the tab you came from. - Views fetch data via Apollo (
useQuery/useLazyQuery) against the typed queries. The products list usesIonInfiniteScrollwith an offset/limit paginator and a debounced search input. - Adding or removing an item on the details view mutates the Zustand store, which in turn persists the updated array to
@capacitor/preferences. The Wish List tab reads the same store reactively.
src/
├── App.tsx # Providers, tabs, top-level routes
├── main.tsx
├── config/
│ └── apollo-client.ts # Apollo + GraphQL endpoint
├── queries/
│ └── products.queries.ts # Typed GraphQL queries
├── types/ # Domain types (Product, Category, …)
├── lib/
│ ├── routes.ts # Typed route builder
│ ├── keys.ts # Storage keys
│ ├── stores/
│ │ └── wish-list.store.ts # Zustand + Preferences persistence
│ └── utils/
│ └── formatting.ts # Currency formatting, …
├── components/ # Reusable presentational pieces
│ ├── products-list.tsx
│ ├── product-details.tsx
│ └── category-details.tsx
└── app/
├── products/
│ ├── products-outlet.tsx
│ └── views/products-view.tsx
├── wish-list/
│ ├── wish-list-outlet.tsx
│ └── views/wish-list-view.tsx
└── views/
└── product-details-view.tsx # Shared by both tabs
The split between app/ (feature views tied to a route) and components/ (reusable, route-agnostic UI) is the same one I use on web-only React projects. Routing is the only layer that changes when the target changes.
npm run dev # Vite dev server (web)
npm run build # tsc && vite build
npm run ios # ionic cap run ios -l --external (live-reload on device)
npm run test.unit # Vitest
npm run test.e2e # Cypress
npm run lint # ESLint
npm run format # Prettier
npm run compile # GraphQL CodegenAll data comes from the Platzi Fake Store API via its GraphQL endpoint: https://api.escuelajs.co/graphql.

