Getting started

Wire Inlay into a Flutter module and a native host - packages, code generation, native init, and engine prewarming.

Inlay turns annotated Dart schema classes into type-safe navigation and shared-state APIs for Dart, Swift, and Kotlin. Before you can navigate between screens or share state, three things need to be in place: the right packages, the generated code, and a one-time native initialization. In this page, we will walk through that setup. The Navigation and State management pages cover the features themselves.

Requirements

Minimum
Flutter3.41 (3.44+ to integrate the iOS host through Swift Package Manager)
Dart3.11
iOS13.0 for the inlay plugin; 16.0 for the SwiftUI helpers (InlayFlutterView, .inlayDialog)
AndroidAPI 24 (Android 7.0)

These are the framework's own floors. The Flutter SDK you build with may require more - Flutter 3.47 raised its iOS minimum to 15, for example - so the effective minimum is whichever is higher.

Pin the Flutter version your module and hosts build with - see Versioning for why and how.

Packages

Inlay is split across a few packages:

PackageRole
inlayCore framework plugin (Dart + Swift + Kotlin). Navigation, storage, platform channels.
inlay_genCode generator - produces typed route/store classes for Dart, Kotlin, and Swift from your annotated schemas. Add it as a dev dependency.
inlay_composeOptional plugin adding Jetpack Compose helpers (InlayFlutterScreen, InlayFlutterDialog).

Add inlay to your Flutter module's pubspec.yaml. Put inlay_gen (and build_runner) under dev_dependencies, since code generation only runs at build time. Add inlay_compose only if your Android host uses Jetpack Compose:

# pubspec.yaml of your Flutter module
dependencies:
  inlay: ^0.1.1
  inlay_compose: ^0.1.1 # optional - Jetpack Compose only

dev_dependencies:
  inlay_gen: ^0.1.1
  build_runner: ^2.11.0

Projects that don't use Compose depend only on inlay - no Compose dependencies end up on the Android classpath. Use InlayNavigator.push(...) or InlayNavigator.createFragment(...) directly in that case.

Code generation

Inlay's type safety comes from code generation. You describe your routes and stores once, as plain annotated Dart classes, and inlay_gen emits matching type-safe classes for Dart, Kotlin, and Swift. There are no hand-written route paths or string keys that could drift out of sync - when a schema changes, the mismatch shows up as a compile error on every platform that uses it.

Schema files work similarly to Pigeon definitions - they are specs only. App code never imports them; it imports the generated output. The annotated classes only describe the shape of your routes and stores to the generator.

Write your schema files

Let's put the route and store definitions in dedicated files - by convention lib/inlay/routes.dart and lib/inlay/stores.dart. A route is an annotated class whose fields become typed, serialized parameters; a store is an annotated class whose fields become a synchronized key-value record.

lib/inlay/routes.dart
import 'package:inlay/inlay.dart';

// A Flutter screen. `:userId` in the path maps to the `userId` field. Keep path
// fields required - the generator doesn't enforce it, and a null path parameter
// would encode as an empty path segment.
// Simple fields (primitives, enums) become optional query parameters; complex
// fields like `badges` travel only in the typed params envelope.
@InlayFlutterRoute('/profile/:userId')
class ProfilePage {
  const ProfilePage({required this.userId, this.badges});

  final String userId;
  final List<UserBadge>? badges; // nested types and enums are supported
}

// A native screen reachable from Flutter, type-safely.
@InlayNativeRoute()
class NativeSettingsPage {
  const NativeSettingsPage({this.source});

  final String? source;
}

class UserBadge {
  const UserBadge({required this.label, required this.level});
  final String label;
  final BadgeLevel level;
}

enum BadgeLevel { bronze, silver, gold }
lib/inlay/stores.dart
import 'package:inlay/inlay.dart';

@InlayStore(key: 'user_preferences')
class UserPreferencesStore {
  const UserPreferencesStore({
    @InlayStoreKey() required this.userId, // marks the identifying key
    this.displayName = 'User',
    this.darkMode = false,
    this.tags = const [],
  });

  final String userId;
  final String displayName;
  final bool darkMode;
  final List<String> tags;
}

For the full annotation reference - dialogs, path vs query parameters, native-route handlers, and store keys - see Defining routes and Defining stores.

Configure inlay.yaml

Each module has an inlay.yaml at its root. It points the generator at your schema files and declares where generated code lands for each language:

inlay.yaml
# Schema inputs (specs only - not imported by app code)
routes: lib/inlay/routes.dart
stores: lib/inlay/stores.dart

# Output target per language. Omit kotlin or swift to skip that language.
# Dart is always generated; under build_runner it defaults to lib/src/generated/.
dart:
  output: lib/src/generated/

kotlin:
  output: android/src/main/kotlin/com/example/app/generated/
  package: com.example.app.generated # required for Kotlin

swift:
  output: ios/Classes/Generated/

Point the Kotlin and Swift outputs at the directories your native host compiles, so that the generated data classes and structs are picked up directly by the native build.

Run the generator

The recommended path is the build_runner builder. Because inlay_gen declares its builder with auto_apply: dependents, it runs automatically for any module that has inlay_gen in its dependencies - there's no build.yaml to add. Generate from the module directory, and re-generate whenever a schema changes:

dart run build_runner build
# or, while iterating on schemas:
dart run build_runner watch

If you'd rather not use build_runner (for example in a CI step or a native build hook), the standalone CLI reads the same inlay.yaml:

dart run inlay_gen:inlay_gen --config inlay.yaml

What gets generated

From the schema above, the generator emits one file per language pair, each with the same types so the boundary stays in sync:

FileContents
routes.g.dartA sealed FlutterRoute / FlutterDialogRoute hierarchy and native-route classes - serialization built in, enabling exhaustive pattern matching.
stores.g.dartTyped store classes with serialization to/from the key-value layer.
Routes.g.kt / Routes.g.swiftKotlin data classes / Swift structs for the same routes, plus a typed NativeRouteHandler base.
Stores.g.kt / Stores.g.swiftKotlin data classes / Swift structs for the same stores.

App code imports the Dart output (import 'package:my_module/src/generated/routes.g.dart';), and your native host uses the generated Kotlin/Swift types directly. Remember to treat all *.g.* files as generated code - don't edit them by hand, re-run the generator instead.

Native initialization

We recommend keeping the Flutter module runnable as a standalone app while you build it, and treating host integration as the last step. Hybrid debugging (a native debugger and a Flutter debugger attached at the same time) is slow and can be flaky, and teams doing add-to-app at scale build almost everything standalone first. Plain flutter run in the module remains your day-to-day loop - the host wiring below is a one-time setup.

Call the framework's init once at app startup. It creates the shared FlutterEngineGroup and registers your handler for Flutter → native routes. Both entry points accept an optional prewarm parameter (default true).

// AppDelegate
import FlutterPluginRegistrant

// Register plugins on every engine Inlay creates - set before start()
// so the prewarmed engine is covered too.
InlayNavigator.shared.setOnEngineCreated { engine in
    GeneratedPluginRegistrant.register(with: engine)
}
InlayNavigator.shared.start() // prewarm: true by default
InlayNavigator.shared.setNativeRouteHandler(MyNativeRouteHandler())

Don't skip the setOnEngineCreated line on iOS - it is what keeps your module's plugins working. Flutter plugins register per engine, and the iOS embedding never does it for you, so without this call every plugin with native iOS code fails with MissingPluginException on each screen. Android registers plugins automatically, so its init needs no equivalent line. Plugins in a multi-engine setup covers the mechanics.

See Handling native routes for what NativeRouteHandler looks like and how Flutter → native navigation is dispatched.

Dart entrypoint

Every Flutter engine the framework spins up runs the same Dart entrypoint - inlayMain by default. At a minimum it has to initialize the Flutter binding and the shared storage layer before runApp:

@pragma('vm:entry-point')
void inlayMain() async {
  WidgetsFlutterBinding.ensureInitialized();
  await KeyValueStorage.instance.init();

  // ... resolve the initial route and runApp(...)
  // (see the Navigation page for router wiring)
}

Schema drift is detected automatically. The generator embeds a stable fingerprint of your route/store schema into the Dart, Kotlin, and Swift output; every route carries it inside PageSettings, and the generated decoders verify it on arrival. A host and module built from different schema revisions fail on the first navigation with a descriptive error naming both fingerprints - there is nothing to wire up in app code.

To use a different top-level function (for example, to keep several add-to-app entrypoints in one module), point the framework at it at startup. If the hidden prewarmed engine already exists, it is recreated with the new entrypoint, while engines that are already running keep theirs.

InlayNavigator.shared.setDartEntrypoint("myOtherMain")

How you turn the initial route into a widget depends on whether you use a declarative router (go_router, auto_route) or imperative pattern matching - both are covered in Flutter router integration.

Engine prewarming

By default the framework keeps one hidden prewarmed engine, so the first Flutter navigation doesn't have to pay the engine startup cost. You can disable it or tear it down explicitly:

InlayNavigator.shared.setPrewarmEnabled(false)
InlayNavigator.shared.destroyPrewarmedEngine()

Engines spawned from the same FlutterEngineGroup share resources, so each additional one costs little. Inlay creates and disposes them for you - see Core concepts for the engine model behind this.

On this page