This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You’ll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a React Native application.
This is Part 5 in the Full-Stack React Native Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.

Full-Stack React Native Development with Nhost

Prerequisites

What You’ll Build

By the end of this part, you’ll have:
  • A personal bucket so users can upload their own private files
  • File upload functionality
  • File management interface for viewing and deleting files
  • Security permissions ensuring users can only access their own files

Step-by-Step Guide

1

Create a Personal Storage Bucket

First, we’ll create a storage bucket where users can upload their personal files.In your Nhost project dashboard:
  1. Navigate to Database
  2. Change to schema.storage, then buckets
  3. Now click on + Insert on the top right corner.
  4. As id set personal, leave the rest of the fields blank and click on Insert at the bottom Create bucket
2

Configure Storage Permissions

Now we need to set up permissions for the storage bucket to ensure the user role can only upload, view, and delete their own files.
To upload files we need to grant permissions to insert on the table storage.files. Because we want to allow uploading files only to the personal bucket we will be using the bucket_id eq personal as a custom check. In addition, we are configuring a preset uploaded_by_user_id = X-Hasura-User-id, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.upload files permissions
You can read more about storage permissions here
3

Install Required Dependencies

First, let’s install the dependencies needed for file handling in React Native.
npx expo install expo-document-picker@13 expo-file-system@18 expo-sharing@13
  • expo-document-picker: For selecting files from device storage
  • expo-file-system: For handling file system operations
  • expo-sharing: For sharing/opening files with other apps
4

Create the File Upload Screen Component

Now let’s implement the React Native screen for file upload functionality using the shared theme from the protected routes tutorial.
app/files.tsx
import type { FetchError } from "@nhost/nhost-js/fetch";
import type { ErrorResponse, FileMetadata } from "@nhost/nhost-js/storage";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import { Stack } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useState } from "react";
import {
  ActivityIndicator,
  Alert,
  FlatList,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, fileUploadStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";

interface DeleteStatus {
  message: string;
  isError: boolean;
}

interface GraphqlGetFilesResponse {
  files: FileMetadata[];
}

// Utility function to format file size
function formatFileSize(bytes: number, decimals = 2): string {
  if (bytes === 0) return "0 Bytes";

  const k = 1024;
  const dm = decimals < 0 ? 0 : decimals;
  const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];

  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}

// Convert Blob to Base64 for React Native file handling
function blobToBase64(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      const base64data = reader.result as string;
      // Remove the data URL prefix (e.g., "data:application/octet-stream;base64,")
      const base64Content = base64data.split(",")[1] || "";
      resolve(base64Content);
    };
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

export default function Files() {
  const { nhost } = useAuth();
  const [selectedFile, setSelectedFile] =
    useState<DocumentPicker.DocumentPickerResult | null>(null);
  const [uploading, setUploading] = useState<boolean>(false);
  const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
  const [isFetching, setIsFetching] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [files, setFiles] = useState<FileMetadata[]>([]);
  const [viewingFile, setViewingFile] = useState<string | null>(null);
  const [deleting, setDeleting] = useState<string | null>(null);
  const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
  const [isViewingInProgress, setIsViewingInProgress] =
    useState<boolean>(false);

  const fetchFiles = useCallback(async () => {
    setIsFetching(true);
    setError(null);

    try {
      // Fetch files using GraphQL query
      const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
        query: `query GetFiles {
          files {
            id
            name
            size
            mimeType
            bucketId
            uploadedByUserId
          }
        }`,
      });

      setFiles(response.body.data?.files || []);
    } catch (err) {
      const errMessage =
        err instanceof Error ? err.message : "An unexpected error occurred";
      setError(`Failed to fetch files: ${errMessage}`);
    } finally {
      setIsFetching(false);
    }
  }, [nhost.graphql]);

  // Fetch existing files when component mounts
  useEffect(() => {
    void fetchFiles();
  }, [fetchFiles]);

  const pickDocument = async () => {
    // Prevent DocumentPicker from opening if we're currently viewing a file
    if (isViewingInProgress) {
      return;
    }

    try {
      const result = await DocumentPicker.getDocumentAsync({
        type: "*/*", // All file types
        copyToCacheDirectory: true,
      });

      if (!result.canceled) {
        setSelectedFile(result);
        setError(null);
        setUploadResult(null);
      }
    } catch (err) {
      setError("Failed to pick document");
      console.error("DocumentPicker Error:", err);
    }
  };

  const handleUpload = async () => {
    if (!selectedFile || selectedFile.canceled) {
      setError("Please select a file to upload");
      return;
    }

    setUploading(true);
    setError(null);

    try {
      // For React Native, we need to read the file first
      const fileToUpload = selectedFile.assets?.[0];
      if (!fileToUpload) {
        throw new Error("No file selected");
      }

      const file: unknown = {
        uri: fileToUpload.uri,
        name: fileToUpload.name || "file",
        type: fileToUpload.mimeType || "application/octet-stream",
      };

      // Upload file to the personal bucket
      // The uploadedByUserId is automatically set by the storage permissions
      const response = await nhost.storage.uploadFiles({
        "bucket-id": "personal",
        "file[]": [file as File],
      });

      // Get the processed file data
      const uploadedFile = response.body.processedFiles?.[0];
      if (uploadedFile === undefined) {
        throw new Error("Failed to upload file");
      }

      setUploadResult(uploadedFile);

      // Reset form
      setSelectedFile(null);

      // Update files list
      setFiles((prevFiles) => [uploadedFile, ...prevFiles]);

      // Refresh file list
      await fetchFiles();

      // Clear success message after 3 seconds
      setTimeout(() => {
        setUploadResult(null);
      }, 3000);
    } catch (err: unknown) {
      const error = err as FetchError<ErrorResponse>;
      setError(`Failed to upload file: ${error.message}`);
      console.error("Upload error:", err);
    } finally {
      setUploading(false);
    }
  };

  // Function to handle viewing a file with proper authorization
  const handleViewFile = async (
    fileId: string,
    fileName: string,
    mimeType: string,
  ) => {
    setViewingFile(fileId);
    setIsViewingInProgress(true);

    try {
      // Fetch the file with authentication using the SDK
      const response = await nhost.storage.getFile(fileId);

      if (!response.body) {
        throw new Error("Failed to retrieve file contents");
      }

      // For iOS/Android, we need to save the file to the device first
      // Create a unique temp file path with a timestamp to prevent collisions
      const timestamp = Date.now();
      const tempFileName = fileName.includes(".")
        ? fileName
        : `${fileName}.file`;
      const tempFilePath = `${FileSystem.cacheDirectory}${timestamp}_${tempFileName}`;

      // Get the blob from the response
      const blob = response.body;

      // Convert blob to base64
      const base64Data = await blobToBase64(blob);

      // Write the file to the filesystem
      await FileSystem.writeAsStringAsync(tempFilePath, base64Data, {
        encoding: FileSystem.EncodingType.Base64,
      });

      // Check if sharing is available (iOS & Android)
      const isSharingAvailable = await Sharing.isAvailableAsync();

      if (isSharingAvailable) {
        // Open the file with the default app
        await Sharing.shareAsync(tempFilePath, {
          mimeType: mimeType || "application/octet-stream",
          dialogTitle: `View ${fileName}`,
          UTI: mimeType, // for iOS
        });

        // Clean up the temp file after sharing
        try {
          await FileSystem.deleteAsync(tempFilePath, { idempotent: true });
        } catch (cleanupErr) {
          console.warn("Failed to clean up temp file:", cleanupErr);
        }

        // Add a delay before allowing new document picker actions
        // This prevents iOS from triggering file selection dialogs
        setTimeout(() => {
          setIsViewingInProgress(false);
        }, 1000);
      } else {
        throw new Error("Sharing is not available on this device");
      }
    } catch (err) {
      const error = err as FetchError<ErrorResponse>;
      setError(`Failed to view file: ${error.message}`);
      console.error("Error viewing file:", err);
      Alert.alert("Error", `Failed to view file: ${error.message}`);
      setIsViewingInProgress(false);
    } finally {
      setViewingFile(null);
    }
  };

  // Function to handle deleting a file
  const handleDeleteFile = (fileId: string) => {
    if (!fileId || deleting) return;

    // Confirm deletion
    Alert.alert("Delete File", "Are you sure you want to delete this file?", [
      {
        text: "Cancel",
        style: "cancel",
      },
      {
        text: "Delete",
        style: "destructive",
        onPress: () => {
          void (async () => {
            setDeleting(fileId);
            setError(null);
            setDeleteStatus(null);

            // Get the file name for the status message
            const fileToDelete = files.find((file) => file.id === fileId);
            const fileName = fileToDelete?.name || "File";

            try {
              // Delete the file using the Nhost storage SDK
              // Permissions ensure users can only delete their own files
              await nhost.storage.deleteFile(fileId);

              // Show success message
              setDeleteStatus({
                message: `${fileName} deleted successfully`,
                isError: false,
              });

              // Update the local files list by removing the deleted file
              setFiles(files.filter((file) => file.id !== fileId));

              // Refresh the file list
              await fetchFiles();

              // Clear the success message after 3 seconds
              setTimeout(() => {
                setDeleteStatus(null);
              }, 3000);
            } catch (err) {
              // Show error message
              const error = err as FetchError<ErrorResponse>;
              setDeleteStatus({
                message: `Failed to delete ${fileName}: ${error.message}`,
                isError: true,
              });
              console.error("Error deleting file:", err);
            } finally {
              setDeleting(null);
            }
          })();
        },
      },
    ]);
  };

  return (
    <ProtectedScreen>
      <Stack.Screen options={{ title: "File Upload" }} />
      <View style={commonStyles.container}>
        {/* Upload Form */}
        <View style={commonStyles.card}>
          <Text style={commonStyles.cardTitle}>Upload a File</Text>

          <TouchableOpacity
            style={fileUploadStyles.fileUpload}
            onPress={pickDocument}
          >
            <View style={fileUploadStyles.uploadIcon}>
              <Text style={fileUploadStyles.uploadIconText}>⬆️</Text>
            </View>
            <Text style={fileUploadStyles.uploadText}>
              Tap to select a file
            </Text>
            {selectedFile &&
              !selectedFile.canceled &&
              selectedFile.assets?.[0] && (
                <Text style={fileUploadStyles.fileName}>
                  {selectedFile.assets[0].name} (
                  {formatFileSize(selectedFile.assets[0].size || 0)})
                </Text>
              )}
          </TouchableOpacity>

          {error && (
            <View style={commonStyles.errorContainer}>
              <Text style={commonStyles.errorText}>{error}</Text>
            </View>
          )}

          {uploadResult && (
            <View style={commonStyles.successContainer}>
              <Text style={commonStyles.successText}>
                File uploaded successfully!
              </Text>
            </View>
          )}

          <TouchableOpacity
            style={[
              commonStyles.button,
              (!selectedFile || selectedFile.canceled || uploading) &&
                fileUploadStyles.buttonDisabled,
            ]}
            onPress={handleUpload}
            disabled={!selectedFile || selectedFile.canceled || uploading}
          >
            <Text style={commonStyles.buttonText}>
              {uploading ? "Uploading..." : "Upload File"}
            </Text>
          </TouchableOpacity>
        </View>

        {/* Files List */}
        <View style={commonStyles.card}>
          <Text style={commonStyles.cardTitle}>Your Files</Text>

          {deleteStatus && (
            <View
              style={[
                deleteStatus.isError
                  ? commonStyles.errorContainer
                  : commonStyles.successContainer,
              ]}
            >
              <Text
                style={
                  deleteStatus.isError
                    ? commonStyles.errorText
                    : commonStyles.successText
                }
              >
                {deleteStatus.message}
              </Text>
            </View>
          )}

          {isFetching ? (
            <View style={commonStyles.loadingContainer}>
              <ActivityIndicator size="large" color={colors.primary} />
              <Text style={commonStyles.loadingText}>Loading files...</Text>
            </View>
          ) : files.length === 0 ? (
            <View style={fileUploadStyles.emptyState}>
              <Text style={fileUploadStyles.emptyIcon}>📄</Text>
              <Text style={fileUploadStyles.emptyTitle}>No files yet</Text>
              <Text style={fileUploadStyles.emptyDescription}>
                Upload your first file to get started!
              </Text>
            </View>
          ) : (
            <FlatList
              data={files}
              keyExtractor={(item) => item.id || Math.random().toString()}
              renderItem={({ item }) => (
                <View style={fileUploadStyles.fileItem}>
                  <View style={fileUploadStyles.fileInfo}>
                    <Text
                      style={fileUploadStyles.fileNameText}
                      numberOfLines={1}
                    >
                      {item.name}
                    </Text>
                    <Text style={fileUploadStyles.fileDetails}>
                      {item.mimeType}{formatFileSize(item.size || 0)}
                    </Text>
                  </View>
                  <View style={fileUploadStyles.fileActions}>
                    <TouchableOpacity
                      style={fileUploadStyles.actionButton}
                      onPress={() =>
                        handleViewFile(
                          item.id || "unknown",
                          item.name || "unknown",
                          item.mimeType || "unknown",
                        )
                      }
                      disabled={viewingFile === item.id}
                    >
                      {viewingFile === item.id ? (
                        <Text style={fileUploadStyles.actionText}></Text>
                      ) : (
                        <Text style={fileUploadStyles.actionText}>👁️</Text>
                      )}
                    </TouchableOpacity>
                    <TouchableOpacity
                      style={[
                        fileUploadStyles.actionButton,
                        fileUploadStyles.deleteButton,
                      ]}
                      onPress={() => handleDeleteFile(item.id || "unknown")}
                      disabled={deleting === item.id}
                    >
                      {deleting === item.id ? (
                        <Text style={fileUploadStyles.actionText}></Text>
                      ) : (
                        <Text style={fileUploadStyles.actionText}>🗑️</Text>
                      )}
                    </TouchableOpacity>
                  </View>
                </View>
              )}
              style={fileUploadStyles.fileList}
            />
          )}
        </View>
      </View>
    </ProtectedScreen>
  );
}
5

Update Home Screen Navigation

Update the home screen to include navigation to the new file upload screen.
app/index.tsx
import { useRouter } from "expo-router";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, homeStyles } from "./styles/commonStyles";

export default function Index() {
  const router = useRouter();
  const { isAuthenticated, session, nhost, user } = useAuth();

  const handleSignOut = async () => {
    Alert.alert("Sign Out", "Are you sure you want to sign out?", [
      { text: "Cancel", style: "cancel" },
      {
        text: "Sign Out",
        style: "destructive",
        onPress: async () => {
          try {
            if (session) {
              await nhost.auth.signOut({
                refreshToken: session.refreshToken,
              });
            }
            router.replace("/");
          } catch (err: unknown) {
            const message = err instanceof Error ? err.message : String(err);
            Alert.alert("Error", `Failed to sign out: ${message}`);
          }
        },
      },
    ]);
  };

  return (
    <View style={commonStyles.centerContent}>
      <Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>

      <View style={homeStyles.welcomeCard}>
        {isAuthenticated ? (
          <View style={{ gap: 15, width: "100%" }}>
            <Text style={homeStyles.welcomeText}>
              Hello, {user?.displayName || user?.email}!
            </Text>

            <TouchableOpacity
              style={[commonStyles.button, commonStyles.fullWidth]}
              onPress={() => router.push("/todos")}
            >
              <Text style={commonStyles.buttonText}>My Todos</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[commonStyles.button, commonStyles.fullWidth]}
              onPress={() => router.push("/files")}
            >
              <Text style={commonStyles.buttonText}>My Files</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[commonStyles.button, commonStyles.fullWidth]}
              onPress={() => router.push("/profile")}
            >
              <Text style={commonStyles.buttonText}>Go to Profile</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
              onPress={handleSignOut}
            >
              <Text style={commonStyles.buttonText}>Sign Out</Text>
            </TouchableOpacity>
          </View>
        ) : (
          <>
            <Text style={homeStyles.authMessage}>You are not signed in.</Text>

            <View style={{ gap: 15, width: "100%" }}>
              <TouchableOpacity
                style={[commonStyles.button, commonStyles.fullWidth]}
                onPress={() => router.push("/signin")}
              >
                <Text style={commonStyles.buttonText}>Sign In</Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={[
                  commonStyles.button,
                  commonStyles.buttonSecondary,
                  commonStyles.fullWidth,
                ]}
                onPress={() => router.push("/signup")}
              >
                <Text style={commonStyles.buttonText}>Sign Up</Text>
              </TouchableOpacity>
            </View>
          </>
        )}
      </View>
    </View>
  );
}
6

Test Your File Upload System

Run your React Native application and test all the functionality:
npm start
Things to try out:
  1. Try signing in and out and see how the file upload screen is only accessible when signed in.
  2. Upload different types of files (images, documents, PDFs, etc.)
  3. View files using the native sharing functionality
  4. Delete files with confirmation dialogs
  5. Sign in with another account and verify you cannot see files from the first account
  6. Test on both iOS and Android if available

Key Features Implemented