Migrating an application platform to pure ECMAScript Modules (ESM) looks great on paper. Modern `import/export` syntax, cleaner tree-shaking, and alignment with Node standards are incredibly compelling reasons to make the switch.
However, when you bring pure ESM (`"type": "module"`) into the native mobile landscape with Expo 54, React 19, and Jest, you quickly run into a wall of configuration issues. Native mobile architectures rely heavily on custom asset resolution pipelines and deep native bridges that expect a CommonJS architecture during unit testing.
The good news? You don't have to give up modern syntax to avoid configuration headaches. This guide outlines the ultimate middle-ground blueprint for your next greenfield Expo React Native project: Write clean ESM code, but run your test runner in standard CommonJS mode.
The Architecture Stack
Our production-grade stack focuses on stability, rapid testing feedback loops, and modern UI development:
- Core Framework: Expo 54 & React Native 0.81+ (Leveraging React 19 primitives)
- Language & Linter: TypeScript & ESLint (Flat Config setup)
- UI Foundation: React Native Paper V3 (Material Design component library)
- Testing Suite: Jest & React Native Testing Library (RNTL)
1. The Dependencies Blueprint (`package.json`)
Notice that we explicitly omit "type": "module". This tells Node to treat the execution layer as standard CommonJS, letting Babel cleanly compile code behind the scenes while you write modern import statements in your code editor.
```
```
{
"private": true,
"name": "greenfield-expo-app",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-paper": "^5.15.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
},
"devDependencies": {
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "29.5.14",
"@types/react": "~19.1.0",
"babel-jest": "^30.4.1",
"jest": "~29.7.0",
"jest-expo": "~54.0.17",
"typescript": "~5.9.2"
}
}
```
```
2. Eliminating the Asset Parsing Bug (`jest.config.js`)
When testing components that map static local assets (like require('./assets/image.jpg')), Jest often tries to parse raw image binaries as JavaScript code, throwing a SyntaxError: Unexpected token.
To solve this completely, create a tiny transformer file named jest-binary-transformer.js:
```
// jest-binary-transformer.js
module.exports = {
process() {
return { code: 'module.exports = 1;' };
},
};
```
Now, hook this transformer into your centralized `jest.config.js` file. By pulling directly from the `jest-expo/android` preset, you ensure that native core requirements like `PlatformConstants` are automatically injected into your tests:
```
// jest.config.js
const androidPreset = require('jest-expo/android/jest-preset.js');
module.exports = {
...androidPreset,
setupFiles: ['<rootDir>/jest-setup.js'],
injectGlobals: true,
transform: {
// 1. Compile source typescript and imports with babel
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
// 2. Intercept asset binaries and turn them into safe mock styles instantly
'\\.(jpg|jpeg|png|gif|webp|svg|ttf|mp4|mp3)$': '<rootDir>/jest-binary-transformer.js',
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|react-native-paper|react-native-reanimated|expo-modules-core)',
],
setupFilesAfterEnv: [
'@testing-library/jest-native/extend-expect'
],
};
```
```
3. Safe Platform Global Initializations (`jest-setup.js`)
Keep your application global variables isolated from native dependency lifecycles. This baseline setup file establishes necessary properties for modern React Native component trees without breaking layout engines:
```
// jest-setup.js
globalThis.__DEV__ = true;
// Pre-seed core batch bridges to enable fluid layout hooks
globalThis.__fbBatchedBridgeConfig = {
remoteModuleConfig: [],
localModulesConfig: []
};
// Mock fallback definitions for basic expo system constraints
jest.mock('expo-constants', () => ({
manifest: {},
expoConfig: { name: 'greenfield-app', slug: 'greenfield-app' },
Constants: { manifest: {} },
}));
```
4. Writing Clean Lint Rules with Flat Configs (`eslint.config.cjs`)
Because the project root handles file operations via CommonJS, write your ESLint configuration using the `.cjs` extension. However, set your sourceType parser option to 'module'. This ensures that your linter fully approves of modern ESM import statements throughout your development code:
```
// eslint.config.cjs
const js = require('@eslint/js');
const tsPlugin = require('@typescript-eslint/eslint-plugin');
const tsParser = require('@typescript-eslint/parser');
const react = require('eslint-plugin-react');
const reactNative = require('eslint-plugin-react-native');
const globals = require('globals');
module.exports = [
{
files: ['**/*.ts', **/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module', // Approved for modern import syntax!
},
globals: {
...globals.node,
jest: true,
describe: true,
test: true,
expect: true,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
react,
'react-native': reactNative,
},
rules: {
...js.configs.recommended.rules,
...tsPlugin.configs.recommended.rules,
...react.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'no-console': 'warn',
},
},
];
```
Summary Takeaway for Greenfield Teams
When kickstarting a new React Native project on Expo 54, do not let strict runtime ESM requirements slow you down. By treating ESM as a syntax standard for your source files rather than an execution rule for your test runner, you get the best of both worlds. You can write modern, future-proof code while enjoying fast, reliable test execution without the configuration headaches.