How to add native code to your app with Expo Modules

DevelopmentReact NativeProduct17 minutes read

Jacob Clausen

Jacob Clausen

Engineering

Add native power to React Native with Expo Modules. This tutorial builds an audio route detector module, covers event listeners, and shows how to test and ship.

How to add native code to your app with Expo Modules

The Expo ecosystem and a generous community provide libraries for nearly every need in our apps. But sometimes we need native functionality, and the available options don’t fit our use case. That’s when writing custom native code becomes essential.

With Expo Modules, we can create our custom native functionality - while still enjoying the benefits of Expo. We’re not locked into what comes out of the box with the Expo SDK. We have the flexibility to extend our apps in ways that are tailored to our requirements.

In this post, we’ll look at how to create a custom native module for detecting audio routes - that is where the device is currently sending sound output (for example, through the speaker, headphones, or a bluetooth device).

Notes:

There are only very limited ways to test audio route changes on a simulator or emulator, so it’s best to focus on trying this out on a real device instead.

Fresh Expo project

We’ll start by creating a new Expo app. You can also use an existing project if you already have one, but for the sake of this tutorial we’ll create a fresh one. For this example I will be using npm, but feel free to follow along with the package manager of your choice.

Code
npx create-expo-app@latest expo-custom-local-module-example --template blank-typescript

Expo Dev Client

Because this project will have custom native code, it can’t run inside Expo Go, so we'll want to start by creating a development build. The expo-dev-client package gives us an app similar to Expo Go - complete with debugging tools - but with our own native modules included.

Code
npx expo install expo-dev-client

Custom Module setup

Expo Modules API is an abstraction layer on top of JSI which conveniently exposes an API that is consistent across platforms where possible.

We’re set with the basic setup - let’s get started with the creation of our local Expo Module. When prompted the name I’ll be using expo-audio-route, and then just go ahead and accept the suggestions for the following prompts.

Code
npx create-expo-module@latest --local

The --local flag creates a module that lives inside your project rather than a standalone package that could be published to npm. It’s perfect when you just need some custom native functionality for a specific app. This will create a modules folder in the root of our project.

Code
... rest of your project root
modules/
└── expo-audio-route/
├── android/
├── ios/
├── src/
├── expo-module.config.js
└── index.ts

Our custom native code will live inside of the ios and and android directories. Unlike your app's native folders, these do need to be version controlled (more on this later).

Cleanup

Expo Modules can be used to expose native functionality or to create custom native views. In our case we only need functionality, so we can safely remove the generated view files we won't need.

Terminal
rm modules/expo-audio-route/src/ExpoAudioRouteView.tsx rm modules/expo-audio-route/src/ExpoAudioRouteView.web.tsx rm modules/expo-audio-route/src/ExpoAudioRouteModule.web.ts rm modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteView.kt rm modules/expo-audio-route/ios/ExpoAudioRouteView.swift

Plan the API

Before we touch any Swift or Kotlin, we’ll decide what our JavaScript API should look like. This is what is exposed and that developers will actually use. Everything we do on the native side is in service of this contract.

For our Audio Route module, we want two things:

  1. A way to ask the current audio route
    Developers should be able to call one function and know whether audio is playing through There are more possible options to cover, but these will do for now.
    • wiredHeadset
    • bluetooth
    • speaker
    • unknown (fallback for anything else)
  2. A way to listen for changes
    The audio route can change at any time: you unplug your headphones or connect a bluetooth device. The module should notify JavaScript when this happens.

Setting up the TS types

Let’s begin by clearing the contents of our ExpoAudioRoute.types.ts and ExpoAudioRouteModule. You can find these in the src directory of your custom module.

Code
modules/
└── expo-audio-route/
├── ios/
├── src/
│ ├── ExpoAudioRoute.types.ts
│ ├── ExpoAudioRouteModule.ts
│ └── ... other files
├── expo-module.config.js
└── index.ts

Next, we’ll define our TypeScript types. These will describe the possible audio routes, the event payloads, and the shape of the events our module will emit. Having these definitions in place gives us a clear contract between the native code and the JavaScript side.

modules/expo-audio-route/src/ExpoAudioRoute.types.ts
export type ExpoAudioRouteModuleEvents = {
onAudioRouteChange: (params: RouteChangeEvent) => void;
};
export type RouteChangeEvent = {
route: AudioRoute;
};
export type AudioRoute =
| "speaker"
| "wiredHeadset"
| "bluetooth"
| "unknown";

We’ll then expose the functionality we planned for by wiring up the native module to these types. This step makes our contract available in JavaScript, so developers can call getCurrentRouteAsync() and subscribe to onAudioRouteChange events directly from their apps.

modules/expo-audio-route/src/ExpoAudioRouteModule.ts
import { NativeModule, requireNativeModule } from "expo";
import { AudioRoute, ExpoAudioRouteModuleEvents } from "./ExpoAudioRoute.types";
declare class ExpoAudioRouteModule extends NativeModule<ExpoAudioRouteModuleEvents> {
getCurrentRouteAsync(): Promise<AudioRoute>;
}
// This call loads the native module object from the JSI.
export default requireNativeModule<ExpoAudioRouteModule>("ExpoAudioRoute");

Now we have two main entry points in our JavaScript API:

  • getCurrentRouteAsync() - a function you can call at any time to query the native side what the current audio route is.
  • A way to subscribe to events whenever the native side detects a route change.

You might notice that in our TypeScript file we never defined an addListener or removeListener function. That’s because these are already built in when you extend from Expo’s NativeModule type.

Expo Modules automatically exposes the following methods on every native module that declares events:

  • addListener(eventName, callback) - starts listening for events from native. Under the hood, this triggers your Swift/Kotlin OnStartObserving block the first time a listener is added.
  • removeAllListeners(eventName) or subscription.remove() - removes event listeners. When the last listener is removed, Expo calls your OnStopObserving block on the native side.

This means your TypeScript declaration (NativeModule<ExpoAudioRouteModuleEvents>) is enough to make TypeScript aware of the event names and payload shapes.

Finally, we’ll update the index.ts file of our module to stop exporting the ExpoAudioRouteView, since we already removed the files related to Views in a previous step.

modules/expo-audio-route/index.ts
export { default } from "./src/ExpoAudioRouteModule";
export * from "./src/ExpoAudioRoute.types";

Getting Started with the Native Code

We’ll now dive into the native side of our module by opening the ExpoAudioRouteModule files in both the ios/ and android/ folders. The templates generated by create-expo-module include some placeholder code for views and other functionality we don’t need.

modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
import ExpoModulesCore
public class ExpoAudioRouteModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
}
}
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
package expo.modules.audioroute
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoAudioRouteModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
}
}

To meet our goals (querying the current route, listening for changes, and exposing a simple API) we’ll use a few building blocks from the Expo Modules API to connect our Audio Route module to the underlying iOS and Android audio APIs:

  • Events - declare event names your module can send to JavaScript.
    • Example: Events("onAudioRouteChange").
    • Example, multiple: Events("onAudioRouteChange", "onSomethingElseChanged").
  • AsyncFunction - define a native function that JavaScript can call.
    • Example: AsyncFunction("getCurrentRouteAsync") - use it in JS as AudioRoute.getCurrentRouteAsync().
  • OnStartObserving - runs automatically when the first JS listener is added with .addListener.
  • OnStopObserving - runs automatically when the last JS listener is removed.

When declaring these, it's recommended to pass the event name as a parameter - for example: OnStartObserving("onAudioRouteChange") and OnStopObserving("onAudioRouteChange"). This makes your intent explicit and ensures that each block corresponds directly to the event it handles, which is especially helpful if your module emits multiple event types.

In the code sections coming up next, you’ll see how we implement each of these.

Expo Modules gives us a quite a few of these building blocks for describing what our module can do. If you want to dive deeper, check out the Expo Modules API docs.

Below is a quick recording showing these building blocks in action as the app is used.

Setting breakpoints in Xcode to show what methods are executed when

As a first step, we’ll focus on retrieving the current audio route in our app. This function will just return the route when requested, without listening for changes.

Native code part 1 - retrieve audio route

Imports

We start by adding the native audio APIs we’ll need on each platform.

  • On iOS, AVFoundation gives access to the device’s current audio route through AVAudioSession.
  • On Android, we import AudioManager to query connected devices, AudioDeviceInfo to describe them, and AudioDeviceCallback (which we’ll use later) to listen for route changes.
modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
import ExpoModulesCore
+import AVFoundation
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
package expo.modules.audioroute
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
+import android.content.Context
+import android.media.AudioDeviceCallback
+import android.media.AudioDeviceInfo
+import android.media.AudioManager

Initialize AudioManager (Android only)

On Android, we need a reference to the system’s AudioManager so we can query and listen for audio route changes later. We add a private property for it inside our module, and initialize it in the OnCreate block using the appContext provided by Expo Modules.

This ensures we have a valid AudioManager instance ready for any audio-related operations.

modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
class ExpoAudioRouteModule : Module() {
+ private var audioManager: AudioManager? = null
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
+ OnCreate {
+ audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ }
}
}

Add a method to check the current route

Now we define a private helper named currentRoute() that returns a string describing the device’s active audio output. On both platforms, this method normalizes native audio information into one of four string values: wiredHeadset, bluetooth, speaker, or unknown.

  • On iOS, we query AVAudioSession.sharedInstance() for its current route outputs and map the first port type.
  • On Android, we ask the AudioManager for its connected output devices, check them by priority (wired, bluetooth, speaker), and return a matching string.
modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
public class ExpoAudioRouteModule: Module {
public func definition() -> ModuleDefinition {
// Current code, stays the same for now
}
+ private func currentRoute() -> String {
+ let session = AVAudioSession.sharedInstance()
+ let outputs = session.currentRoute.outputs
+
+ guard let first = outputs.first else {
+ return "unknown"
+ }
+
+ switch first.portType {
+ case .headphones, .headsetMic:
+ return "wiredHeadset"
+ case .bluetoothA2DP, .bluetoothLE, .bluetoothHFP:
+ return "bluetooth"
+ case .builtInSpeaker:
+ return "speaker"
+ default:
+ return "unknown"
+ }
+ }
}
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
class ExpoAudioRouteModule : Module() {
override fun definition() = ModuleDefinition {
// Current code, stays the same for now
}
+ private fun currentRoute(): String {
+ val am = audioManager ?: return "unknown"
+ val outputs = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+
+ // Check in priority order: wired > bluetooth > speaker
+ val wiredTypes = listOf(
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
+ )
+
+ val bluetoothTypes = listOf(
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO
+ )
+
+ val speakerTypes = listOf(
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
+ AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
+ )
+
+ val device = outputs.firstOrNull { it.type in wiredTypes }
+ ?: outputs.firstOrNull { it.type in bluetoothTypes }
+ ?: outputs.firstOrNull { it.type in speakerTypes }
+
+ return when (device?.type) {
+ in wiredTypes -> "wiredHeadset"
+ in bluetoothTypes -> "bluetooth"
+ in speakerTypes -> "speaker"
+ else -> "unknown"
+ }
+ }
}

Expose getCurrentRouteAsync to JavaScript

Finally, we expose our currentRoute() helper to JavaScript by adding an AsyncFunction("getCurrentRouteAsync") inside the module definition. This allows JavaScript to query the current audio route at any time. While we’ll also emit events when the route changes, this async function complements that flow by letting apps immediately check the current state on demand.

modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
+ AsyncFunction("getCurrentRouteAsync") {
+ self.currentRoute()
+ }
}
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
class ExpoAudioRouteModule : Module() {
private var audioManager: AudioManager? = null
private var deviceCallback: AudioDeviceCallback? = null
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
OnCreate {
audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
+ AsyncFunction("getCurrentRouteAsync") {
+ currentRoute()
+ }
}
}

Consuming the module

And finally, here’s a minimal example of how we can consume our module in our React component.

App.tsx
import * as React from "react";
import { Text, View } from "react-native";
import AudioModule, { type AudioRoute } from "./modules/expo-audio-route";
export default function App() {
const [currentRoute, setCurrentRoute] = React.useState<AudioRoute>("unknown");
React.useEffect(() => {
// This will call getCurrentRouteAsync function we declared in the Swift/Kotlin code
AudioModule.getCurrentRouteAsync().then((route) => {
setCurrentRoute(route);
});
}, []);
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text>
Audio Route: {currentRoute}
</Text>
</View>
);
}

At this point, we can actually build the first stage of our app. While it won’t be able to listen for events from our module yet, we can still try out the functionality to retrieve the current audio route. If you want to test this out, jump to the "Time to build"-section. Otherwise, continue to part two, where we’ll add the onAudioRouteChange event.

Native code part 2 - subscribe to audio route changes

Declare the event

The first step is to declare the event name that our module will send to JavaScript. Both platforms define an event named onAudioRouteChange. Make sure the event name you declare matches exactly the one you use when sending events from Swift or Kotlin, and the one you listen for in JavaScript.

modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
+ Events("onAudioRouteChange")
AsyncFunction("getCurrentRouteAsync") {
self.currentRoute()
}
}
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
class ExpoAudioRouteModule : Module() {
private var audioManager: AudioManager? = null
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
+ Events("onAudioRouteChange")
AsyncFunction("getCurrentRouteAsync") {
currentRoute()
}
OnCreate {
audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
}
}

Start listening for changes

We add a function that listens for audio route changes from the system and forwards those updates to JavaScript through the onAudioRouteChange event.

  • On iOS, we subscribe to AVAudioSession.routeChangeNotification to detect when the active audio output changes (for example, when plugging in headphones or connecting a Bluetooth device). We keep the observer returned by NotificationCenter.addObserver(...) in a private property called routeChangeObserver, so we can remove it later.
  • On Android, we first add a new private property, deviceCallback, to keep track of our listener instance. Then, using AudioManager.registerAudioDeviceCallback, we register that callback to be notified when audio devices are added or removed. Each time this happens, we emit the current route to JavaScript. Keeping a reference to deviceCallback allows us to prevent duplicate registrations and cleanly unregister later.
modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
public class ExpoAudioRouteModule: Module {
+ private let notificationCenter: NotificationCenter = .default
+ private var routeChangeObserver: NSObjectProtocol?
public func definition() -> ModuleDefinition { ... }
+ private func startObservingRouteChanges() {
+ self.routeChangeObserver = NotificationCenter.default.addObserver(
+ forName: AVAudioSession.routeChangeNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: .main
+ ) { [weak self] _ in
+ guard let self else { return }
+ self.sendEvent(
+ "onAudioRouteChange",
+ [
+ "route": self.currentRoute()
+ ]
+ )
+ }
+
+ try? AVAudioSession.sharedInstance().setActive(true, options: [])
+ }
private func currentRoute() -> String { ... }
}
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
class ExpoAudioRouteModule : Module() {
private var audioManager: AudioManager? = null
+ private var deviceCallback: AudioDeviceCallback? = null
override fun definition() = ModuleDefinition { ... }
+ private fun startObservingRouteChanges() {
+ val am = audioManager ?: return
+ if (deviceCallback != null) return
+
+ deviceCallback = object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(added: Array<out AudioDeviceInfo>?) {
+ sendEvent("onAudioRouteChange", mapOf("route" to currentRoute()))
+ }
+ override fun onAudioDevicesRemoved(removed: Array<out AudioDeviceInfo>?) {
+ sendEvent("onAudioRouteChange", mapOf("route" to currentRoute()))
+ }
+ }
+ am.registerAudioDeviceCallback(deviceCallback, null)
+ }
private fun currentRoute(): String { ... }
}

Stop listening for changes

To prevent memory leaks and unnecessary callbacks, we add a function that stops listening for audio route changes when there are no active JavaScript subscribers.

  • On iOS, we remove the stored observer from NotificationCenter when there are no active JavaScript listeners.
  • On Android, we unregister the AudioDeviceCallback from AudioManager and reset our stored deviceCallback property to null, allowing it to be safely registered again later if needed.
modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
private func startObservingRouteChanges() { ... }
+ private func stopObservingRouteChanges() {
+ if let routeChangeObserver {
+ notificationCenter.removeObserver(routeChangeObserver)
+ self.routeChangeObserver = nil
+ }
+ }
private func currentRoute() -> String { ... }
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
private fun startObservingRouteChanges() { ... }
+ private fun stopObservingRouteChanges() {
+ val am = audioManager ?: return
+ deviceCallback?.let { am.unregisterAudioDeviceCallback(it) }
+ deviceCallback = null
+ }
private fun currentRoute(): String { ... }

Managing listeners

Finally, we connect the listening logic using OnStartObserving and OnStopObserving. These lifecycle hooks are called automatically by Expo Modules: This ensures the native side only does work when needed (the system listeners are active only while JavaScript is subscribed) and from the JS side, the API remains as simple as using the useEventListener hook.

modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
Events("onAudioRouteChange")
AsyncFunction("getCurrentRouteAsync") {
self.currentRoute()
}
+ OnStartObserving("onAudioRouteChange") {
+ self.startObservingRouteChanges()
+ }
+
+ OnStopObserving("onAudioRouteChange") {
+ self.stopObservingRouteChanges()
+ }
}
modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
Events("onAudioRouteChange")
AsyncFunction("getCurrentRouteAsync") {
currentRoute()
}
+ OnStartObserving("onAudioRouteChange") {
+ startObservingRouteChanges()
+ }
+
+ OnStopObserving("onAudioRouteChange") {
+ stopObservingRouteChanges()
+ }
}

Reacting to route changes

With event emission wired up, we can now react to route changes directly in our React component. By listening to onAudioRouteChange, our app stays up to date with the system’s current audio output, reflecting changes immediately without any manual refreshes.

App.tsx
import * as React from "react";
import { Text, View } from "react-native";
import AudioModule, { type AudioRoute } from "./modules/expo-audio-route";
import { useEventListener } from "expo";
export default function App() {
const [currentRoute, setCurrentRoute] = React.useState<AudioRoute>("unknown");
useEventListener(AudioModule, "onAudioRouteChange", ({ route }) => {
setCurrentRoute(route);
});
React.useEffect(() => {
// This will call getCurrentRouteAsync function we declared in the Swift/Kotlin code
AudioModule.getCurrentRouteAsync().then((route) => {
setCurrentRoute(route);
});
}, []);
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text>
Audio Route: {currentRoute}
</Text>
</View>
);
}

Time to build!

Before we start building the app, let’s clarify how to handle native projects and our local custom module when working with Continuous Native Generation.

Managing local Expo Modules with CNG

Continuous Native Generation (CNG) is the process of generating native projects on demand from a set of concise inputs (such as e.g. your app config and package.json). Instead of committing entire native projects (ios and android root directories) to source control, you only commit the configuration and code that define your app. Whenever you need to compile, Expo regenerates the full native projects for you.

Because of this, the root-level ios/ and android/ folders are generated artifacts, similar to node_modules/. They shouldn’t be edited by hand or tracked in version control.

That said, our custom native module is different as it lives inside modules/, and its ios/ and android/ folders are not generated. That’s where we’ll be writing and maintaining our own native code, so those should be checked into source control.

To reflect this, add the following to .gitignore:

.gitignore
/android
/ios

You can confirm this setup by running:

Terminal
npx expo prebuild --clean

Afterwards, check your version control - the root ios/ and android/ directories should no longer be tracked, while your modules/expo-audio-route/ios and modules/expo-audio-route/android folders remain tracked.

Build locally

You can build your project locally by running the compile commands provided by the Expo CLI. These commands generate the ios and android directories that Xcode and Android Studio use to compile your app.

Prerequisites

Before building, make sure you have Android Studio and Xcode installed and set up on your machine. Follow the official setup guides for each platform:

Choose where to run the app

The commands below compile your project using your locally installed Android SDK or Xcode, producing a debug build of your app.

Make sure your phone is physically connected to your computer so it can be detected and used to install the build directly:

Terminal
# Build native iOS project
npx expo run:ios --device

# Build native Android project
npx expo run:android --device

When you run these commands, you’ll be prompted to select a connected device or simulator to install the build on.

Or build with EAS

You can also build remotely using EAS Build if you prefer. With its comprehensive CLI, EAS handles the entire build pipeline for us - from generating native projects and managing dependencies to code signing and producing installable binaries. This means we don’t need to configure Xcode or Android Studio locally just to get a working build; instead, we can focus on writing our code while EAS takes care of the heavy lifting.

To follow along make sure you have:

Configure EAS Build:

Terminal
eas build:configure

This will create an eas.json file to your project with some basic configuration. There is plenty of configuration that you can do to this file, but for our test case right now it’ll work as is.

Terminal
eas build --profile development

This will start building our development build using the development profile defined in eas.json. You can modify existing profiles or create new ones as needed. If you only want to target a specific platform, run:

Terminal
eas build --profile development --platform <platform>

Wrap-up

We designed a JS API, implemented native code in Swift and Kotlin, and exposed it with Expo Modules. Now you can detect audio routes in your Expo app!

This same process works to implement any custom native functionality your Expo app requires: you define the API, implement iOS and Android, and let Expo’s tooling glue it all together. We’ve only scratched the surface here - the opportunities with Expo Modules are endless. I’d love to hear what ideas you come up with, or how you’re already using custom modules in your own projects.

And check out the Github repo for all the code: https://github.com/entiendoNull/expo-module-audio-route-example/

Expo Modules
native code
Swift
Kotlin
continuous native generation

Create amazing apps, in record time with EAS

Learn more