Analysis of ship (backend), ship_flutter_starter, and implementation plan for ship_rn_starter (React Native + Expo).
- Framework: Koa.js
- Auth: Cookie-based (web) / Bearer token (mobile via
X-Client-Type: mobile) - Database: MongoDB
- WebSocket: Socket.io (via Redis when
REDIS_URIis set)
| Method | Endpoint | Auth | Description |
| POST | /account/sign-in | No | Returns { accessToken, user } for mobile |
| POST | /account/sign-up | No | Returns { emailVerificationToken?, user }, sends verification email |
| POST | /account/sign-out | Yes | Clears session |
| GET | /account | Yes | Current user |
| PUT | /account | Yes | Update user |
| POST | /account/forgot-password | No | Sends reset email, 204 on success |
| PUT | /account/reset-password | No | Body: { token, password } |
| GET | /account/verify-reset-token | No | Validates token, redirects (web) — mobile uses token from deep link |
| GET/POST | /account/verify-email | No | Query: ?token=... — mobile gets { accessToken, user } |
| POST | /account/resend-email | No | Resend verification email |
| POST | /account/sign-in/google-mobile | No | Body: { idToken } → { accessToken, user } |
{
_id: string;
firstName: string;
lastName: string;
email: string;
isEmailVerified: boolean;
avatarUrl?: string;
}- Header
X-Client-Type: mobilerequired for token responses - Sign-in/sign-up/verify-email return JSON
{ accessToken, user }instead of setting cookies - Token stored on client (e.g. AsyncStorage)
- Pattern: Feature-based + Clean Architecture
- Structure:
core/— config, DI, theme, services, validatorsfeatures/— auth, chat, profile, users, routing, navigation- Per feature:
data/(API, models, repositories),domain/(entities, use cases),presentation/(screens, providers, widgets)
- Riverpod (flutter_riverpod, hooks_riverpod, riverpod_annotation)
- Code generation: riverpod_generator, json_serializable
- Async state for sign-in, sign-up, account, chats, messages
accountProvider— current user (AsyncValue)
- go_router
- Redirect logic: unauthenticated → sign-in; authenticated on public routes → home
- Public routes: sign-in, sign-up, forgot-password
- Private routes: home, profile, chats, chat/:chatId
- Shell: Bottom bar (flutter_floating_bottom_bar)
- ApiService (Dio) — baseUrl,
X-Client-Type: mobile, Bearer interceptor, 401 → clear token + callback - StorageService (SharedPreferences) — token, refresh token
- AuthService — Google Sign-In
| Screen | Route | Notes |
| Sign In | /sign-in | Email + password, Google sign-in |
| Sign Up | /sign-up | firstName, lastName, email, password |
| Forgot Password | /sign-in/forgot-password | Email only (Flutter: mock impl) |
| Home | / | Users list (uses Ship users API) |
| Chats | /chats | Chat list (mock) |
| Chat Details | /chats/:chatId | Messages (mock) |
| Profile | /profile | Edit profile, sign out |
- Sign-up → API returns user + optional token in dev
- Sign-in → API returns
{ accessToken, user }→ save token → navigate - 401 → clear token →
accountProviderinvalidated → redirect to sign-in - Token in
Authorization: Bearer <token>
- Mock implementation: ChatApiMockImpl (no Ship chat API)
- Chats list, create chat, send message, delete chat
- SSE-like stream for typing simulation
- Can be swapped for real API later
ship_rn_starter/
├── app/ # Expo Router (file-based)
│ ├── (auth)/ # Auth stack (unauthenticated)
│ │ ├── sign-in.tsx
│ │ ├── sign-up.tsx
│ │ ├── forgot-password.tsx
│ │ ├── verification.tsx # Email verification / reset token
│ │ └── reset-password.tsx # New password with token
│ ├── (tabs)/ # Main app (authenticated)
│ │ ├── index.tsx # Home
│ │ ├── chats.tsx
│ │ ├── chat/[id].tsx # Chat details
│ │ └── profile.tsx
│ ├── _layout.tsx # Root layout + auth redirect
│ └── +not-found.tsx
├── src/
│ ├── api/ # API layer
│ │ ├── client.ts # Axios/fetch + interceptors
│ │ ├── auth.api.ts
│ │ └── constants.ts
│ ├── auth/ # Auth state & hooks
│ │ ├── auth-context.tsx
│ │ ├── use-auth.ts
│ │ └── storage.ts
│ ├── features/
│ │ ├── chat/
│ │ │ ├── api/
│ │ │ ├── hooks/ # TanStack Query hooks
│ │ │ └── types.ts
│ │ └── profile/
│ ├── hooks/
│ └── lib/ # Utils, constants
├── components/
│ └── ui/ # react-native-reusables
├── constants/
└── package.json- Use for server state: auth, user, chats, messages
- Mutations: sign-in, sign-up, sign-out, forgot-password, reset-password, update profile
- Queries: current user, chats list, messages
- Cache invalidation on mutations
- Integrates well with React Native and Expo
- Option A: TanStack Query
useQuery(['account'])+useMutationfor sign-in/out — single source of truth - Option B: Zustand or React Context for minimal client state (token, user)
- Recommendation: TanStack Query for account + simple in-memory/AsyncStorage persistence for token. On app start: if token exists → fetch account; if 401 → clear token and redirect to sign-in.
| Purpose | Package | Notes |
| HTTP client | axios or fetch | Axios for interceptors |
| Async storage | @react-native-async-storage/async-storage | Token persistence |
| TanStack Query | @tanstack/react-query | Server state |
| Forms & validation | react-hook-form + zod | Same as Ship web |
| Deep linking | expo-linking | For verify-email, reset-password tokens |
| Google Auth | @react-native-google-signin/google-signin or expo-auth-session | For Google sign-in |
// src/api/client.ts
- baseURL: process.env.EXPO_PUBLIC_API_URL (from .env per environment)
- headers: { 'Content-Type': 'application/json', 'X-Client-Type': 'mobile' }
- Request: add Authorization: Bearer <token> from AsyncStorage
- Response 401: clear token, trigger redirect to sign-in- API client
- Axios instance with base URL,
X-Client-Type: mobile - Request interceptor: add Bearer token from AsyncStorage
- Response interceptor: on 401 — clear storage, redirect to sign-in (via callback/store)
- Axios instance with base URL,
- Storage
- AsyncStorage wrapper for
accessToken
- AsyncStorage wrapper for
- TanStack Query
- QueryClientProvider
- Default options (staleTime, retry)
- Environment
EXPO_PUBLIC_API_URLfrom env files (dev/staging/production)- See Section 8 for full setup
- Auth API
signIn,signUp,signOut,getAccountforgotPassword,resetPassword,verifyEmail,resendEmailsignInWithGoogle(idToken)
- Auth hooks
useSignIn,useSignUp,useSignOut,useAccountuseForgotPassword,useResetPassword,useVerifyEmail
- Auth redirect
- Root layout: check auth state → redirect to
/(auth)/sign-inor/(tabs)
- Root layout: check auth state → redirect to
- Screens
- Sign In (email, password, link to Sign Up / Forgot Password)
- Sign Up (firstName, lastName, email, password)
- Forgot Password (email)
- Verification (token from query/deep link → verify email or validate reset token)
- Reset Password (token from query, new password)
- Tabs layout
- Home, Chats, Profile (similar to Flutter bottom bar)
- Auth guard
- Redirect unauthenticated users from
/(tabs)/*to sign-in
- Redirect unauthenticated users from
- Home
- Users list (GET
/users) or placeholder
- Users list (GET
- Chats
- Mock chats list (like Flutter)
- Chat Details
- Mock chat UI, send message, typing indicator
- Profile
- Display/edit user (PUT
/account), sign out
- Display/edit user (PUT
- Mock API layer (chats, messages) — like Flutter's ChatApiMockImpl
- TanStack Query hooks for mock data
- Screens wired to hooks
- Later: optional swap to real Ship chat API when merged
- Google Sign-In (expo-auth-session or @react-native-google-signin)
- Error handling (toast/snackbar)
- Loading states
- Deep linking for verify-email and reset-password
app/
├── _layout.tsx # Root: QueryClientProvider, auth redirect
├── (auth)/
│ ├── _layout.tsx # Auth stack (no tabs)
│ ├── sign-in.tsx
│ ├── sign-up.tsx
│ ├── forgot-password.tsx
│ ├── verification.tsx # ?type=email|reset&token=...
│ └── reset-password.tsx # ?token=...
├── (tabs)/
│ ├── _layout.tsx # Tab navigator
│ ├── index.tsx # Home
│ ├── chats.tsx
│ └── profile.tsx
└── chat/[id].tsx # Chat details (outside tabs, or nested)const { data: user, isLoading } = useAccount();
if (isLoading) return <SplashScreen />;
const isAuthRoute = pathname.startsWith('/(auth)');
if (!user && !isAuthRoute) return Redirect href="/(auth)/sign-in" />;
if (user && isAuthRoute) return <Redirect href="/(tabs)" />;
return <Slot />;| Data | Tool | Example |
| Account (user) | TanStack Query | useQuery({ queryKey: ['account'], queryFn: getAccount }) |
| Sign-in/out | TanStack Query | useMutation + invalidate ['account'] |
| Token | AsyncStorage | Persist on sign-in, clear on sign-out/401 |
| Chats list | TanStack Query | useQuery(['chats'], getChats) |
| Messages | TanStack Query | useQuery(['chats', id, 'messages'], getMessages) |
| Local UI state | useState/useReducer | Forms, modals |
No Zustand/Redux required for this scope. TanStack Query covers server state and cache; auth redirect uses account query state.
| Aspect | Flutter | RN (Plan) |
| Architecture | Feature-based + Clean Arch | Feature-based, simpler layers |
| State | Riverpod | TanStack Query |
| Routing | go_router | Expo Router |
| API | Dio | Axios |
| Storage | SharedPreferences | AsyncStorage |
| Forms | Manual + validators | react-hook-form + zod |
| UI | shadcn_flutter | react-native-reusables |
| Chat | Mock | Mock (same approach) |
| Environments | --dart-define API_URL=... | .env.* + dotenv-cli + EAS profiles |
Goal: run dev server or simulator with different API URLs and app identifiers for each environment.
ship_rn_starter/
├── .env # Default / dev (commit to repo as template)
├── .env.development # Local dev (optional override)
├── .env.staging # Staging config
├── .env.production # Production config (templates only)
└── .env.local # Machine-specific overrides (gitignore)Variables per env:
| Variable | Dev | Staging | Production |
| EXPO_PUBLIC_API_URL | http://localhost:3001 | https://api-staging.example.com | https://api.example.com |
| EXPO_PUBLIC_APP_ENV | development | staging | production |
Expo loads .env by default. For .env.staging / .env.production use:
- Option A:
dotenv-cli—dotenv -e .env.staging -- expo start - Option B:
expo-envor similar — pass env file via script
{
"scripts": {
"start": "expo start",
"start:dev": "expo start",
"start:staging": "dotenv -e .env.staging -- expo start",
"start:production": "dotenv -e .env.production -- expo start",
"ios": "expo start --ios",
"ios:dev": "expo start --ios",
"ios:staging": "dotenv -e .env.staging -- expo start --ios",
"ios:production": "dotenv -e .env.production -- expo start --ios",
"android": "expo start --android",
"android:staging": "dotenv -e .env.staging -- expo start --android"
}
}Use dynamic config to show env in app name (for debugging):
// app.config.js
const IS_DEV = process.env.EXPO_PUBLIC_APP_ENV === 'development';
const IS_STAGING = process.env.EXPO_PUBLIC_APP_ENV === 'staging';
export default {
...require('./app.json').expo,
name: IS_STAGING ? 'Ship RN (Staging)' : IS_DEV ? 'Ship RN (Dev)' : 'Ship RN',
};For cloud builds (EAS Build), set env in profiles:
{
"build": {
"development": {
"env": {
"EXPO_PUBLIC_APP_ENV": "development",
"EXPO_PUBLIC_API_URL": "http://localhost:3001"
}
},
"staging": {
"env": {
"EXPO_PUBLIC_APP_ENV": "staging",
"EXPO_PUBLIC_API_URL": "https://api-staging.example.com"
},
"distribution": "internal"
},
"production": {
"env": {
"EXPO_PUBLIC_APP_ENV": "production",
"EXPO_PUBLIC_API_URL": "https://api.example.com"
}
}
}
}- Add
dotenv-clias dev dependency - Create
.env.development,.env.staging,.env.production(with placeholders) - Add scripts to
package.json - Use
process.env.EXPO_PUBLIC_API_URLin API client (already in Phase 1) - Add
app.config.jsif you want different app names per env - Add
eas.jsonwhen using EAS Build
- Dev:
npm run start:devornpm run ios:dev— connects tolocalhost - Staging:
npm run start:stagingornpm run ios:staging— connects to staging API - Production:
npm run start:production— connects to production API (use with care)
- Add dependencies:
@tanstack/react-query,axios,@react-native-async-storage/async-storage,react-hook-form,zod,@hookform/resolvers,dotenv-cli - Implement Phase 1 (API client, storage, Query setup)
- Implement Phase 1.5 (environments: .env files + scripts)
- Implement Phase 2 (auth API, hooks, screens)
- Implement Phase 3–4 (tabs, home, chats, profile)
- Implement Phase 5 (chat mock)
- Implement Phase 6 (Google Sign-In, deep links, polish)