Expo File System Next: File and Directory Classes for React Native (2026)

Expo File System Next in SDK 54 replaces the legacy async URI API with File, Directory, and Paths classes. Here is the full setup, sync vs async guide, and legacy migration playbook.

Expo File System Next Guide (2026)

Updated: August 7, 2026

The Expo File System Next API (imported from expo-file-system in SDK 54+) is an object-oriented rewrite that replaces the legacy async-only module with two ergonomic classes, File and Directory, plus a typed Paths constants module and both synchronous and asynchronous methods. If you built anything on the old FileSystem.readAsStringAsync or documentDirectory API, this article is the practical upgrade path. I'll cover installation, the new classes, streaming downloads, the Paths module, sync-vs-async trade-offs, and the exact migration playbook I ran across a fintech monorepo in Q2 2026.

  • The new API lives under the top-level expo-file-system import as of SDK 54; the legacy string-URI functions moved to expo-file-system/legacy.
  • File and Directory instances are cheap wrappers around a URI. Construct them from Paths constants, then call read(), write(), create(), delete(), or list().
  • Synchronous methods (textSync(), bytesSync()) let you skip a microtask when the file is small. Real wins in Reanimated worklets and Hermes startup paths.
  • File.downloadFileAsync() handles resumable downloads with progress callbacks and does not buffer the payload into JS memory.
  • The Paths namespace exposes Paths.document, Paths.cache, Paths.appleSharedContainers, and app-group helpers that used to require raw string manipulation.
  • Migrating is mechanical: replace readAsStringAsync(uri) with new File(uri).text() and delete your homegrown path-join utilities.

What is Expo File System Next?

Expo File System Next is the rewritten filesystem module that shipped as an opt-in preview in SDK 52 and became the default export of expo-file-system in SDK 54. The team's stated goal was to replace a decade of accumulated string-URI helpers with a small object-oriented surface that mirrors the Web File and FileSystem APIs while still exposing platform-specific features like iOS shared app groups and Android's Storage Access Framework. You can cross-check any behaviour in the official expo-file-system reference, which now leads with the Next API.

Honestly, the old module got out of hand. It exposed dozens of top-level functions (readAsStringAsync, writeAsStringAsync, getInfoAsync, makeDirectoryAsync, copyAsync, moveAsync, readDirectoryAsync, and so on), each taking a URI string as its first argument. The new module collapses all of that into methods on File and Directory instances. You construct an instance from a base directory (usually a Paths constant) plus zero or more path segments, and every operation lives as a method on that instance. The result is fewer imports, less string concatenation, and TypeScript autocomplete that actually tells you which operations are legal on a given URI.

Under the hood the module is still a JSI-backed native module, so the sync methods truly are synchronous. They block the JS thread the same way MMKV.getString() does. That's the feature that unlocks new usage patterns like reading small config files directly from a Reanimated worklet or during a Hermes cold-start critical path.

Install and set up expo-file-system

If you're on Expo SDK 54 or later, expo-file-system is already listed as a template dependency; the Next API is the default export. On an existing project run:

npx expo install expo-file-system

# then rebuild the dev client
npx expo prebuild --clean
npx expo run:ios
npx expo run:android

You don't need a config plugin for the base module. If you plan to use iOS shared app groups (for example, to share a downloaded PDF with a share extension or a widget), add the app group to your app.json so the Expo module can resolve Paths.appleSharedContainers:

{
  "expo": {
    "ios": {
      "entitlements": {
        "com.apple.security.application-groups": ["group.com.example.myapp"]
      }
    }
  }
}

Then in TypeScript, import the pieces you actually need. Tree-shaking is real here. Importing only File keeps Directory and the download helper out of the bundle graph if you never reference them:

import { File, Directory, Paths } from 'expo-file-system';

Everything below assumes those three imports. If you're still on SDK 53, install the preview under its explicit sub-path (import { File } from 'expo-file-system/next'). The ergonomics are identical, but you should plan the upgrade to SDK 54 to drop the sub-path import.

The Paths module: document, cache, and shared containers

The Paths namespace is where the ergonomic wins start. Instead of remembering that FileSystem.documentDirectory ends with a slash and FileSystem.cacheDirectory is a different string on iOS vs Android, you get typed constants that are Directory instances themselves:

Paths.document     // Directory, persists across launches, backed up to iCloud
Paths.cache        // Directory, safe to delete, iOS may purge under memory pressure
Paths.bundle       // Directory, read-only, ships inside your app IPA/APK
Paths.appleSharedContainers  // Record<string, Directory>, keyed by app group id

Because each constant is already a Directory, you compose paths by passing it to a File or nested Directory constructor with additional segments. The module normalises separators, handles URL encoding, and eliminates the "did I forget the trailing slash" class of bug:

// old: FileSystem.documentDirectory + 'users/' + userId + '/profile.json'
const profile = new File(Paths.document, 'users', userId, 'profile.json');
console.log(profile.uri);
// -> file:///.../Documents/users/42/profile.json

For shared iOS containers you look up the directory by app group id, then compose from there. This is perfect for a widget that reads the same JSON blob the main app writes:

const shared = Paths.appleSharedContainers['group.com.example.myapp'];
const widgetData = new File(shared, 'widget-state.json');
widgetData.write(JSON.stringify({ balance: 1234.56 }));

Working with the File class

A File instance is a lazy handle. Constructing one doesn't touch the disk. The disk work happens when you call one of its methods. The core surface is small enough to memorise:

const notes = new File(Paths.document, 'notes.txt');

notes.create();              // create empty; throws if it exists
notes.write('Hello world');  // string or Uint8Array
const body = notes.text();   // read as UTF-8 string (async: notes.text())
const bytes = notes.bytes(); // read as Uint8Array
notes.exists;                // boolean, no I/O beyond a stat
notes.size;                  // bytes on disk
notes.md5;                   // computed MD5, cached per instance
notes.delete();              // remove
notes.copy(destination);     // destination is a File or Directory
notes.move(destination);

The most common bug I see in code review is calling write() on a File whose parent directory doesn't exist yet. I hit this exact bug shipping the first PR that landed the new API. Unlike the legacy writeAsStringAsync, the new API does not silently create intermediate directories. Wrap writes with a small helper or call Directory.create({ intermediates: true }) first. The trade-off is intentional and matches Node.js's fs.writeFile semantics, which is what most engineers coming from a backend background already expect.

Options are passed as a second argument. To append instead of overwrite, or to write raw bytes with a specific encoding hint, use the options bag:

notes.write('another line\n', { encoding: 'utf8', append: true });

// Base64 payloads decoded to bytes on the native side
const png = new File(Paths.cache, 'avatar.png');
png.write(base64String, { encoding: 'base64' });

Working with the Directory class

Directories mirror the File API for the operations that make sense on a folder. The one that matters most on Android is create({ intermediates: true }), which fills in missing parents the way mkdir -p does on the CLI:

const userDir = new Directory(Paths.document, 'users', userId);
userDir.create({ intermediates: true });  // idempotent when true

const files = userDir.list();  // returns Array<File | Directory>
for (const entry of files) {
  if (entry instanceof File) {
    console.log(entry.uri, entry.size);
  }
}

userDir.delete();  // recursive by default

The list() method returns a mixed array of File and Directory instances, which is where the type discrimination pays off. Old code that did readDirectoryAsync got back an array of bare filenames and had to call getInfoAsync in a loop to figure out which were folders. That whole round-trip is gone.

For downloads and cache-like directories you often want a "wipe everything" primitive. The idiomatic way is to delete the directory and recreate it. The native side is fast enough that this is cheaper than iterating:

function resetCache() {
  const cacheDir = new Directory(Paths.cache, 'downloads');
  if (cacheDir.exists) cacheDir.delete();
  cacheDir.create();
}

Sync vs async: when to use each

Every mutation method has both a promise-returning form (write(), create(), delete()) and a suffix-Sync form. For reads the pattern is inverted: text()/bytes() are async, and textSync()/bytesSync() block. The naming is deliberate and matches Node's fs / fs/promises split.

The two situations where sync methods have earned their keep in the code I've shipped:

  • Cold-start config: reading a small JSON feature-flag file during app boot before the React tree mounts. An async read forces a needless await that widens the TTI window by 20 to 60 ms on Android. See our React Native startup time deep-dive for the trace.
  • Worklet-side lookups: Reanimated worklets can't await promises. A textSync() call in a runOnJS-free codepath lets a gesture handler cross-reference a small on-disk cache without hopping threads.

For anything larger (images, video, PDF payloads), use the async form. The native module implements them off-thread, so awaiting is genuinely non-blocking and future SDK optimisations (chunked reads, IO_URING on Android 15) will be async-only.

Downloads, streaming, and progress

The killer feature the legacy API lacked was a first-class download primitive that did not buffer into JS memory. File.downloadFileAsync(), a static method on the class, streams the response body directly to the destination file, exposing a progress callback and an AbortSignal for cancellation:

import { File, Paths } from 'expo-file-system';

async function downloadReport(url: string) {
  const destination = new File(Paths.cache, 'report.pdf');

  const controller = new AbortController();
  const output = await File.downloadFileAsync(url, destination, {
    signal: controller.signal,
    headers: { Authorization: `Bearer ${token}` },
    onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
      const pct = totalBytesWritten / totalBytesExpectedToWrite;
      console.log(`downloaded ${(pct * 100).toFixed(1)}%`);
    },
  });

  return output; // the same File instance, ready to open
}

Progress events fire on the JS thread but are throttled by the native side to roughly one per 16 ms, so wiring them into a Reanimated shared value drives smooth progress bars without a manual throttle. Compare that to the old createDownloadResumable API, which required you to manage a resumable-data blob in JS and re-instantiate the download on retry.

For chunked reads of a large local file (say, hashing a 500 MB video before an upload), the new API exposes a readableStream() that returns a standard Web ReadableStream. That means you can pipe it into any Web-Crypto or fetch consumer without an extra buffer:

const video = new File(Paths.document, 'clip.mp4');
const stream = video.readableStream();

// pipe straight into a fetch upload
await fetch('https://api.example.com/upload', {
  method: 'POST',
  body: stream,
  duplex: 'half',
});

How do I migrate from the legacy expo-file-system API?

The migration is mostly mechanical. In SDK 54, the legacy module is still shipped under an explicit sub-path so you can migrate incrementally rather than in one big-bang PR:

// old
import * as FileSystem from 'expo-file-system';
const body = await FileSystem.readAsStringAsync(FileSystem.documentDirectory + 'a.txt');

// legacy shim during migration
import * as FileSystem from 'expo-file-system/legacy';

// new
import { File, Paths } from 'expo-file-system';
const body = await new File(Paths.document, 'a.txt').text();

My playbook for a monorepo migration:

  1. Codemod all from 'expo-file-system' imports to from 'expo-file-system/legacy' in a single PR. Nothing behaves differently, this just names the old API as legacy.
  2. Add an ESLint rule (no-restricted-imports) forbidding new imports from the legacy path. Every subsequent PR must use the new API.
  3. Migrate one leaf module at a time, deleting the legacy import when the file's last usage is converted.
  4. When zero legacy imports remain, the codemod runs a final pass to strip the /legacy suffix. Everything now points at the new module.

Android Storage Access Framework and shared files

The one platform gap the legacy API had was Android's Storage Access Framework (SAF), the modern way to read and write files that live outside your app sandbox. On Android 11+ apps can't read /sdcard paths directly; they must ask the system for a content:// URI via the SAF document picker. The Next API accepts SAF URIs in the File constructor:

import * as DocumentPicker from 'expo-document-picker';
import { File } from 'expo-file-system';

async function importCsv() {
  const result = await DocumentPicker.getDocumentAsync({ type: 'text/csv' });
  if (result.canceled) return;

  const picked = new File(result.assets[0].uri); // content:// URI
  const csv = await picked.text();
  return parseCsv(csv);
}

The module transparently routes content:// URIs through ContentResolver on the native side, so the same text() and bytes() methods work regardless of whether the file is in your sandbox or on shared storage. That's a genuine simplification. The old API required StorageAccessFramework.readAsStringAsync as a parallel entry point. For write access to a user-picked folder you still call the SAF-specific helpers, but reads and writes to a picked file are now unified. See the Android SAF documentation for the underlying permission model.

Common patterns: JSON cache, image download, share sheet

Typed JSON cache

The most common use of a filesystem module in a fintech app is a small typed cache (feature flags, a user's last-known state, a queued action to retry after a failed request). The new API pairs beautifully with a Zod schema:

import { z } from 'zod';
import { File, Paths } from 'expo-file-system';

const FeatureFlags = z.object({
  newOnboarding: z.boolean(),
  maxTransferAmount: z.number(),
});
type FeatureFlags = z.infer<typeof FeatureFlags>;

const cache = new File(Paths.cache, 'flags.json');

export function readFlags(): FeatureFlags | null {
  if (!cache.exists) return null;
  try {
    return FeatureFlags.parse(JSON.parse(cache.textSync()));
  } catch {
    cache.delete();
    return null;
  }
}

export function writeFlags(flags: FeatureFlags) {
  cache.write(JSON.stringify(flags));
}

Download and cache an image, then hand off to expo-image

For images that are too large to cache in the standard expo-image memory cache, download to disk and pass the resulting URI. Pair this with the strategies from our expo-image caching guide:

async function cachedRemoteImage(remoteUrl: string) {
  const filename = remoteUrl.split('/').pop()!;
  const local = new File(Paths.cache, 'images', filename);
  if (local.exists) return local.uri;

  new Directory(Paths.cache, 'images').create({ intermediates: true });
  await File.downloadFileAsync(remoteUrl, local);
  return local.uri;
}

Save a PDF and open the share sheet

Combine expo-file-system with expo-sharing to save a generated PDF and offer it to the user:

import * as Sharing from 'expo-sharing';

async function exportStatement(pdfBase64: string) {
  const file = new File(Paths.document, `statement-${Date.now()}.pdf`);
  file.write(pdfBase64, { encoding: 'base64' });

  if (await Sharing.isAvailableAsync()) {
    await Sharing.shareAsync(file.uri, { mimeType: 'application/pdf' });
  }
}

Frequently Asked Questions

Is expo-file-system Next synchronous?

It exposes both. Every read and write method has an async form (returning a Promise) and a *Sync form that blocks the JS thread. Use the sync form only for small files during cold start or inside Reanimated worklets. For anything over ~64 KB the async form is the right default because it runs off-thread on the native side.

What is the difference between Paths.document and Paths.cache in Expo?

Paths.document is persistent user-generated content. It survives app updates, is backed up to iCloud on iOS, and should hold data the user would consider "theirs". Paths.cache is disposable. The OS may reclaim it under memory pressure and it is not backed up. Rule of thumb: if losing the file would break a feature, use Paths.document; if losing it just triggers a re-download, use Paths.cache.

How do I download a file with Expo File System?

Use the static File.downloadFileAsync(url, destination, options) method. It streams the response body directly to disk without buffering into JS memory, and accepts an AbortSignal for cancellation plus an onProgress callback that fires roughly every 16 ms on the JS thread. It replaces the legacy createDownloadResumable API.

Can I use expo-file-system on the web?

Partially. The Next API implements a subset on Web using the browser's Cache and OPFS (Origin Private File System) primitives, enough for storing JSON blobs and downloaded assets, but iOS-only concepts like Paths.appleSharedContainers throw at runtime. For code that must run on React Native Web, guard those calls with Platform.OS !== 'web'.

Does the new API require the New Architecture?

No. The module ships as a Turbo Module when the New Architecture is enabled and falls back to a bridge module when it isn't, so the same JS surface works on both. That said, the sync methods are meaningfully faster under the New Architecture because JSI eliminates the cross-thread serialisation the old bridge required.

Yelena Petrov
About the Author Yelena Petrov

React Native architect at a fintech. Builds platform teams, type-safe bridges, and runs the upgrade playbook so others don't have to.