diff --git a/packages/knip/fixtures/plugins/expo/app.config.ts b/packages/knip/fixtures/plugins/expo/app.config.ts
new file mode 100644
index 000000000..5af23da9e
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo/app.config.ts
@@ -0,0 +1,20 @@
+const config = {
+ name: 'Knip',
+ updates: {
+ enabled: true,
+ },
+ notification: {
+ color: '#ffffff',
+ },
+ userInterfaceStyle: 'automatic',
+ ios: {
+ backgroundColor: '#ffffff',
+ },
+ plugins: [
+ ['@config-plugins/detox', { subdomains: '*' }],
+ '@sentry/react-native/expo',
+ ['expo-splash-screen', { backgroundColor: '#ffffff' }],
+ ],
+};
+
+export default config;
diff --git a/packages/knip/fixtures/plugins/expo/node_modules/expo/package.json b/packages/knip/fixtures/plugins/expo/node_modules/expo/package.json
new file mode 100644
index 000000000..e60cb292e
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo/node_modules/expo/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "expo",
+ "bin": "bin/cli",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+}
\ No newline at end of file
diff --git a/packages/knip/fixtures/plugins/expo/package.json b/packages/knip/fixtures/plugins/expo/package.json
new file mode 100644
index 000000000..70074b77d
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@fixtures/expo",
+ "version": "*",
+ "scripts": {
+ "start": "expo start"
+ },
+ "dependencies": {
+ "@config-plugins/detox": "*",
+ "@sentry/react-native": "*",
+ "expo": "*",
+ "expo-atlas": "*",
+ "expo-dev-client": "*",
+ "expo-notifications": "*",
+ "expo-router": "*",
+ "expo-splash-screen": "*",
+ "expo-system-ui": "*",
+ "expo-updates": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+}
diff --git a/packages/knip/fixtures/plugins/expo/src/app/index.ts b/packages/knip/fixtures/plugins/expo/src/app/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/knip/fixtures/plugins/expo2/app.config.js b/packages/knip/fixtures/plugins/expo2/app.config.js
new file mode 100644
index 000000000..0511de685
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo2/app.config.js
@@ -0,0 +1,21 @@
+const config = {
+ name: 'Knip',
+ platforms: ['android'],
+ androidNavigationBar: {
+ visible: true,
+ },
+ android: {
+ userInterfaceStyle: 'dark',
+ },
+ plugins: [
+ 'expo-camera',
+ [
+ 'expo-router',
+ {
+ root: 'src/routes',
+ },
+ ],
+ ],
+};
+
+export default config;
diff --git a/packages/knip/fixtures/plugins/expo2/node_modules/expo/package.json b/packages/knip/fixtures/plugins/expo2/node_modules/expo/package.json
new file mode 100644
index 000000000..e60cb292e
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo2/node_modules/expo/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "expo",
+ "bin": "bin/cli",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+}
\ No newline at end of file
diff --git a/packages/knip/fixtures/plugins/expo2/package.json b/packages/knip/fixtures/plugins/expo2/package.json
new file mode 100644
index 000000000..219399ebe
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo2/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@fixtures/expo2",
+ "version": "*",
+ "main": "expo-router/entry",
+ "scripts": {
+ "start": "expo start"
+ },
+ "dependencies": {
+ "expo": "*",
+ "expo-dev-client": "*",
+ "expo-insights": "*",
+ "expo-navigation-bar": "*",
+ "expo-router": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+}
diff --git a/packages/knip/fixtures/plugins/expo2/src/routes/index.js b/packages/knip/fixtures/plugins/expo2/src/routes/index.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/knip/fixtures/plugins/expo3/app.json b/packages/knip/fixtures/plugins/expo3/app.json
new file mode 100644
index 000000000..12f54f6a9
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo3/app.json
@@ -0,0 +1,11 @@
+{
+ "expo": {
+ "name": "Knip",
+ "slug": "knip",
+ "platforms": ["ios", "web"],
+ "updates": {
+ "enabled": false
+ },
+ "plugins": ["react-native-ble-plx"]
+ }
+}
diff --git a/packages/knip/fixtures/plugins/expo3/app/_layout.ts b/packages/knip/fixtures/plugins/expo3/app/_layout.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/knip/fixtures/plugins/expo3/node_modules/expo/package.json b/packages/knip/fixtures/plugins/expo3/node_modules/expo/package.json
new file mode 100644
index 000000000..e60cb292e
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo3/node_modules/expo/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "expo",
+ "bin": "bin/cli",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+}
\ No newline at end of file
diff --git a/packages/knip/fixtures/plugins/expo3/package.json b/packages/knip/fixtures/plugins/expo3/package.json
new file mode 100644
index 000000000..92fa41196
--- /dev/null
+++ b/packages/knip/fixtures/plugins/expo3/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@fixtures/expo3",
+ "version": "*",
+ "main": "expo-router/entry",
+ "scripts": {
+ "start": "expo start"
+ },
+ "dependencies": {
+ "@expo/metro-runtime": "*",
+ "expo": "*",
+ "expo-system-ui": "*",
+ "expo-updates": "*",
+ "react": "*",
+ "react-dom": "*",
+ "react-native": "*",
+ "react-native-web": "*"
+ }
+}
diff --git a/packages/knip/schema.json b/packages/knip/schema.json
index 572336274..763a62b43 100644
--- a/packages/knip/schema.json
+++ b/packages/knip/schema.json
@@ -344,6 +344,10 @@
"title": "ESLint plugin configuration (https://knip.dev/reference/plugins/eslint)",
"$ref": "#/definitions/plugin"
},
+ "expo": {
+ "title": "Expo plugin configuration (https://knip.dev/reference/plugins/expo)",
+ "$ref": "#/definitions/plugin"
+ },
"gatsby": {
"title": "Gatsby plugin configuration (https://knip.dev/reference/plugins/gatsby)",
"$ref": "#/definitions/plugin"
diff --git a/packages/knip/src/plugins/expo/helpers.ts b/packages/knip/src/plugins/expo/helpers.ts
new file mode 100644
index 000000000..4a4a55584
--- /dev/null
+++ b/packages/knip/src/plugins/expo/helpers.ts
@@ -0,0 +1,78 @@
+import type { PluginOptions } from '../../types/config.js';
+import { type Input, toDependency, toProductionDependency } from '../../util/input.js';
+import { getPackageNameFromModuleSpecifier } from '../../util/modules.js';
+import type { ExpoConfig } from './types.js';
+
+// https://docs.expo.dev/versions/latest/config/app
+
+export const getDependencies = async (expoConfig: ExpoConfig, { manifest }: PluginOptions) => {
+ const config = 'expo' in expoConfig ? expoConfig.expo : expoConfig;
+
+ const platforms = config.platforms ?? ['ios', 'android'];
+
+ const pluginPackages =
+ (config.plugins
+ ?.map(plugin => {
+ const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
+ return getPackageNameFromModuleSpecifier(pluginName);
+ })
+ .filter(Boolean) as string[]) ?? [];
+
+ const inputs = new Set(pluginPackages.map(toDependency));
+
+ const allowedPackages = ['expo-atlas', 'expo-dev-client'];
+ const allowedProductionPackages = ['expo-insights'];
+
+ const manifestDependencies = Object.keys(manifest.dependencies ?? {});
+
+ for (const pkg of allowedPackages) {
+ if (manifestDependencies.includes(pkg)) {
+ inputs.add(toDependency(pkg));
+ }
+ }
+
+ for (const pkg of allowedProductionPackages) {
+ if (manifestDependencies.includes(pkg)) {
+ inputs.add(toProductionDependency(pkg));
+ }
+ }
+
+ if (config.updates?.enabled !== false) {
+ inputs.add(toProductionDependency('expo-updates'));
+ }
+
+ if (config.notification) {
+ inputs.add(toProductionDependency('expo-notifications'));
+ }
+
+ const isExpoRouter = manifest.main === 'expo-router/entry';
+
+ // https://docs.expo.dev/router/installation/#setup-entry-point
+ if (isExpoRouter) {
+ inputs.add(toProductionDependency('expo-router'));
+ }
+
+ // https://docs.expo.dev/workflow/web/#install-web-dependencies
+ if (platforms.includes('web')) {
+ inputs.add(toProductionDependency('react-native-web'));
+ inputs.add(toProductionDependency('react-dom'));
+
+ // https://github.com/expo/expo/tree/main/packages/@expo/metro-runtime
+ if (!isExpoRouter) {
+ inputs.add(toDependency('@expo/metro-runtime'));
+ }
+ }
+
+ if (
+ (platforms.includes('android') && (config.userInterfaceStyle || config.android?.userInterfaceStyle)) ||
+ (platforms.includes('ios') && (config.backgroundColor || config.ios?.backgroundColor))
+ ) {
+ inputs.add(toProductionDependency('expo-system-ui'));
+ }
+
+ if (platforms.includes('android') && config.androidNavigationBar) {
+ inputs.add(toProductionDependency('expo-navigation-bar'));
+ }
+
+ return [...inputs];
+};
diff --git a/packages/knip/src/plugins/expo/index.ts b/packages/knip/src/plugins/expo/index.ts
new file mode 100644
index 000000000..d2673832a
--- /dev/null
+++ b/packages/knip/src/plugins/expo/index.ts
@@ -0,0 +1,52 @@
+import type { IsPluginEnabled, Plugin, ResolveConfig, ResolveEntryPaths } from '../../types/config.js';
+import { toProductionEntry } from '../../util/input.js';
+import { join } from '../../util/path.js';
+import { hasDependency } from '../../util/plugin.js';
+import { getDependencies } from './helpers.js';
+import type { ExpoConfig } from './types.js';
+
+// https://docs.expo.dev/
+
+const title = 'Expo';
+
+const enablers = ['expo'];
+
+const isEnabled: IsPluginEnabled = ({ dependencies }) => hasDependency(dependencies, enablers);
+
+const config: string[] = ['app.json', 'app.config.{ts,js}'];
+
+const resolveEntryPaths: ResolveEntryPaths = async (expoConfig, { manifest }) => {
+ const config = 'expo' in expoConfig ? expoConfig.expo : expoConfig;
+
+ let production: string[] = [];
+
+ // https://docs.expo.dev/router/installation/#setup-entry-point
+ if (manifest.main === 'expo-router/entry') {
+ production = ['app/**/*.{js,jsx,ts,tsx}', 'src/app/**/*.{js,jsx,ts,tsx}'];
+
+ const normalizedPlugins =
+ config.plugins?.map(plugin => (Array.isArray(plugin) ? plugin : ([plugin] as const))) ?? [];
+ const expoRouterPlugin = normalizedPlugins.find(([plugin]) => plugin === 'expo-router');
+
+ if (expoRouterPlugin) {
+ const [, options] = expoRouterPlugin;
+
+ if (typeof options?.root === 'string') {
+ production = [join(options.root, '**/*.{js,jsx,ts,tsx}')];
+ }
+ }
+ }
+
+ return production.map(entry => toProductionEntry(entry));
+};
+
+const resolveConfig: ResolveConfig = async (expoConfig, options) => getDependencies(expoConfig, options);
+
+export default {
+ title,
+ enablers,
+ isEnabled,
+ config,
+ resolveEntryPaths,
+ resolveConfig,
+} satisfies Plugin;
diff --git a/packages/knip/src/plugins/expo/types.ts b/packages/knip/src/plugins/expo/types.ts
new file mode 100644
index 000000000..3f5eb6dfb
--- /dev/null
+++ b/packages/knip/src/plugins/expo/types.ts
@@ -0,0 +1,21 @@
+// https://github.com/expo/expo/blob/main/packages/%40expo/config-types/src/ExpoConfig.ts
+
+type AppConfig = {
+ platforms?: ('ios' | 'android' | 'web')[];
+ notification?: Record;
+ updates?: {
+ enabled?: boolean;
+ };
+ backgroundColor?: string;
+ userInterfaceStyle?: 'automatic' | 'light' | 'dark';
+ ios?: {
+ backgroundColor?: string;
+ };
+ android?: {
+ userInterfaceStyle?: 'automatic' | 'light' | 'dark';
+ };
+ androidNavigationBar?: Record;
+ plugins?: (string | [string, Record])[];
+};
+
+export type ExpoConfig = AppConfig | { expo: AppConfig };
diff --git a/packages/knip/src/plugins/index.ts b/packages/knip/src/plugins/index.ts
index c7b7286fd..98469fc3f 100644
--- a/packages/knip/src/plugins/index.ts
+++ b/packages/knip/src/plugins/index.ts
@@ -15,6 +15,7 @@ import { default as dotenv } from './dotenv/index.js';
import { default as drizzle } from './drizzle/index.js';
import { default as eleventy } from './eleventy/index.js';
import { default as eslint } from './eslint/index.js';
+import { default as expo } from './expo/index.js';
import { default as gatsby } from './gatsby/index.js';
import { default as githubActions } from './github-actions/index.js';
import { default as glob } from './glob/index.js';
@@ -105,6 +106,7 @@ export const Plugins = {
drizzle,
eleventy,
eslint,
+ expo,
gatsby,
'github-actions': githubActions,
glob,
diff --git a/packages/knip/src/schema/plugins.ts b/packages/knip/src/schema/plugins.ts
index d4046edaf..c573c293e 100644
--- a/packages/knip/src/schema/plugins.ts
+++ b/packages/knip/src/schema/plugins.ts
@@ -29,6 +29,7 @@ export const pluginsSchema = z.object({
drizzle: pluginSchema,
eleventy: pluginSchema,
eslint: pluginSchema,
+ expo: pluginSchema,
gatsby: pluginSchema,
'github-actions': pluginSchema,
glob: pluginSchema,
diff --git a/packages/knip/src/types/PluginNames.ts b/packages/knip/src/types/PluginNames.ts
index 1f3a078e3..7d8ec9f3c 100644
--- a/packages/knip/src/types/PluginNames.ts
+++ b/packages/knip/src/types/PluginNames.ts
@@ -16,6 +16,7 @@ export type PluginName =
| 'drizzle'
| 'eleventy'
| 'eslint'
+ | 'expo'
| 'gatsby'
| 'github-actions'
| 'glob'
@@ -106,6 +107,7 @@ export const pluginNames = [
'drizzle',
'eleventy',
'eslint',
+ 'expo',
'gatsby',
'github-actions',
'glob',
diff --git a/packages/knip/test/plugins/expo.test.ts b/packages/knip/test/plugins/expo.test.ts
new file mode 100644
index 000000000..d7084e50a
--- /dev/null
+++ b/packages/knip/test/plugins/expo.test.ts
@@ -0,0 +1,27 @@
+import { test } from 'bun:test';
+import assert from 'node:assert/strict';
+import { main } from '../../src/index.js';
+import { join, resolve } from '../../src/util/path.js';
+import baseArguments from '../helpers/baseArguments.js';
+import baseCounters from '../helpers/baseCounters.js';
+
+const cwd = resolve('fixtures/plugins/expo');
+
+test('Find dependencies with the Expo plugin (1)', async () => {
+ const { issues, counters } = await main({
+ ...baseArguments,
+ cwd,
+ });
+
+ assert(issues.files.has(join(cwd, 'src/app/index.ts')));
+
+ assert(issues.dependencies['package.json']['expo-router']);
+
+ assert.deepEqual(counters, {
+ ...baseCounters,
+ processed: 2,
+ total: 2,
+ files: 1,
+ dependencies: 1,
+ });
+});
diff --git a/packages/knip/test/plugins/expo2.test.ts b/packages/knip/test/plugins/expo2.test.ts
new file mode 100644
index 000000000..0e626fd96
--- /dev/null
+++ b/packages/knip/test/plugins/expo2.test.ts
@@ -0,0 +1,26 @@
+import { test } from 'bun:test';
+import assert from 'node:assert/strict';
+import { main } from '../../src/index.js';
+import { resolve } from '../../src/util/path.js';
+import baseArguments from '../helpers/baseArguments.js';
+import baseCounters from '../helpers/baseCounters.js';
+
+const cwd = resolve('fixtures/plugins/expo2');
+
+test('Find dependencies with the Expo plugin (2)', async () => {
+ const { issues, counters } = await main({
+ ...baseArguments,
+ cwd,
+ });
+
+ assert(issues.unlisted['app.config.js']['expo-camera']);
+ assert(issues.unlisted['app.config.js']['expo-system-ui']);
+ assert(issues.unlisted['app.config.js']['expo-updates']);
+
+ assert.deepEqual(counters, {
+ ...baseCounters,
+ processed: 2,
+ total: 2,
+ unlisted: 3,
+ });
+});
diff --git a/packages/knip/test/plugins/expo3.test.ts b/packages/knip/test/plugins/expo3.test.ts
new file mode 100644
index 000000000..0bef61960
--- /dev/null
+++ b/packages/knip/test/plugins/expo3.test.ts
@@ -0,0 +1,30 @@
+import { test } from 'bun:test';
+import assert from 'node:assert/strict';
+import { main } from '../../src/index.js';
+import { resolve } from '../../src/util/path.js';
+import baseArguments from '../helpers/baseArguments.js';
+import baseCounters from '../helpers/baseCounters.js';
+
+const cwd = resolve('fixtures/plugins/expo3');
+
+test('Find dependencies with the Expo plugin (3)', async () => {
+ const { issues, counters } = await main({
+ ...baseArguments,
+ cwd,
+ });
+
+ assert(issues.unlisted['app.json']['expo-router']);
+ assert(issues.unlisted['app.json']['react-native-ble-plx']);
+
+ assert(issues.dependencies['package.json']['@expo/metro-runtime']);
+ assert(issues.dependencies['package.json']['expo-system-ui']);
+ assert(issues.dependencies['package.json']['expo-updates']);
+
+ assert.deepEqual(counters, {
+ ...baseCounters,
+ processed: 1,
+ total: 1,
+ unlisted: 2,
+ dependencies: 3,
+ });
+});