For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Build a production feature
Add a native screen for your feature. Feedback widget walkthrough with Expo Router, Bottom Sheet forms, and the mobile API client.
Mobile features share the same backend as web: one Drizzle schema, one Hono router, one set of Zod types. What changes is how you authenticate requests, present lists, and compose UI with @workspace/ui-mobile.
This recipe picks up the feedback widget from the web feature guide. Complete the database and API steps there first, then return here for the native layer.
What you'll ship on mobile
modules/feedback/lib/api.tsmirroring the web TanStack Query layer- A settings-screen entry that opens a Bottom Sheet form
- Cookie-based auth headers so
POST /api/feedbackrecognizes the signed-in user - Translations via the same
feedbacki18n namespace
Web vs mobile
| Concern | Web | Mobile |
|---|---|---|
| API client | credentials: "include" | Manual cookie header via authClient.getCookie() |
| Platform header | WEB-CLIENT | MOBILE |
| Overlay UI | Modal | BottomSheet |
| Lists | queryOptions + DataTable | infiniteQueryOptions + FlatList |
| Navigation | Next.js router.replace | Expo Router router.push / replace |
| UI kit | @workspace/ui-web | @workspace/ui-mobile |
Organizations on mobile follow the same split. Compare apps/mobile/src/modules/organization/ with the web module when you need a bigger reference.
Mobile architecture
The settings screen opens FeedbackBottomSheet, which calls lib/api.ts. That module sends POST /api/feedback with the session cookie and MOBILE platform header.
Confirm the API client
The mobile client lives in apps/mobile/src/lib/api/index.tsx. It forwards locale + session cookies and tags requests as mobile:
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: () => ({
cookie: `${config.cookie}=${useI18nConfig.getState().config.locale};${authClient.getCookie()}`,
"x-client-platform": Platform.MOBILE,
}),
init: {
credentials: "omit",
},
});API base URL
getBaseUrl() must point at your running web API in dev and production. A mismatch here is the most common reason mobile mutations silently fail. See Using API client.
Add the feature API module
Copy the web pattern: mutationOptions, handle(), and a response schema:
import { mutationOptions } from "@tanstack/react-query";
import { createFeedbackResponseSchema } from "@workspace/api/schema";
import { handle } from "@workspace/api/utils";
import { api } from "~/lib/api";
const KEY = "feedback";
export const feedback = {
mutations: {
create: mutationOptions({
mutationKey: [KEY, "create"],
mutationFn: (json: { message: string; type: string; email?: string }) =>
handle(api.feedback.$post, {
schema: createFeedbackResponseSchema,
})({ json }),
}),
},
};For read endpoints (e.g. listing the user's past feedback), prefer infiniteQueryOptions and a FlatList. See apps/mobile/src/modules/organization/members/list/members-list.tsx for the infinite-scroll pattern.
Build the Bottom Sheet form
Mobile overlays use Bottom Sheets instead of modals. The form wiring matches web: standardSchemaResolver, Controller, and Field components.
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { Controller, useForm } from "react-hook-form";
import { Alert, View } from "react-native";
import { createFeedbackInputSchema } from "@workspace/api/schema";
import { useTranslation } from "@workspace/i18n";
import {
BottomSheet,
BottomSheetCloseTrigger,
BottomSheetContent,
BottomSheetHeader,
BottomSheetOpenTrigger,
BottomSheetScrollView,
BottomSheetTitle,
} from "@workspace/ui-mobile/bottom-sheet";
import { Button } from "@workspace/ui-mobile/button";
import { Field, FieldError, FieldLabel } from "@workspace/ui-mobile/field";
import { Input } from "@workspace/ui-mobile/input";
import { Text } from "@workspace/ui-mobile/text";
import { Textarea } from "@workspace/ui-mobile/textarea";
import { authClient } from "~/lib/auth";
import { feedback } from "./lib/api";
import type { CreateFeedbackInput } from "@workspace/api/schema";
export const FeedbackBottomSheet = ({
children,
}: {
children: React.ReactNode;
}) => {
const { t } = useTranslation(["common", "feedback"]);
const session = authClient.useSession();
const user = session.data?.user;
const create = useMutation({
...feedback.mutations.create,
onSuccess: () => {
form.reset();
Alert.alert(t("feedback:success"));
},
onError: () => Alert.alert(t("feedback:error")),
});
const form = useForm<CreateFeedbackInput>({
resolver: standardSchemaResolver(createFeedbackInputSchema),
defaultValues: { message: "", type: "general", email: "" },
});
return (
<BottomSheet>
<BottomSheetOpenTrigger>{children}</BottomSheetOpenTrigger>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{t("feedback:title")}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetScrollView>
<View className="gap-4 p-4">
{!user && (
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>{t("common:email")}</FieldLabel>
<Input {...field} keyboardType="email-address" />
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
)}
<Controller
name="message"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>{t("feedback:message.label")}</FieldLabel>
<Textarea {...field} />
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Button
onPress={form.handleSubmit((data) => create.mutate(data))}
disabled={create.isPending}
>
<Text>{t("feedback:submit")}</Text>
</Button>
<BottomSheetCloseTrigger />
</View>
</BottomSheetScrollView>
</BottomSheetContent>
</BottomSheet>
);
};Compare with CreateOrganizationBottomSheet in apps/mobile/src/modules/organization/create-organization.tsx. Same structure, different schema and success navigation.
Add a screen route
Expose the sheet from an existing settings screen or add a dedicated route under Expo Router.
import { useTranslation } from "@workspace/i18n";
import { Button } from "@workspace/ui-mobile/button";
import { Text } from "@workspace/ui-mobile/text";
import { FeedbackBottomSheet } from "~/modules/feedback/feedback-bottom-sheet";
export default function FeedbackScreen() {
const { t } = useTranslation("feedback");
return (
<FeedbackBottomSheet>
<Button variant="outline">
<Text>{t("button")}</Text>
</Button>
</FeedbackBottomSheet>
);
}Register the screen in your settings stack _layout.tsx the same way other settings rows are declared.
Reuse translations
Mobile pulls from the same packages/i18n files as web. If you added feedback.json in the web recipe, mobile strings work immediately. Pass the namespace to useTranslation(["common", "feedback"]).
Test on device
Simulator / device
- Run Metro:
pnpm --filter mobile dev - Sign in on mobile so the API receives a valid session cookie
- Open the feedback screen and submit
- Confirm the row in Postgres (same table as web)
- Success and error states use
Alert.alert, same pattern as account settings
Maestro E2E (optional)
Add a flow under apps/mobile/e2e/ that taps the feedback entry and asserts the success alert. See E2E testing.
File structure
Checklist
| Layer | Done when… |
|---|---|
| API module | feedback.mutations.create succeeds from a device |
| Auth | Signed-in submissions set userId; guests can pass email |
| UI | Bottom sheet opens, validates, shows native alert |
| Navigation | Screen reachable from settings (or your chosen entry point) |
| i18n | No hard-coded strings in the component |
Related guides
How is this guide?
Last updated on