All updates
Oliver Edis

How to Build a VR App with React Native: A ViroReact Quickstart for Meta Quest

How to Build a VR App with React Native: A ViroReact Quickstart for Meta Quest

Can you build an immersive VR app with React Native? As of ViroReact 2.55.0, yes, and you can do it from the same codebase that already powers your AR experiences. With this release we brought support for HorizonOS, the operating system used by Meta’s Quest headsets, arguably the most widely used VR headsets in the market at the moment. This now means that not only can your existing AR experiences now be rendered in immersive VR but you can do so with very few changes to your codebase, no rewrites. One codebase for AR and VR.

In this post we’re going to walk you through the quickest way to get setup for building a VR app with React Native and ViroReact. We’ll explore the new XRSceneNavigator component, build our first immersive scene, and sideload it onto a Meta Quest headset.

What you’ll need

A handful of things, none of them exotic:

  • A Meta Quest 2, Quest 3, or Quest 3S in Developer Mode. If you’ve never enabled Developer Mode on your Quest, do that first in the Meta Quest mobile app, under Settings → Headset → Developer Mode.
  • Node 20+, the Expo CLI, and adb on your PATH. adb ships with Android Studio's platform tools, or you can install it standalone.
  • Expo SDK 55 or newer and React Native 0.83 or newer. The Quest VR path enforces this at runtime, ViroXRSceneNavigator throws an actionable error on older versions and refuses to launch VR. AR continues to work on Expo 54, but VR on Quest requires 55.

That’s the entire prerequisite list. No Unity, no Unreal, no native Android knowledge.

1. Create a clean Expo app

Start from a fresh template so we know the project state is good:

npx create-expo-app@latest my-first-vr-app --template blank-typescript
cd my-first-vr-app

This gives you a standard Expo project on the latest SDK with TypeScript pre-configured.

2. Add ViroReact

Install the package:

npm install @reactvision/react-viro

@reactvision/react-viro is both the JavaScript library and a config plugin. Installing it is enough; we'll wire the plugin in app.json next.

3. Configure the Expo plugin for Quest

Open app.json (or app.config.ts if you prefer TypeScript config) and add ViroReact to the plugins array, with QUEST enabled:

{
  "expo": {
    "name": "my-first-vr-app",
    "slug": "my-first-vr-app",
    "newArchEnabled": true,
    "plugins": [
      [
        "@reactvision/react-viro",
        {
          "android": {
            "xRMode": ["QUEST"]
          }
        }
      ]
    ]
  }
}

A few notes on this:

  • xRMode controls which XR targets the plugin compiles in. We're shipping a VR-only build here, so ["QUEST"] is enough. If you want the same APK to also run as an AR app on a regular Android phone, use ["AR", "QUEST"] instead, and ViroReact will use isQuest at runtime to pick the right path.
  • questAppId is an optional field on the plugin config. It's the numeric App ID from your Meta developer dashboard and gets written into AndroidManifest.xml as a com.oculus.app_id meta-data entry so the headset shows the right name in the system UI. Since we're not publishing this app, you can leave it out. Add it later, when you're ready to ship to the Meta Quest Store.
  • newArchEnabled: true is non-negotiable. ViroReact's Expo plugin requires React Native's New Architecture (Fabric). On fresh Expo 55 projects this is already on; double-check before continuing.

4. Write your VR scene

Replace the contents of App.tsx with this:

import React from "react";
import { StyleSheet, View } from "react-native";
import {
  ViroXRSceneNavigator,
  ViroScene,
  ViroAmbientLight,
  ViroDirectionalLight,
  ViroBox,
  ViroText,
  ViroController,
  ViroMaterials,
} from "@reactvision/react-viro";

ViroMaterials.createMaterials({
  boxMaterial: {
    diffuseColor: "#3ddc84",
  },
});
function MyVRScene() {
  return (
    <ViroScene>

      {/* Make the controllers visible with their pointer reticle. */}
      <ViroController controllerVisibility reticleVisibility />
      {/* Light the scene so PBR materials look right. */}
      <ViroAmbientLight color="#ffffff" intensity={400} />
      <ViroDirectionalLight
        color="#ffffff"
        direction={[0, -1, -0.2]}
        castsShadow
      />
      {/* A floating greeting at eye level. */}
      <ViroText
        text="Hello, VR"
        position={[0, 0, -2]}
        scale={[1, 1, 1]}
        style={{
          fontFamily: "Helvetica",
          fontSize: 28,
          color: "#ffffff",
          textAlign: "center",
        }}
      />
      {/* A green box below the text, so you have something obvious
          to look at and move around. */}
      <ViroBox
        position={[0, -0.6, -2]}
        scale={[0.4, 0.4, 0.4]}
        rotation={[0, 30, 0]}
        materials={["boxMaterial"]}
      />
    </ViroScene>
  );
}
export default function App() {
  return (
    <View style={styles.container}>
      <ViroXRSceneNavigator
        vrInitialScene={{ scene: MyVRScene }}
        onExitViro={() => {
          // Fires when the user presses the B button or your scene
          // calls exitVRScene(). Wire this to your panel navigation
          // when you build a real app.
        }}
        style={styles.viro}
      />
    </View>
  );
}
const styles = StyleSheet.create({
  container: { flex: 1 },
  viro: { flex: 1 },
});

A few things worth pointing out:

  • The root of a VR scene is ViroScene, not ViroARScene. ViroARScene is for camera-passthrough AR and renders nothing on Quest.
  • ViroXRSceneNavigator is the cross-reality entry point. We're passing vrInitialScene here because we're going Quest-only; if you also want AR on phones, pass arInitialScene alongside it.
  • On Quest, ViroXRSceneNavigator actually renders null in the React Native panel. The immersive view lives in a separate Activity that the library generates for you. That's why onExitViro matters: when the user exits VR (via the B button, the system menu, or your own UI), the panel is what they come back to, and you'll want to navigate them somewhere.
  • ViroController makes the controllers and their pointer reticle visible so you have something to point with the moment you put the headset on.
  • ViroMaterials.createMaterials registers named materials so the materials={["boxMaterial"]} reference on ViroBox resolves.

That’s the entire app. Hello world in VR, on Quest, in roughly 50 lines.

5. Prebuild the native project

ViroReact ships with native modules and a custom Quest VRActivity, so we need an Expo prebuild before we can produce an APK:

npx expo prebuild --clean

This generates the android/ directory (and ios/, which we'll ignore for this run), writes the ViroReact native modules in, creates VRActivity.kt inside your package, and adds the <activity> entry in AndroidManifest.xml that declares com.oculus.intent.category.VR. Without that intent category, Horizon OS won't grant your app exclusive OpenXR display access and the immersive view stays black.

If you ever upgrade ViroReact and notice that hot reload dies the moment VR launches, delete the existing android/app/src/main/java/<your-package>/VRActivity.kt and re-run expo prebuild --clean. The plugin won't overwrite an existing VRActivity.kt, and old templates from pre-2.55 versions don't drive the React lifecycle correctly.

6. Build the APK

The simplest local build:

cd android 
./gradlew assembleRelease

Gradle will pull dependencies, run the AGP build, and drop an APK at:

android/app/build/outputs/apk/release/app-release.apk

If you’d rather use EAS Build (Expo’s hosted build service), eas build --platform android --profile preview is the equivalent. You'll get the same APK back, signed by EAS, without needing the Android toolchain on your machine.

Either path produces a single APK. The same file runs on Quest because we put QUEST in xRMode.

7. Install on the headset

Plug your Quest into your dev machine over USB, accept the “Allow USB debugging” prompt inside the headset the first time, and confirm adb sees it:

adb devices

You should see your Quest listed. If you don’t, the Meta Quest Developer Hub is the fastest way to debug ADB issues. It surfaces driver problems clearly on both macOS and Windows.

Then push the APK:

adb install -r android/app/build/outputs/apk/release/app-release.apk

Once the install finishes, put the headset on. Your new app shows up in the Unknown Sources section of the Quest library. Open it from there, and you should drop straight into the VR scene: ambient-lit, with “Hello, VR” floating at eye level, a green box rotating below it, and your controllers visible.

What to try next

The scene above is intentionally minimal. The point is to prove the end-to-end pipeline. From here, the obvious next moves:

  • Push to a second scene from inside VR. Every component rendered by ViroXRSceneNavigator gets a sceneNavigator prop with push, pop, replace, and jump methods. Wire one of them to an onClick on a ViroNode and you've got navigation.
  • Add a 360 environment. Viro360Image and Viro360Video give you a full skybox in one component. Drop one in the scene root and your "room" becomes wherever you want.
  • Pull a Studio scene in instead. If you’d rather design VR scenes visually, swap MyVRScene out for <StudioSceneNavigator sceneId="..." /> and ViroReact will mount a scene authored in ReactVision Studio. Same cross-platform behaviour, the scene works in AR on phones and VR on Quest with no extra code.
  • Ship the same app to AR. Change xRMode to ["AR", "QUEST"], add an arInitialScene, and you've got a single APK that runs as AR on a phone and VR on a headset. That's the entire premise of ViroReact 2.55.0, write once, ship everywhere.

If you hit anything strange along the way, open an issue on GitHub or come find us in Discord. And if you build something, even something tiny, please share it. We love seeing what people make once the friction drops out of XR development.

Welcome to immersive VR with React Native!

Dive into ReactVision