Types of assets
iOS assets
- Images: PNG, JPEG files in asset catalogs or Resources folder
- Icons: App icon and alternate icons
- Fonts: TTF, OTF font files
- Sounds: Audio files for notifications and UI
- Plists: Configuration files
- Storyboards: XIB/Storyboard files
Android assets
- Drawables: Images in
res/drawable-*folders - Icons: App launcher icons in
res/mipmap-*folders - Fonts: Font files in
assets/fonts/ - Raw resources: Files in
res/raw/ - XML resources: Strings, colors, styles in
res/values/
Adding assets with dangerous mods
UsewithDangerousMod to copy files during prebuild:
import { withDangerousMod, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
const withCustomAsset: ConfigPlugin = (config) => {
return withDangerousMod(config, ['ios', async (config) => {
const { platformProjectRoot } = config.modRequest;
const sourceFile = path.join(config.modRequest.projectRoot, 'assets', 'custom.png');
const targetFile = path.join(platformProjectRoot, 'Images', 'custom.png');
// Ensure target directory exists
await fs.promises.mkdir(path.dirname(targetFile), { recursive: true });
// Copy file
await fs.promises.copyFile(sourceFile, targetFile);
return config;
}]);
};
Managing iOS assets
Adding images to Resources folder
import { withDangerousMod, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
import { glob } from 'glob';
const withiOSImages: ConfigPlugin<{ sourceDir: string }> = (config, { sourceDir }) => {
return withDangerousMod(config, ['ios', async (config) => {
const { platformProjectRoot, projectRoot } = config.modRequest;
const sourcePath = path.join(projectRoot, sourceDir);
const targetPath = path.join(platformProjectRoot, 'Images');
// Ensure target directory exists
await fs.promises.mkdir(targetPath, { recursive: true });
// Find all image files
const images = glob.sync('**/*.{png,jpg,jpeg}', {
cwd: sourcePath,
absolute: false,
});
// Copy each image
for (const image of images) {
const source = path.join(sourcePath, image);
const target = path.join(targetPath, path.basename(image));
await fs.promises.copyFile(source, target);
}
return config;
}]);
};
// Usage
export default withiOSImages(config, {
sourceDir: 'assets/ios-images',
});
Adding fonts to iOS project
import {
withDangerousMod,
withInfoPlist,
withXcodeProject,
ConfigPlugin
} from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
const withCustomFonts: ConfigPlugin<{ fonts: string[] }> = (config, { fonts }) => {
// Copy font files
config = withDangerousMod(config, ['ios', async (config) => {
const { platformProjectRoot, projectRoot } = config.modRequest;
const fontsDir = path.join(platformProjectRoot, 'Fonts');
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const font of fonts) {
const source = path.join(projectRoot, 'assets', 'fonts', font);
const target = path.join(fontsDir, font);
await fs.promises.copyFile(source, target);
}
return config;
}]);
// Add fonts to Info.plist
config = withInfoPlist(config, (config) => {
if (!config.modResults.UIAppFonts) {
config.modResults.UIAppFonts = [];
}
config.modResults.UIAppFonts = [
...new Set([...config.modResults.UIAppFonts, ...fonts]),
];
return config;
});
// Add fonts to Xcode project
config = withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
for (const font of fonts) {
xcodeProject.addResourceFile(
`Fonts/${font}`,
{ target: xcodeProject.getFirstTarget().uuid }
);
}
return config;
});
return config;
};
// Usage
export default withCustomFonts(config, {
fonts: ['CustomFont-Regular.ttf', 'CustomFont-Bold.ttf'],
});
Adding sound files
import { withDangerousMod, withXcodeProject, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
const withSoundFiles: ConfigPlugin<{ sounds: string[] }> = (config, { sounds }) => {
// Copy sound files
config = withDangerousMod(config, ['ios', async (config) => {
const { platformProjectRoot, projectRoot } = config.modRequest;
const soundsDir = path.join(platformProjectRoot, 'Sounds');
await fs.promises.mkdir(soundsDir, { recursive: true });
for (const sound of sounds) {
const source = path.join(projectRoot, 'assets', 'sounds', sound);
const target = path.join(soundsDir, sound);
await fs.promises.copyFile(source, target);
}
return config;
}]);
// Add to Xcode project
config = withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
for (const sound of sounds) {
xcodeProject.addResourceFile(
`Sounds/${sound}`,
{ target: xcodeProject.getFirstTarget().uuid }
);
}
return config;
});
return config;
};
Managing Android assets
Adding images to drawable folders
import { withDangerousMod, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
const withAndroidDrawables: ConfigPlugin<{
images: { [filename: string]: { [density: string]: string } }
}> = (config, { images }) => {
return withDangerousMod(config, ['android', async (config) => {
const { platformProjectRoot, projectRoot } = config.modRequest;
const resDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'res');
for (const [filename, densities] of Object.entries(images)) {
for (const [density, sourcePath] of Object.entries(densities)) {
const drawableDir = path.join(resDir, `drawable-${density}`);
await fs.promises.mkdir(drawableDir, { recursive: true });
const source = path.join(projectRoot, sourcePath);
const target = path.join(drawableDir, filename);
await fs.promises.copyFile(source, target);
}
}
return config;
}]);
};
// Usage
export default withAndroidDrawables(config, {
images: {
'custom_icon.png': {
mdpi: 'assets/android/mdpi/custom_icon.png',
hdpi: 'assets/android/hdpi/custom_icon.png',
xhdpi: 'assets/android/xhdpi/custom_icon.png',
xxhdpi: 'assets/android/xxhdpi/custom_icon.png',
xxxhdpi: 'assets/android/xxxhdpi/custom_icon.png',
},
},
});
Adding fonts to Android
import { withDangerousMod, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
const withAndroidFonts: ConfigPlugin<{ fonts: string[] }> = (config, { fonts }) => {
return withDangerousMod(config, ['android', async (config) => {
const { platformProjectRoot, projectRoot } = config.modRequest;
const fontsDir = path.join(
platformProjectRoot,
'app',
'src',
'main',
'assets',
'fonts'
);
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const font of fonts) {
const source = path.join(projectRoot, 'assets', 'fonts', font);
const target = path.join(fontsDir, font);
await fs.promises.copyFile(source, target);
}
return config;
}]);
};
Adding raw resources
import { withDangerousMod, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
const withRawResources: ConfigPlugin<{ files: string[] }> = (config, { files }) => {
return withDangerousMod(config, ['android', async (config) => {
const { platformProjectRoot, projectRoot } = config.modRequest;
const rawDir = path.join(
platformProjectRoot,
'app',
'src',
'main',
'res',
'raw'
);
await fs.promises.mkdir(rawDir, { recursive: true });
for (const file of files) {
const source = path.join(projectRoot, 'assets', 'raw', file);
const target = path.join(rawDir, file);
await fs.promises.copyFile(source, target);
}
return config;
}]);
};
// Usage
export default withRawResources(config, {
files: ['notification_sound.mp3', 'config.json'],
});
Managing XML resources
Adding string resources
Resources.ts
import { withStringsXml, ConfigPlugin } from '@expo/config-plugins';
import { AndroidConfig } from '@expo/config-plugins';
const withCustomStrings: ConfigPlugin<{
strings: { [key: string]: string }
}> = (config, { strings }) => {
return withStringsXml(config, (config) => {
const stringsXml = config.modResults;
for (const [key, value] of Object.entries(strings)) {
AndroidConfig.Resources.setStringItem(
{ name: key, value },
stringsXml
);
}
return config;
});
};
// Usage
export default withCustomStrings(config, {
strings: {
app_name: 'My App',
welcome_message: 'Welcome to my app',
error_message: 'Something went wrong',
},
});
Adding color resources
Colors.ts
import { withAndroidColors, ConfigPlugin } from '@expo/config-plugins';
import { AndroidConfig } from '@expo/config-plugins';
const withCustomColors: ConfigPlugin<{
colors: { [key: string]: string }
}> = (config, { colors }) => {
return withAndroidColors(config, (config) => {
const colorsXml = config.modResults;
for (const [key, value] of Object.entries(colors)) {
AndroidConfig.Resources.setColorItem(
{ name: key, value },
colorsXml
);
}
return config;
});
};
// Usage
export default withCustomColors(config, {
colors: {
colorPrimary: '#6200EE',
colorAccent: '#03DAC5',
backgroundColor: '#FFFFFF',
},
});
Validating asset filenames
Android has strict naming requirements for resource files.validations.ts
export function isValidAndroidAssetName(name: string): boolean {
// Android resource files must:
// - Only contain lowercase letters, numbers, and underscores
// - Not start with a number
return /^[a-z_][a-z0-9_]*$/.test(name);
}
export function assertValidAndroidAssetName(name: string): void {
if (!isValidAndroidAssetName(name)) {
throw new Error(
`Invalid Android resource name: "${name}". ` +
'Android resource names must contain only lowercase letters, numbers, ' +
'and underscores, and must not start with a number.'
);
}
}
import { assertValidAndroidAssetName } from '@expo/config-plugins';
const withValidatedAssets: ConfigPlugin = (config) => {
return withDangerousMod(config, ['android', async (config) => {
const filename = 'my-custom-icon.png'; // Invalid: contains hyphens
// Convert to valid name
const validName = filename.replace(/[^a-z0-9_]/g, '_');
assertValidAndroidAssetName(validName);
// Now safe to use
return config;
}]);
};
Platform-specific assets
Create a unified plugin that handles both platforms:import { withDangerousMod, withPlugins, ConfigPlugin } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';
type Props = {
ios?: {
images?: string[];
fonts?: string[];
};
android?: {
images?: { [filename: string]: { [density: string]: string } };
fonts?: string[];
};
};
const withPlatformAssets: ConfigPlugin<Props> = (config, { ios, android }) => {
return withPlugins(config, [
// iOS assets
[
withDangerousMod,
[
'ios',
async (config) => {
if (!ios) return config;
const { platformProjectRoot, projectRoot } = config.modRequest;
// Copy iOS images
if (ios.images) {
const imagesDir = path.join(platformProjectRoot, 'Images');
await fs.promises.mkdir(imagesDir, { recursive: true });
for (const image of ios.images) {
const source = path.join(projectRoot, 'assets', 'ios', image);
const target = path.join(imagesDir, image);
await fs.promises.copyFile(source, target);
}
}
// Copy iOS fonts
if (ios.fonts) {
const fontsDir = path.join(platformProjectRoot, 'Fonts');
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const font of ios.fonts) {
const source = path.join(projectRoot, 'assets', 'fonts', font);
const target = path.join(fontsDir, font);
await fs.promises.copyFile(source, target);
}
}
return config;
},
],
],
// Android assets
[
withDangerousMod,
[
'android',
async (config) => {
if (!android) return config;
const { platformProjectRoot, projectRoot } = config.modRequest;
// Copy Android images
if (android.images) {
const resDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'res');
for (const [filename, densities] of Object.entries(android.images)) {
for (const [density, sourcePath] of Object.entries(densities)) {
const drawableDir = path.join(resDir, `drawable-${density}`);
await fs.promises.mkdir(drawableDir, { recursive: true });
const source = path.join(projectRoot, sourcePath);
const target = path.join(drawableDir, filename);
await fs.promises.copyFile(source, target);
}
}
}
// Copy Android fonts
if (android.fonts) {
const fontsDir = path.join(
platformProjectRoot,
'app',
'src',
'main',
'assets',
'fonts'
);
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const font of android.fonts) {
const source = path.join(projectRoot, 'assets', 'fonts', font);
const target = path.join(fontsDir, font);
await fs.promises.copyFile(source, target);
}
}
return config;
},
],
],
]);
};
export default withPlatformAssets;
app.json
{
"expo": {
"plugins": [
[
"./plugins/withPlatformAssets",
{
"ios": {
"images": ["custom_image.png"],
"fonts": ["CustomFont.ttf"]
},
"android": {
"images": {
"custom_image.png": {
"mdpi": "assets/android/mdpi/custom_image.png",
"hdpi": "assets/android/hdpi/custom_image.png"
}
},
"fonts": ["CustomFont.ttf"]
}
}
]
]
}
}
Best practices
1. Organize assets in source directory
project/
assets/
ios/
images/
sounds/
android/
mdpi/
hdpi/
xhdpi/
xxhdpi/
xxxhdpi/
fonts/
CustomFont-Regular.ttf
CustomFont-Bold.ttf
2. Validate filenames before copying
import { assertValidAndroidAssetName } from '@expo/config-plugins';
for (const filename of androidAssets) {
const nameWithoutExt = filename.replace(/\.[^.]+$/, '');
assertValidAndroidAssetName(nameWithoutExt);
}
3. Check file existence
import fs from 'fs';
if (!fs.existsSync(sourcePath)) {
throw new Error(`Asset file not found: ${sourcePath}`);
}
4. Use glob for bulk operations
import { glob } from 'glob';
const images = glob.sync('**/*.{png,jpg,jpeg}', {
cwd: assetsDir,
});
5. Make plugins idempotent
Copy operations are idempotent by default (overwriting existing files), but be careful with append operations.6. Document required asset structure
Include a README explaining the expected asset organization:## Assets Structure
Place your assets in the following structure:
- `assets/fonts/` - Font files (used by both platforms)
- `assets/ios/` - iOS-specific images
- `assets/android/mdpi/` - Android MDPI density images
- `assets/android/hdpi/` - Android HDPI density images
...
Debugging asset issues
Add logging to verify assets are copied:const withDebugAssets: ConfigPlugin = (config) => {
return withDangerousMod(config, ['ios', async (config) => {
const { platformProjectRoot } = config.modRequest;
console.log('Copying assets to:', platformProjectRoot);
// Copy operations...
console.log('Assets copied successfully');
return config;
}]);
};
EXPO_DEBUG=1 npx expo prebuild
Next steps
- Modifying native projects - More native modifications
- Testing plugins - Test asset copying
- Build Properties - Configure build settings