Skip to content

Native Share Extension Recipes

A React Native guide for coding agents. Also covers expo share extension, receive content from other apps, expo-sharing incoming share, expo share intent migration, ios share extension, android share intent, and 1 more.

Show all 7 aliases

expo share extension, receive content from other apps, expo-sharing incoming share, expo share intent migration, ios share extension, android share intent, share to chat destinations

A share extension crosses an operating-system boundary. Configure accepted content types in native code, route every launch through one intake screen, copy temporary files into app-owned storage, and make delivery idempotent. Never clear the system payload before the app has a durable copy.

Choose the Package for the Installed Expo SDK

For a new Expo SDK 57 app, prefer the official expo-sharing incoming-share API. Incoming sharing is currently experimental, and the iOS implementation opens the main app rather than processing entirely inside an extension view controller. Re-test it whenever Expo or iOS is upgraded.

Apps using expo-share-intent 5 can retain its provider and modal architecture until a deliberate migration. Do not configure expo-sharing and expo-share-intent to receive the same content in the same binary.

Project stateReceiver
New Expo SDK 57 appexpo-sharing with its built-in config plugin
Existing Expo 55 app already using the community packageKeep expo-share-intent while testing a planned migration
Bare React Native appFollow the selected package's manual native setup

The implementations below target Expo SDK 57 and are independently written.

Enable the Native Targets in App Config

The JavaScript hook is not enough. Enable the iOS extension and Android intent filters, then create a new development build. Choose MIME types narrowly so the app does not appear in unrelated share sheets.

// app.config.ts
import type { ConfigContext, ExpoConfig } from "expo/config";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: config.name ?? "Community",
  slug: config.slug ?? "community",
  plugins: [
    ...(config.plugins ?? []),
    [
      "expo-sharing",
      {
        ios: {
          enabled: true,
          extensionBundleIdentifier: "com.example.community.ShareExtension",
          appGroupId: "group.com.example.community",
          activationRule: {
            supportsText: true,
            supportsWebUrlWithMaxCount: 1,
            supportsImageWithMaxCount: 5,
            supportsFileWithMaxCount: 5,
          },
        },
        android: {
          enabled: true,
          singleShareMimeTypes: ["text/plain", "image/*", "application/pdf"],
          multipleShareMimeTypes: ["image/*"],
        },
      },
    ],
  ],
});

Replace the example identifiers with values derived from the app's real bundle identifier. Keep the iOS App Group stable across builds because it is the data bridge between the extension and main target.

An existing Expo 55 project using the community package has a different plugin shape:

[
  "expo-share-intent",
  {
    "iosActivationRules": {
      "NSExtensionActivationSupportsText": true,
      "NSExtensionActivationSupportsWebURLWithMaxCount": 1,
      "NSExtensionActivationSupportsImageWithMaxCount": 5,
      "NSExtensionActivationSupportsFileWithMaxCount": 5
    },
    "androidIntentFilters": ["text/*", "image/*", "application/pdf"],
    "androidMultiIntentFilters": ["image/*"],
    "iosShareExtensionName": "Share to Community",
    "iosAppGroupIdentifier": "group.com.example.community"
  }
]

Do not paste the legacy shape into an expo-sharing configuration. The option names and runtime hooks are different.

Route Every Incoming Share to One Screen

Expo Router receives an expo-sharing deep link when the operating system opens the app. Treat the path as untrusted input and keep authentication checks inside the destination screen.

// app/+native-intent.ts
export function redirectSystemPath({
  path,
}: {
  path: string;
  initial: boolean;
}): string {
  try {
    const url = new URL(path, "community://home");
    if (url.hostname === "expo-sharing") return "/share/intake";
    return url.pathname.startsWith("/") ? `${url.pathname}${url.search}` : "/";
  } catch {
    return "/share/error";
  }
}

Do not read cached share state in redirectSystemPath. A stale payload can remain after the original launch. The hostname indicates why the app opened; the intake screen owns payload resolution and recovery.

Copy Shared Files Into Durable App Storage

Resolved content URIs may refer to another provider or an App Group inbox. Copy them before clearing the operating-system payload. Persist a draft record so destination selection and retry can survive process termination.

// share/share-draft-repository.ts
import { randomUUID } from "expo-crypto";
import { Directory, File, Paths } from "expo-file-system";
import Storage from "expo-sqlite/kv-store";
import type { ResolvedSharePayload } from "expo-sharing";
import { z } from "zod";

const pendingShareKey = "pending-share.v2";
const maxAssetCount = 5;
const maxAssetBytes = 25 * 1024 * 1024;
const allowedFileMimeTypes = new Set([
  "application/pdf",
  "text/plain",
]);

const sharedAssetSchema = z.object({
  id: z.string(),
  uri: z.string(),
  name: z.string(),
  mimeType: z.string(),
  size: z.number().nullable(),
  kind: z.enum(["image", "video", "audio", "file", "website"]),
});

const shareDraftSchema = z.object({
  id: z.string(),
  createdAt: z.string(),
  text: z.array(z.string()),
  assets: z.array(sharedAssetSchema).max(maxAssetCount),
  note: z.string().max(500),
  destinationIds: z.array(z.string().min(1)).max(20),
});

export type SharedAsset = z.infer<typeof sharedAssetSchema>;
export type ShareDraft = z.infer<typeof shareDraftSchema>;

function safeFilename(value: string): string {
  const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
  return cleaned.slice(0, 100) || `shared-${Date.now()}`;
}

function isAllowedMimeType(mimeType: string): boolean {
  return mimeType.startsWith("image/")
    || mimeType.startsWith("video/")
    || mimeType.startsWith("audio/")
    || allowedFileMimeTypes.has(mimeType);
}

async function deleteDraftInbox(draftId: string): Promise<void> {
  const inbox = new Directory(Paths.document, "shared-inbox", draftId);
  if (inbox.exists) await inbox.delete();
}

type ValidatedAssetPayload = {
  source: File;
  originalName: string;
  mimeType: string;
  size: number;
  kind: SharedAsset["kind"];
};

function validateAssetPayloads(
  payloads: readonly ResolvedSharePayload[],
): ValidatedAssetPayload[] {
  const assetPayloads = payloads.filter(
    (payload) => Boolean(payload.contentUri) && payload.contentType !== "website",
  );
  if (assetPayloads.length > maxAssetCount) {
    throw new Error(`Share at most ${maxAssetCount} files at a time`);
  }

  return assetPayloads.map((payload, index) => {
    if (!payload.contentUri || !payload.contentType || payload.contentType === "text") {
      throw new Error("Shared file metadata is incomplete");
    }

    const source = new File(payload.contentUri);
    const mimeType = payload.contentMimeType ?? source.type;
    const size = payload.contentSize ?? source.size;
    if (!mimeType || !isAllowedMimeType(mimeType)) {
      throw new Error("This file type is not supported");
    }
    if (!Number.isFinite(size) || size < 0 || size > maxAssetBytes) {
      throw new Error("A shared file is too large or has an unknown size");
    }

    return {
      source,
      originalName: payload.originalName || `shared-${index + 1}${source.extension || ""}`,
      mimeType,
      size,
      kind: payload.contentType,
    };
  });
}

export async function prepareShareDraft(
  payloads: readonly ResolvedSharePayload[],
): Promise<ShareDraft> {
  const draftId = randomUUID();
  const text = payloads.flatMap((payload) => {
    // Expo text payloads may omit contentType; no contentUri is the stable signal.
    if (payload.contentUri && payload.contentType !== "website") return [];
    const value = (payload.value ?? payload.contentUri)?.trim();
    return value ? [value] : [];
  });
  const validatedAssets = validateAssetPayloads(payloads);
  if (text.length === 0 && validatedAssets.length === 0) {
    throw new Error("No supported shared content was found");
  }

  const inbox = new Directory(Paths.document, "shared-inbox", draftId);
  let persisted = false;

  try {
    await inbox.create({ idempotent: true, intermediates: true });
    const assets: SharedAsset[] = [];

    for (const [index, payload] of validatedAssets.entries()) {
      const uniqueName = `${String(index + 1).padStart(2, "0")}-${safeFilename(payload.originalName)}`;
      const destination = new File(inbox, uniqueName);
      await payload.source.copy(destination);

      assets.push({
        id: randomUUID(),
        uri: destination.uri,
        name: destination.name,
        mimeType: payload.mimeType,
        size: payload.size,
        kind: payload.kind,
      });
    }

    const draft: ShareDraft = {
      id: draftId,
      createdAt: new Date().toISOString(),
      text,
      assets,
      note: "",
      destinationIds: [],
    };
    await Storage.setItem(pendingShareKey, JSON.stringify(draft));
    persisted = true;
    return draft;
  } finally {
    if (!persisted) {
      await deleteDraftInbox(draftId).catch(() => undefined);
    }
  }
}

export async function loadShareDraft(): Promise<ShareDraft | null> {
  const value = await Storage.getItem(pendingShareKey);
  if (!value) return null;
  try {
    const parsed = shareDraftSchema.safeParse(JSON.parse(value));
    return parsed.success ? parsed.data : null;
  } catch {
    return null;
  }
}

export async function saveShareDraftEdits(
  draft: ShareDraft,
  note: string,
  destinationIds: readonly string[],
): Promise<ShareDraft> {
  const updated = shareDraftSchema.parse({
    ...draft,
    note: note.slice(0, 500),
    destinationIds: [...new Set(destinationIds)],
  });
  await Storage.setItem(pendingShareKey, JSON.stringify(updated));
  return updated;
}

export async function clearShareDraft(): Promise<void> {
  const draft = await loadShareDraft();
  if (draft) await deleteDraftInbox(draft.id);
  await Storage.removeItem(pendingShareKey);
}

Version the storage key or migrate the schema when app upgrades change the draft shape. Enforce file count, MIME type, and size limits both before upload and on the server.

Resolve, Persist, Then Clear the System Payload

Guard the effect with a payload fingerprint so React remounts do not create duplicate drafts. If preparation fails, keep the payload and offer Retry.

// app/share/intake.tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { useIncomingShare } from "expo-sharing";
import { prepareShareDraft } from "../../share/share-draft-repository";

type IntakeState =
  | { status: "waiting" }
  | { status: "preparing" }
  | { status: "error"; message: string };

export default function ShareIntakeScreen() {
  const router = useRouter();
  const {
    resolvedSharedPayloads,
    isResolving,
    error,
    clearSharedPayloads,
    refreshSharePayloads,
  } = useIncomingShare();
  const [state, setState] = useState<IntakeState>({ status: "waiting" });
  const handledFingerprint = useRef<string | null>(null);

  const fingerprint = useMemo(
    () => JSON.stringify(
      resolvedSharedPayloads.map((payload) => [
        payload.contentType,
        payload.contentUri,
        payload.value,
        payload.contentSize,
      ]),
    ),
    [resolvedSharedPayloads],
  );

  useEffect(() => {
    if (isResolving || error || resolvedSharedPayloads.length === 0) return;
    if (handledFingerprint.current === fingerprint) return;
    handledFingerprint.current = fingerprint;

    setState({ status: "preparing" });
    prepareShareDraft(resolvedSharedPayloads)
      .then((draft) => {
        clearSharedPayloads();
        router.replace({ pathname: "/share/compose", params: { draftId: draft.id } });
      })
      .catch(() => {
        handledFingerprint.current = null;
        setState({ status: "error", message: "The shared items could not be prepared." });
      });
  }, [clearSharedPayloads, error, fingerprint, isResolving, resolvedSharedPayloads, router]);

  if (error || state.status === "error") {
    return (
      <View style={intakeStyles.center}>
        <Text accessibilityRole="alert">The shared items could not be opened.</Text>
        <Pressable accessibilityRole="button" onPress={refreshSharePayloads} style={intakeStyles.action}>
          <Text>Try again</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <View style={intakeStyles.center}>
      <Text accessibilityLiveRegion="polite">
        {isResolving ? "Reading shared items" : "Preparing shared items"}
      </Text>
    </View>
  );
}

const intakeStyles = StyleSheet.create({
  center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12, padding: 24 },
  action: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center", paddingHorizontal: 12 },
});

If the user is signed out, keep the prepared draft and route through authentication. Resume the compose screen after sign-in rather than asking the user to share the content again.

Deliver to Multiple Destinations Idempotently

Upload each durable asset once. Submit one server command containing every destination and use the draft ID as the idempotency key. The server should record per-destination results and retry only unfinished destinations.

export type ShareDestination = {
  id: string;
  name: string;
  kind: "conversation" | "person";
};

export type UploadedShareAsset = {
  id: string;
  url: string;
  name: string;
  mimeType: string;
};

type AuthorizedFetch = (
  input: string,
  init?: RequestInit,
) => Promise<Response>;

export async function deliverShare(
  authorizedFetch: AuthorizedFetch,
  draft: ShareDraft,
  destinationIds: readonly string[],
  uploadedAssets: readonly UploadedShareAsset[],
): Promise<void> {
  const response = await authorizedFetch("/api/share-deliveries", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": draft.id,
    },
    body: JSON.stringify({
      draftId: draft.id,
      destinationIds,
      text: [...draft.text, draft.note.trim()].filter(Boolean),
      assets: uploadedAssets,
    }),
  });

  if (!response.ok) throw new Error("Shared content could not be delivered");
}

Do not create chats and messages in an unprotected client loop. A failure after destination 2 of 5 otherwise makes a retry duplicate the first 2 deliveries.

Build an Accessible Destination Composer

The compose screen receives the durable draft, not temporary system URIs. Keep failed delivery visible and preserve the note and recipient selection for Retry.

import { useCallback, useEffect, useState } from "react";
import {
  FlatList,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
} from "react-native";
import { saveShareDraftEdits } from "../../share/share-draft-repository";

type SharePalette = {
  surface: string;
  border: string;
  foreground: string;
  mutedForeground: string;
  primary: string;
  primaryForeground: string;
  danger: string;
};

export function ShareDestinationComposer({
  draft,
  destinations,
  palette,
  onDeliver,
  onDiscard,
}: {
  draft: ShareDraft;
  destinations: readonly ShareDestination[];
  palette: SharePalette;
  onDeliver(draft: ShareDraft, destinationIds: readonly string[]): Promise<void>;
  onDiscard(): void;
}) {
  const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
    () => new Set(draft.destinationIds),
  );
  const [note, setNote] = useState(draft.note);
  const [sending, setSending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const canSend = selectedIds.size > 0 && !sending;

  const selectedLabel = `${selectedIds.size} ${selectedIds.size === 1 ? "destination" : "destinations"} selected`;

  const toggleDestination = useCallback((id: string) => {
    setSelectedIds((current) => {
      const next = new Set(current);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const renderDestination = useCallback(
    ({ item }: { item: ShareDestination }) => {
      const checked = selectedIds.has(item.id);
      return (
        <Pressable
          accessibilityLabel={item.name}
          accessibilityRole="checkbox"
          accessibilityState={{ checked }}
          onPress={() => toggleDestination(item.id)}
          style={[composeStyles.destination, { borderBottomColor: palette.border }]}
        >
          <Text numberOfLines={1} style={{ flex: 1, color: palette.foreground, fontWeight: "600" }}>
            {item.name}
          </Text>
          <Text style={{ color: checked ? palette.primary : palette.mutedForeground }}>
            {checked ? "Selected" : "Select"}
          </Text>
        </Pressable>
      );
    },
    [palette.border, palette.foreground, palette.mutedForeground, palette.primary, selectedIds, toggleDestination],
  );

  useEffect(() => {
    const timer = setTimeout(() => {
      void saveShareDraftEdits(draft, note, [...selectedIds]).catch(() => {
        setError("Draft changes could not be saved. Keep this screen open and try again.");
      });
    }, 250);
    return () => clearTimeout(timer);
  }, [draft, note, selectedIds]);

  async function submit() {
    if (!canSend) return;
    setSending(true);
    setError(null);
    try {
      const destinationIds = [...selectedIds];
      const savedDraft = await saveShareDraftEdits(draft, note, destinationIds);
      await onDeliver(savedDraft, destinationIds);
    } catch {
      setError("Nothing was lost. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  return (
    <View style={[composeStyles.screen, { backgroundColor: palette.surface }]}>
      <View style={[composeStyles.header, { borderBottomColor: palette.border }]}>
        <Pressable accessibilityRole="button" onPress={onDiscard} style={composeStyles.headerAction}>
          <Text style={{ color: palette.foreground }}>Cancel</Text>
        </Pressable>
        <Text accessibilityRole="header" style={[composeStyles.title, { color: palette.foreground }]}>Share</Text>
        <View style={composeStyles.headerAction} />
      </View>

      <FlatList
        accessibilityLabel="Share destinations"
        data={destinations}
        keyExtractor={(destination) => destination.id}
        ListEmptyComponent={
          <Text style={[composeStyles.empty, { color: palette.mutedForeground }]}>
            No destinations are available. Check your connection and try again.
          </Text>
        }
        ListHeaderComponent={
          <View style={composeStyles.preview}>
            <Text style={{ color: palette.foreground }}>
              {draft.assets.length > 0
                ? `${draft.assets.length} ${draft.assets.length === 1 ? "attachment" : "attachments"}`
                : draft.text.join("\n")}
            </Text>
          </View>
        }
        renderItem={renderDestination}
      />

      <View style={[composeStyles.footer, { borderTopColor: palette.border }]}>
        <TextInput
          accessibilityLabel="Add a message"
          maxLength={500}
          multiline
          onChangeText={setNote}
          placeholder="Add a message"
          placeholderTextColor={palette.mutedForeground}
          style={[composeStyles.note, { borderColor: palette.border, color: palette.foreground }]}
          value={note}
        />
        <View style={composeStyles.sendRow}>
          <Text accessibilityLiveRegion="polite" style={{ flex: 1, color: error ? palette.danger : palette.mutedForeground }}>
            {error ?? selectedLabel}
          </Text>
          <Pressable
            accessibilityLabel={sending ? "Sending shared content" : "Send shared content"}
            accessibilityRole="button"
            accessibilityState={{ busy: sending, disabled: !canSend }}
            disabled={!canSend}
            onPress={submit}
            style={[composeStyles.send, { backgroundColor: palette.primary }, !canSend ? composeStyles.disabled : null]}
          >
            <Text style={{ color: palette.primaryForeground, fontWeight: "700" }}>
              {sending ? "Sending" : "Send"}
            </Text>
          </Pressable>
        </View>
      </View>
    </View>
  );
}

const composeStyles = StyleSheet.create({
  screen: { flex: 1 },
  header: { minHeight: 56, flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 8 },
  headerAction: { width: 72, minHeight: 44, alignItems: "center", justifyContent: "center" },
  title: { flex: 1, textAlign: "center", fontSize: 17, fontWeight: "700" },
  preview: { padding: 16 },
  empty: { padding: 16 },
  destination: { minHeight: 56, flexDirection: "row", alignItems: "center", gap: 12, borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16 },
  footer: { gap: 10, borderTopWidth: StyleSheet.hairlineWidth, padding: 12 },
  note: { minHeight: 48, maxHeight: 120, borderWidth: 1, borderRadius: 16, borderCurve: "continuous", paddingHorizontal: 12, paddingVertical: 10, fontSize: 16 },
  sendRow: { minHeight: 44, flexDirection: "row", alignItems: "center", gap: 12 },
  send: { minWidth: 72, minHeight: 44, alignItems: "center", justifyContent: "center", borderRadius: 16, borderCurve: "continuous" },
  disabled: { opacity: 0.45 },
});

After successful delivery, delete the persisted draft and its copied files. On Cancel, confirm only if the user added a note or selected destinations; otherwise discard immediately.

Share Extension Invariants

  • Native plugin changes require a new binary, not a JavaScript reload.
  • Accept only the MIME types the product can safely process.
  • Copy provider-backed files before clearing the system payload.
  • Persist one durable draft before navigation, authentication, or destination selection.
  • Upload each asset once and deliver to all destinations through one idempotent server operation.
  • Preserve the draft, recipients, and note when delivery fails.
  • Distinguish resolving, empty, unsupported, failed, and ready states.
  • Validate file count, size, MIME type, ownership, and destination access on the server.
  • Re-test iOS behavior after every Expo or iOS upgrade while incoming sharing remains experimental.

Sources

Configuration, routing, payload types, and the experimental status follow the Expo SDK 57 Sharing documentation. Durable file handling follows Expo FileSystem. The legacy compatibility note follows the expo-share-intent project. All code is independently written.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "share-extension-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems