Back to Blog

Invalid Date14 min read

Setting up React Native with Expo SDK 56, NativeWind v4, and EAS Dev Builds

A practical walkthrough of scaffolding a new React Native app on the bleeding edge — Expo SDK 56, NativeWind for Tailwind-style styling, and an EAS development build so it runs on real Android devices. Written for someone new to the React Native ecosystem but comfortable with web tooling.

I went through this myself recently and hit enough subtle gotchas that I wanted to write the steps down. This is the version I wish I had had.


Why this stack

  • Expo (managed workflow) — handles native build complexity, ships with expo-router (file-based routing like Next.js), and gives you EAS for production builds. The Bare workflow is more flexible but slower to start and steeper to learn.
  • NativeWind v4 — Tailwind for React Native. Lets you write className="flex-1 bg-white" on RN components. The class strings get compiled to RN StyleSheet objects at build time, so there's no runtime CSS cost.
  • EAS Build (dev profile) — Expo's cloud build service. Produces a custom dev-client APK so you can run your app on a real device with fast refresh, even when you're using native modules Expo Go doesn't bundle.

The major version pinning to know:

  • NativeWind v4 requires Tailwind v3.4.x (not v4 — incompatible).
  • Expo SDK 56 ships React 19.2 and React Native 0.85, which means most online tutorials lag behind.

Prerequisites

  • Node.js 22.13+ (Expo SDK 56 requirement)
  • npm or another package manager
  • A terminal you're comfortable with
  • For real-device testing later: an Android device and a free Expo account

Part 1 — Scaffold the Expo project

Create a new project in the current directory (the . argument means "use this folder as the project root"):

npx create-expo-app@latest .

Pick the Default template when prompted. It includes Expo Router, TypeScript, and a sample two-tab layout.

What you'll get on SDK 56:

  • A src/ directory containing app/, components/, constants/, hooks/, and global.css
  • TypeScript path alias: @/* resolves to ./src/*
  • app.json with reactCompiler: true experiment enabled
  • No babel.config.js or metro.config.js (Expo uses defaults)

Heads up — template layout varies between SDKs. Older SDK templates (54 and earlier) use a flat app/ directory at the project root, not src/app/. If you follow tutorials for those, every config path needs adjustment.

Verify it runs

npx expo start

Press w to open in your browser. You should see the default "Welcome to Expo" screen. Web is your fastest iteration target during development — it doesn't replace device testing, but most UI work can be validated there.


Part 2 — Wire up NativeWind v4

Six files need to be created or modified. Each has a specific role in the pipeline:

FileRuns whenJob
tailwind.config.jsBuild timeTells Tailwind which files to scan for classes
src/global.cssBuild timeTailwind entry point with @tailwind directives
babel.config.jsPer-file compileTransforms JSX so className works on RN components
metro.config.jsBundle timeHooks NativeWind into the Metro bundler
src/app/_layout.tsxApp startupSide-effect imports the CSS once
nativewind-env.d.tsTypeScript onlyAdds className types + *.css module declaration

Step 1 — install dependencies

npm install nativewind
npm install --save-dev tailwindcss@^3.4.17 prettier-plugin-tailwindcss@^0.5.11
  • nativewind is the runtime
  • tailwindcss is pinned to v3 because NativeWind v4 doesn't support Tailwind v4
  • prettier-plugin-tailwindcss auto-sorts class names (optional but lifesaving in code review)

On SDK 56 you'll see peer-dependency warnings about React 19.2 / RN 0.85. Those are expected on the bleeding edge — not blockers.

Step 2 — create tailwind.config.js at the project root

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  presets: [require('nativewind/preset')],
  theme: {
    extend: {},
  },
  plugins: [],
};

The content glob is critical. Tailwind is a tree-shaking compiler — it only generates CSS for classes it sees in your source. If the path is wrong, your classes silently produce no styles. This is the #1 NativeWind beginner trap. The default templates show ./App.tsx but newer Expo templates use src/, so adjust accordingly.

Step 3 — update src/global.css

Add the three Tailwind directives at the top:

@tailwind base;
@tailwind components;
@tailwind utilities;

(Keep any existing :root variables below them — they don't conflict.)

VS Code may complain about "Unknown at rule @tailwind" — that's the default CSS language server. Install the "Tailwind CSS IntelliSense" extension to fix and gain class autocomplete.

Step 4 — create babel.config.js at the project root

module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
      'nativewind/babel',
    ],
  };
};

The critical line is jsxImportSource: 'nativewind'. It tells the JSX compiler to use NativeWind's runtime for <View>, <Text>, etc., so className props get translated to RN style objects at render time.

Note: any time you edit babel.config.js, restart Metro with npx expo start --clear to flush the cache.

Step 5 — create metro.config.js at the project root

const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');

const config = getDefaultConfig(__dirname);

module.exports = withNativeWind(config, { input: './src/global.css' });

Watch this path. The NativeWind docs show './global.css'. If your project uses the src/ layout (the SDK 56 default), the correct path is './src/global.css'. Wrong path = className silently does nothing.

Step 6 — import global.css from the root layout

Open src/app/_layout.tsx and add this as the first line:

import '../global.css';

This makes the compiled styles part of your app bundle. Without this import, NativeWind's runtime has nothing to resolve className against.

In a non-Expo-Router project this would go in App.tsx. With Expo Router, the equivalent is the root _layout.tsx (rendered once at startup, wraps every route).

Step 7 — create nativewind-env.d.ts at the project root

This is where the SDK 56 setup gets non-obvious. The NativeWind docs tell you to write:

/// <reference types="nativewind/types" />

This does not work on SDK 56 with the latest TypeScript. Two reasons:

  1. The reference resolution is broken. TypeScript's reference types directive looks for a directory matching the package name. NativeWind ships its types as a sibling file (node_modules/nativewind/types.d.ts), not in a subdirectory. TS won't find it.
  2. The SDK 56 scaffold omits expo-env.d.ts. Without that file, the declare module '*.css' global declaration is missing, so importing global.css produces a TypeScript error: "Cannot find module or type declarations for side-effect import of '../global.css'".

The fix is a three-line file that handles both:

/// <reference path="./node_modules/react-native-css-interop/types.d.ts" />

declare module '*.css';
  • The reference path directive points directly at the file containing the actual className type augmentations (skipping NativeWind's broken indirection)
  • The inline *.css declaration tells TS that CSS imports are side-effect imports with no exports

After saving, restart the TS server in your IDE (VS Code: Cmd+Shift+P → "TypeScript: Restart TS Server"). The red squiggle on the CSS import should clear.

Verify NativeWind works

Edit src/app/index.tsx. Import Text from react-native if it isn't already, then add a className to any visible text:

import { Text } from 'react-native';

<Text className="text-red-500">Welcome to Expo</Text>

Run with a clean cache:

npx expo start --clear

Press w for the browser. The text should render in red. If it doesn't, the most likely causes are:

  • Wrong content path in tailwind.config.js
  • Wrong input path in metro.config.js
  • Didn't restart Metro with --clear
  • The component you applied className to doesn't forward it to a primitive RN component (custom wrappers often need cssInterop registration)

Part 3 — Set up EAS Build for Android dev

Up to this point you've been testing in the browser. To run on a real Android device, you need a development build — your own custom version of Expo Go with all your project's native modules included.

You need this even if you have an Android device handy, because Expo Go on the Play Store lags behind the latest SDK. As of writing, Expo Go supports up to SDK 54; SDK 56 projects can't load in it at all.

A development build is built once per native-module change (so typically very few times during a project's lifetime), and from then on the daily workflow is identical to Expo Go: run Metro, scan QR, live reload.

Cost note for iOS

The steps below cover Android only. iOS dev builds via EAS require a paid Apple Developer Program account ($99/year). Defer that until you actually need iOS testing — Android is free and sufficient for most early development.

Step 1 — install expo-dev-client

npx expo install expo-dev-client

Always use npx expo install (not npm install) for expo-* packages. The wrapper picks the version that matches your Expo SDK; plain npm installs the absolute latest, which may be ahead of what your SDK expects.

This package is what makes the resulting APK a dev client (Metro connection, dev menu, fast refresh) instead of a release-style build.

Step 2 — create a free Expo account

Go to https://expo.dev/signup and create an account. Pick the username carefully — it becomes part of your project URLs and isn't easily changed later.

Step 3 — install eas-cli and log in

npm install -g eas-cli
eas login
eas whoami    # verify

eas login prompts for your Expo email/username and password.

You'll likely see a DEP0040 punycode is deprecated warning on Node 22+. It's a dependency inside eas-cli, not your code — ignore it.

Step 4 — link the project to your Expo account

eas init

This creates an entry for your project on the Expo dashboard and writes a projectId UUID into your app.json under extra.eas.projectId. It also adds "owner": "<your-username>" and an Android package field.

The default package is com.anonymous.<name> — change it immediately. The Android package name is permanent on Play Store, and installed dev builds with different package names don't replace each other on your phone (you'd end up with duplicates).

Use a reverse-domain format you control or could plausibly own:

"android": {
  ...
  "package": "com.yourdomain.yourapp"
}

Step 5 — create eas.json

eas build:configure -p android

This generates eas.json with three build profiles: development, preview, and production. Open it and edit the development profile to explicitly produce an APK (not an Android App Bundle, which can't be sideloaded):

{
  "cli": {
    "version": ">= 20.3.0",
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "android": {
        "buildType": "apk"
      }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {}
  }
}

The key fields:

  • developmentClient: true — makes the APK a dev client (Metro-connecting). Without this, you'd build a release-style app.
  • distribution: "internal" — produces a sideloadable APK with a public download URL, not a Play Store submission.
  • android.buildType: "apk" — explicit APK output, not AAB.

Leave production as .aab — that's what the Play Store requires.

Step 6 — run the first build

eas build --profile development --platform android

The CLI walks through several phases:

Credentials prompt — first build only. It asks: "Generate a new Android Keystore?" Answer YES. The keystore is a cryptographic file Android uses to sign your app. EAS manages it on their servers (encrypted, backed up). It's permanent — if you lose it for a published app, you can't update that app on Play Store ever again. EAS-managed is the safe default. You can later download a backup with eas credentials.

Upload phase — 10–60 seconds. Tar's up the project (minus node_modules) and sends it to Expo's cloud.

Queue + build — 5–15 minutes depending on the queue. You'll get a URL like https://expo.dev/accounts/<you>/projects/<app>/builds/<uuid> where you can watch live logs. You don't need to keep the terminal open; EAS emails when done.

When it finishes, the CLI prints a download URL and a QR code for installing on Android.

Step 7 — install on your Android device

Scan the QR code with your phone's Camera app (not Expo Go — Expo Go is irrelevant from here on). The Camera detects the URL, opens it in your default browser, which downloads the APK.

When the download finishes, Android shows: "For your security, your phone currently isn't allowed to install unknown apps from this source."

Tap Settings in that dialog → toggle "Allow from this source" for whichever browser handled the download. This is a one-time setup per browser. Future builds will install with one tap.

The installed app appears in your app drawer with the name from your app.json.

Step 8 — connect the dev build to Metro

npx expo start --dev-client

The --dev-client flag is essential. Without it, Metro starts in Expo-Go mode and the QR won't work with your dev build.

In the dev build app on your phone:

  • Same Wi-Fi as your laptop: it should auto-detect the running Metro server. Tap to connect.
  • Different network or auto-detect fails: use npx expo start --dev-client --tunnel instead — slower (extra network hop) but works across networks.

Your JS bundle loads onto the device. Fast refresh works — edit code, save, see changes on the phone immediately.


When to rebuild vs. just refresh

This is the most useful efficiency to internalize:

ChangeAction needed
JS code, JSX, styling, business logicNone — fast refresh handles it
Adding/removing native modules (anything via npx expo install)Rebuild via eas build --profile development --platform android
app.json config like permissions, icons, package nameRebuild
eas.json profile changesRebuild

In practice, 95% of your daily edits never trigger a rebuild. You only re-run eas build when you've changed something native.


Gotchas worth remembering

  1. NativeWind needs the content glob to match your project layout. SDK 56 uses src/; older SDKs use a flat app/. Adjust both tailwind.config.js and metro.config.js paths accordingly.
  2. /// <reference types="nativewind/types" /> silently fails on TypeScript 6. Use /// <reference path="./node_modules/react-native-css-interop/types.d.ts" /> instead.
  3. declare module '*.css' is missing on SDK 56 scaffolds. Add it inline to your nativewind-env.d.ts.
  4. Always restart Metro with --clear after editing babel.config.js, metro.config.js, or tailwind.config.js. Metro caches aggressively.
  5. Use npx expo install for expo-* packages, not npm install. The wrapper picks SDK-compatible versions.
  6. Pick your Android package name before the first build. It's effectively permanent and affects how dev builds replace each other on your phone.
  7. Web is for UI iteration, dev builds are for real testing. Don't add web-only code paths to make features work on web that already work on native — it inverts the priority.

What you have at the end

  • A working Expo SDK 56 + Expo Router + TypeScript project
  • NativeWind v4 wired end-to-end, verified with a className test
  • A custom Android dev build installed on your physical device
  • The daily workflow: npx expo start --dev-client → open dev build app → edit code → see changes live

From here, the next steps are typically: clean up the template noise (npm run reset-project), set up your design tokens, and start building real screens. iOS dev builds come later when you're ready to pay for the Apple Developer Program.

The setup is a few hours of front-loaded work but it's the same setup for the lifetime of the project. After this, you're building features, not fighting tooling.

👨‍💻

Your Name

Senior Fullstack Engineer