Client
- Overview
- Attachments / Files
- Performance
- Data Management
Self-Hosting
Use AWS S3 for attachment storage
In this tutorial, we will show you how to replace Supabase Storage with AWS S3 for handling attachments in the React Native To-Do List demo app.
The following pre-requisites are required to complete this tutorial:
- Clone the To-Do List demo app repo
- Follow the instructions in the README and ensure that the app runs locally
- A running PowerSync Service (can be self-hosted)
Steps
This tutorial assumes that you have an AWS account. If you do not have an AWS account, you can create one here.
To enable attachment storage using AWS S3, set up an S3 bucket by following these steps:
Create an S3 Bucket
- Go to the S3 Console and click
Create bucket
. - Enter a unique bucket name and select your preferred region.
- Under
Object Ownership
, set ACLs disabled and ensure the bucket is private. - Enable Bucket Versioning if you need to track changes to files (optional).
Configure Permissions
Go to the Permissions tab and set up the following:
- A bucket policy for access control
- Click Bucket policy and enter a policy allowing the necessary actions (e.g., s3:PutObject, s3:GetObject) for the specific users or roles.
- (Optional) Configure CORS (Cross-Origin Resource Sharing) if your app requires it
Create an IAM User
- Go to the IAM Console and create a new user with programmatic access.
- Attach an AmazonS3FullAccess policy to this user, or create a custom policy with specific permissions for the bucket.
- Save the Access Key ID and Secret Access Key.
Update package.json
Add the following dependencies to the package.json
file in the demos/react-native-supabase-todolist
directory:
"react-navigation-stack": "^2.10.4",
"react-native-crypto": "^2.2.0",
"react-native-randombytes": "^3.6.1",
"aws-sdk": "^2.1352.0"
Install dependencies
Run pnpm install
to install the new dependencies.
Add the following environment variables to the .env
file and update the values with your AWS S3 configuration created in Step 1:
...
EXPO_PUBLIC_AWS_S3_REGION=region
EXPO_PUBLIC_AWS_S3_BUCKET_NAME=bucket_name
EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID=***
EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY=***
Update process-env.d.tss
Update process-env.d.ts
in the demos/react-native-supabase-todolist
directory and add the following highlighted lines:
export {};
declare global {
namespace NodeJS {
interface ProcessEnv {
[key: string]: string | undefined;
EXPO_PUBLIC_SUPABASE_URL: string;
EXPO_PUBLIC_SUPABASE_ANON_KEY: string;
EXPO_PUBLIC_SUPABASE_BUCKET: string;
EXPO_PUBLIC_POWERSYNC_URL: string;
EXPO_PUBLIC_EAS_PROJECT_ID: string;
EXPO_PUBLIC_AWS_S3_REGION: string;
EXPO_PUBLIC_AWS_S3_BUCKET_NAME: string;
EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID: string;
EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY: string;
}
}
}
Update AppConfig.ts
Update AppConfig.ts
in the demos/react-native-supabase-todolist/library/supabase
directory and add the following highlighted lines:
export const AppConfig = {
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL,
supabaseAnonKey: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
supabaseBucket: process.env.EXPO_PUBLIC_SUPABASE_BUCKET || '',
powersyncUrl: process.env.EXPO_PUBLIC_POWERSYNC_URL,
region: process.env.EXPO_PUBLIC_AWS_S3_REGION,
accessKeyId: process.env.EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID || '',
secretAccessKey: process.env.EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY || '',
s3bucketName: process.env.EXPO_PUBLIC_AWS_S3_BUCKET_NAME || ''
};
Create a AWSStorageAdapter.ts
file in the demos/react-native-supabase-todolist/library/storage
directory and add the following contents:
import * as FileSystem from 'expo-file-system';
import S3 from 'aws-sdk/clients/s3';
import { decode as decodeBase64 } from 'base64-arraybuffer';
import { StorageAdapter } from '@powersync/attachments';
import { AppConfig } from '../supabase/AppConfig';
export interface S3StorageAdapterOptions {
client: S3;
}
export class AWSStorageAdapter implements StorageAdapter {
constructor(private options: S3StorageAdapterOptions) {}
async uploadFile(
filename: string,
data: ArrayBuffer,
options?: {
mediaType?: string;
}
): Promise<void> {
if (!AppConfig.s3bucketName) {
throw new Error('AWS S3 bucket not configured in AppConfig.ts');
}
try {
const body = Uint8Array.from(new Uint8Array(data));
const params = {
Bucket: AppConfig.s3bucketName,
Key: filename,
Body: body,
ContentType: options?.mediaType
};
await this.options.client.upload(params).promise();
console.log(`File uploaded successfully to ${AppConfig.s3bucketName}/${filename}`);
} catch (error) {
console.error('Error uploading file:', error);
throw error;
}
}
async downloadFile(filePath: string): Promise<Blob> {
const s3 = new S3({
region: AppConfig.region,
accessKeyId: AppConfig.accessKeyId,
secretAccessKey: AppConfig.secretAccessKey
});
const params = {
Bucket: AppConfig.s3bucketName,
Key: filePath
};
try {
const obj = await s3.getObject(params).promise();
if (obj.Body) {
const data = await new Response(obj.Body as ReadableStream).arrayBuffer();
return new Blob([data]);
} else {
throw new Error('Object body is undefined. Could not download file.');
}
} catch (error) {
console.error('Error downloading file:', error);
throw error;
}
}
async deleteFile(uri: string, options?: { filename?: string }): Promise<void> {
if (await this.fileExists(uri)) {
await FileSystem.deleteAsync(uri);
}
const { filename } = options ?? {};
if (!filename) {
return;
}
if (!AppConfig.s3bucketName) {
throw new Error('Supabase bucket not configured in AppConfig.ts');
}
try {
const params = {
Bucket: AppConfig.s3bucketName,
Key: filename
};
await this.options.client.deleteObject(params).promise();
console.log(`${filename} deleted successfully from ${AppConfig.s3bucketName}.`);
} catch (error) {
console.error(`Error deleting ${filename} from ${AppConfig.s3bucketName}:`, error);
}
}
async readFile(
fileURI: string,
options?: { encoding?: FileSystem.EncodingType; mediaType?: string }
): Promise<ArrayBuffer> {
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
const { exists } = await FileSystem.getInfoAsync(fileURI);
if (!exists) {
throw new Error(`File does not exist: ${fileURI}`);
}
const fileContent = await FileSystem.readAsStringAsync(fileURI, options);
if (encoding === FileSystem.EncodingType.Base64) {
return this.base64ToArrayBuffer(fileContent);
}
return this.stringToArrayBuffer(fileContent);
}
async writeFile(
fileURI: string,
base64Data: string,
options?: {
encoding?: FileSystem.EncodingType;
}
): Promise<void> {
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
await FileSystem.writeAsStringAsync(fileURI, base64Data, { encoding });
}
async fileExists(fileURI: string): Promise<boolean> {
const { exists } = await FileSystem.getInfoAsync(fileURI);
return exists;
}
async makeDir(uri: string): Promise<void> {
const { exists } = await FileSystem.getInfoAsync(uri);
if (!exists) {
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
}
async copyFile(sourceUri: string, targetUri: string): Promise<void> {
await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
}
getUserStorageDirectory(): string {
return FileSystem.documentDirectory!;
}
async stringToArrayBuffer(str: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
}
/**
* Converts a base64 string to an ArrayBuffer
*/
async base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
return decodeBase64(base64);
}
}
The AWSStorageAdapter
class implements a storage adapter for AWS S3, allowing file operations (upload, download, delete) with an S3 bucket.
async uploadFile(filename: string, data: ArrayBuffer, options?: { mediaType?: string; }): Promise<void>
Converts the input ArrayBuffer to a Uint8Array for S3 compatibility
- Validates bucket configuration
- Uploads file with metadata (content type)
- Includes error handling and logging
async downloadFile(filePath: string): Promise<Blob>
- Creates a new S3 client instance with configured credentials
- Retrieves object from S3
- Converts the response to a Blob for client-side usage
- Includes error handling for missing files/data
async deleteFile(uri: string, options?: { filename?: string }): Promise<void>
Two-step deletion process:
- Deletes local file if it exists (using Expo’s FileSystem)
- Deletes remote file from S3 if filename is provided
Includes validation and error handling
Update the system.ts
file in the demos/react-native-supabase-todolist/library/config
directory to use the new AWSStorageAdapter
class (the highlighted lines are the only changes needed):
import '@azure/core-asynciterator-polyfill';
import { PowerSyncDatabase } from '@powersync/react-native';
import React from 'react';
import S3 from 'aws-sdk/clients/s3';
import { type AttachmentRecord } from '@powersync/attachments';
import Logger from 'js-logger';
import { KVStorage } from '../storage/KVStorage';
import { AppConfig } from '../supabase/AppConfig';
import { SupabaseConnector } from '../supabase/SupabaseConnector';
import { AppSchema } from './AppSchema';
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
import { AWSStorageAdapter } from '../storage/AWSStorageAdapter';
Logger.useDefaults();
export class System {
kvStorage: KVStorage;
storage: AWSStorageAdapter;
supabaseConnector: SupabaseConnector;
powersync: PowerSyncDatabase;
attachmentQueue: PhotoAttachmentQueue | undefined = undefined;
constructor() {
this.kvStorage = new KVStorage();
this.supabaseConnector = new SupabaseConnector(this);
const s3Client = new S3({
region: AppConfig.region,
credentials: {
accessKeyId: AppConfig.accessKeyId,
secretAccessKey: AppConfig.secretAccessKey
}
});
this.storage = new AWSStorageAdapter({ client: s3Client });
this.powersync = new PowerSyncDatabase({
schema: AppSchema,
database: {
dbFilename: 'sqlite.db'
}
});
/**
* The snippet below uses OP-SQLite as the default database adapter.
* You will have to uninstall `@journeyapps/react-native-quick-sqlite` and
* install both `@powersync/op-sqlite` and `@op-engineering/op-sqlite` to use this.
*
* import { OPSqliteOpenFactory } from '@powersync/op-sqlite'; // Add this import
*
* const factory = new OPSqliteOpenFactory({
* dbFilename: 'sqlite.db'
* });
* this.powersync = new PowerSyncDatabase({ database: factory, schema: AppSchema });
*/
if (AppConfig.s3bucketName) {
this.attachmentQueue = new PhotoAttachmentQueue({
powersync: this.powersync,
storage: this.storage,
// Use this to handle download errors where you can use the attachment
// and/or the exception to decide if you want to retry the download
onDownloadError: async (attachment: AttachmentRecord, exception: any) => {
if (exception.toString() === 'StorageApiError: Object not found') {
return { retry: false };
}
return { retry: true };
}
});
}
}
async init() {
await this.powersync.init();
await this.powersync.connect(this.supabaseConnector);
if (this.attachmentQueue) {
await this.attachmentQueue.init();
}
}
}
export const system = new System();
export const SystemContext = React.createContext(system);
export const useSystem = () => React.useContext(SystemContext);
You can now run the app and test the attachment upload and download functionality.
The complete files used in this tutorial can be found below:
Was this page helpful?