For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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.ts mirroring the web TanStack Query layer
  • A settings-screen entry that opens a Bottom Sheet form
  • Cookie-based auth headers so POST /api/feedback recognizes the signed-in user
  • Translations via the same feedback i18n namespace

Web vs mobile

ConcernWebMobile
API clientcredentials: "include"Manual cookie header via authClient.getCookie()
Platform headerWEB-CLIENTMOBILE
Overlay UIModalBottomSheet
ListsqueryOptions + DataTableinfiniteQueryOptions + FlatList
NavigationNext.js router.replaceExpo 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:

apps/mobile/src/lib/api/index.tsx
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:

apps/mobile/src/modules/feedback/lib/api.ts
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.

apps/mobile/src/modules/feedback/feedback-bottom-sheet.tsx
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.

apps/mobile/src/app/dashboard/(user)/settings/feedback.tsx
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

  1. Run Metro: pnpm --filter mobile dev
  2. Sign in on mobile so the API receives a valid session cookie
  3. Open the feedback screen and submit
  4. Confirm the row in Postgres (same table as web)
  5. 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

feedback-bottom-sheet.tsx - Bottom sheet form
api.ts - Mutations and query keys
feedback.tsx - Settings entry point

Checklist

LayerDone when…
API modulefeedback.mutations.create succeeds from a device
AuthSigned-in submissions set userId; guests can pass email
UIBottom sheet opens, validates, shows native alert
NavigationScreen reachable from settings (or your chosen entry point)
i18nNo hard-coded strings in the component

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter