This project is a Kotlin Multiplatform app that shows:
- Navigation with
Navigation3 - Room 3 database for shared CRUD
- Android, iOS, Desktop, JS, and Wasm web support
- Live UI updates using
Flow
The app is a simple notes app:
Screen Ashows a form and a notes listScreen Bis a second screen to prove navigation works- You can add, edit, and delete notes
- The list updates live
- Notes are stored in a local database
This project currently supports these Room paths:
- Android: Room 3 +
BundledSQLiteDriver - iOS: Room 3 +
BundledSQLiteDriver - Desktop/JVM: Room 3 +
BundledSQLiteDriver - Web JS: Room 3 +
sqlite-web+ worker - Web Wasm: Room 3 +
sqlite-web+ worker
androidAppAndroid launcherdesktopAppDesktop launchersharedShared UI, navigation, Room entities, DAO, repository, screen model, and platform database creationwebAppWeb launcher for JS and WasmsqliteWasmWorkerWorker package used by Room web
These versions are defined in gradle/libs.versions.toml:
- Kotlin:
2.4.0 - Compose Multiplatform:
1.11.1 - Room 3:
3.0.0-alpha06 - SQLite web/bundled:
2.7.0-alpha06
- shared/src/commonMain/kotlin/org/roomnav/project/App.kt
- shared/src/commonMain/kotlin/org/roomnav/project/ScreenA.kt
- shared/src/commonMain/kotlin/org/roomnav/project/ScreenB.kt
- shared/src/commonMain/kotlin/org/roomnav/project/ScreenNavigation.kt
- shared/src/commonMain/kotlin/org/roomnav/NotesScreenModel.kt
- shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/AppDatabase.kt
- shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/NoteEntity.kt
- shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/NoteDao.kt
- shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/NotesRepository.kt
- shared/src/androidMain/kotlin/org/roomnav/project/CreateDatabase.kt
- shared/src/jvmMain/kotlin/org/roomnav/project/CreateDatabase.kt
- shared/src/iosMain/kotlin/org/roomnav/project/CreateDatabase.kt
- shared/src/webMain/kotlin/org/roomnav/project/CreateDatabase.kt
- shared/src/webMain/kotlin/org/roomnav/project/WebWorkerDriver.kt
- shared/src/jsMain/kotlin/org/roomnav/project/WebWorkerDriver.js.kt
- shared/src/wasmJsMain/kotlin/org/roomnav/project/WebWorkerDriver.wasmJs.kt
- androidApp/src/main/kotlin/org/roomnav/project/MainActivity.kt
- desktopApp/src/main/kotlin/org/roomnav/project/main.kt
- shared/src/iosMain/kotlin/org/roomnav/project/MainViewController.kt
- webApp/src/webMain/kotlin/org/roomnav/project/main.kt
- sqliteWasmWorker/build.gradle.kts
- sqliteWasmWorker/worker/package.json
- sqliteWasmWorker/worker/worker.js
- webApp/webpack.config.d/devserver-headers.js
- webApp/src/webMain/resources/index.html
The flow in this project is:
ScreenA -> NotesScreenModel -> NotesRepository -> NoteDao -> AppDatabase
Then Room emits Flow<List<NoteEntity>> back to the UI.
That is why the list updates by itself when you add, edit, or delete.
flowchart LR
A["Android MainActivity"] --> UI["App + Navigation3"]
I["iOS MainViewController"] --> UI
D["Desktop main.kt"] --> UI
W["Web main.kt"] --> UI
UI --> SA["ScreenA"]
SA --> SM["NotesScreenModel"]
SM --> REPO["NotesRepository"]
REPO --> DAO["NoteDao"]
DAO --> DB["AppDatabase"]
DB --> AD["Android BundledSQLiteDriver"]
DB --> ID["iOS BundledSQLiteDriver"]
DB --> JD["Desktop BundledSQLiteDriver"]
DB --> WD["WebWorkerSQLiteDriver"]
This section is written like a recipe.
Your root project includes:
include(":androidApp")
include(":desktopApp")
include(":shared")
include(":webApp")
include(":sqliteWasmWorker")See settings.gradle.kts.
In gradle/libs.versions.toml, add:
room3 = "3.0.0-alpha06"
sqlite = "2.7.0-alpha06"And libraries:
room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
sqlite-web = { module = "androidx.sqlite:sqlite-web", version.ref = "sqlite" }In build.gradle.kts:
id("androidx.room3") version "3.0.0-alpha06" apply false- apply Kotlin Multiplatform
- apply Compose
- apply KSP
- apply
androidx.room3 - create targets for Android, iOS, JVM, JS, and Wasm
Important shared dependencies:
commonMain.dependencies {
api(libs.room3.runtime)
}
androidMain.dependencies {
implementation(libs.sqlite.bundled)
}
iosMain.dependencies {
implementation(libs.sqlite.bundled)
}
jvmMain.dependencies {
implementation(libs.sqlite.bundled)
}
webMain.dependencies {
implementation(libs.sqlite.web)
implementation(project(":sqliteWasmWorker"))
}Important KSP lines:
add("kspAndroid", libs.room3.compiler)
add("kspJvm", libs.room3.compiler)
add("kspIosSimulatorArm64", libs.room3.compiler)
add("kspIosArm64", libs.room3.compiler)
add("kspJs", libs.room3.compiler)
add("kspWasmJs", libs.room3.compiler)Important schema line:
room3 {
schemaDirectory(layout.projectDirectory.dir("schemas"))
}File: shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/NoteEntity.kt
@Entity
data class NoteEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val title: String,
val description: String,
val updatedAt: Long = System.now().toEpochMilliseconds()
)This is one note row in the database table.
File: shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/NoteDao.kt
@Dao
interface NoteDao {
@Query("SELECT * FROM NoteEntity ORDER BY updatedAt DESC")
fun observeAllNotes(): Flow<List<NoteEntity>>
@Insert
suspend fun insertNote(note: NoteEntity): Long
@Update
suspend fun updateNote(note: NoteEntity)
@Query("DELETE FROM NoteEntity WHERE id = :id")
suspend fun deleteById(id: Long)
}This file says how to:
- read all notes
- insert one note
- update one note
- delete one note
File: shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/AppDatabase.kt
@Database(entities = [NoteEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}This is the main Room database.
File: shared/src/commonMain/kotlin/org/roomnav/project/RoomSetup/NotesRepository.kt
The repository is a clean middle layer between UI and DAO.
It calls:
dao.observeAllNotes()dao.insertNote(...)dao.updateNote(...)dao.deleteById(...)
File: shared/src/commonMain/kotlin/org/roomnav/NotesScreenModel.kt
This class:
- holds a
CoroutineScope - turns the DAO flow into a
StateFlow - exposes
add,update, anddelete
This is why the UI can do:
val notes by model.notes.collectAsState()Navigation files:
The screen keys are:
sealed interface ScreenNavigation {
data object ScreenA : ScreenNavigation
data object ScreenB : ScreenNavigation
}The app uses:
NavDisplay(backStack = backStack, entryProvider = entryProvider { ... })That means:
backStackstores the current screen historyentry<ScreenNavigation.ScreenA> { ... }draws Screen Aentry<ScreenNavigation.ScreenB> { ... }draws Screen B
File: shared/src/commonMain/kotlin/org/roomnav/project/ScreenA.kt
This file does all of this:
- shows title and description inputs
- shows
AddorSave - shows
Clear - shows a note list
- lets you edit a note
- lets you delete a note
- lets you go to Screen B
The most important UI lines are:
val notes by model.notes.collectAsState()and:
if (id == null) model.add(title, description)
else model.update(id, title, description)File: shared/src/commonMain/kotlin/org/roomnav/project/ScreenB.kt
This is just a simple second screen to prove navigation works.
File: shared/src/androidMain/kotlin/org/roomnav/project/CreateDatabase.kt
It uses:
Room.databaseBuilder<AppDatabase>(
context = context.applicationContext,
name = dbFile.absolutePath,
)
.setDriver(BundledSQLiteDriver())
.build()File: shared/src/jvmMain/kotlin/org/roomnav/project/CreateDatabase.kt
Desktop stores the DB in:
File(System.getProperty("user.home"), "navnotes.db")File: shared/src/iosMain/kotlin/org/roomnav/project/CreateDatabase.kt
iOS stores the DB in the documents directory.
File: shared/src/webMain/kotlin/org/roomnav/project/CreateDatabase.kt
fun createDatabase(): AppDatabase {
return Room.databaseBuilder<AppDatabase>("navnotes.db")
.setDriver(createWebWorkerDriver())
.setSingleConnectionPool()
.build()
}Important:
- web uses
sqlite-web - web needs a worker
- web uses a single connection pool
Files:
- shared/src/webMain/kotlin/org/roomnav/project/WebWorkerDriver.kt
- shared/src/jsMain/kotlin/org/roomnav/project/WebWorkerDriver.js.kt
- shared/src/wasmJsMain/kotlin/org/roomnav/project/WebWorkerDriver.wasmJs.kt
This split is needed because:
webMaindeclaresexpect fun createWebWorkerDriver()jsMaingives the JSactualwasmJsMaingives the Wasmactual
Files:
- sqliteWasmWorker/build.gradle.kts
- sqliteWasmWorker/worker/package.json
- sqliteWasmWorker/worker/worker.js
The worker module pulls in:
npm("sqlite-wasm-worker", layout.projectDirectory.dir("worker").asFile)The worker.js file:
- opens a DB
- prepares statements
- executes steps
- closes statements and DBs
It also has a safe fallback:
sqlite3.oo1?.OpfsDb
? new sqlite3.oo1.OpfsDb(requestData.fileName)
: new sqlite3.oo1.DB(requestData.fileName, 'ct');That means:
- if OPFS persistence is available, use it
- if not, still open a working DB instead of crashing
File: webApp/build.gradle.kts
This module:
- creates
jsbrowser executable - creates
wasmJsbrowser executable - depends on
shared - depends on
sqliteWasmWorker
It also contains the Wasm npm sync workaround so Wasm webpack can see the worker package:
syncSqliteWorkerForWasmsyncSqliteRuntimeForWasm
File: webApp/webpack.config.d/devserver-headers.js
config.devServer.headers = {
...(config.devServer.headers || {}),
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};These headers matter for web persistence support.
Files:
Very important line:
<script src="webApp.js"></script>Without that line, the browser only shows a white screen because the app never starts.
File: androidApp/src/main/kotlin/org/roomnav/project/MainActivity.kt
Flow:
val db = createDatabase(this)
val repo = NotesRepository(db.noteDao())
val model = NotesScreenModel(repo)
setContent { App(model) }File: desktopApp/src/main/kotlin/org/roomnav/project/main.kt
Flow:
val db = createDatabase()
val repo = NotesRepository(db.noteDao())
val model = NotesScreenModel(repo)
Window { App(model) }File: shared/src/iosMain/kotlin/org/roomnav/project/MainViewController.kt
Flow:
val db = createDatabase()
val repo = NotesRepository(db.noteDao())
val model = NotesScreenModel(repo)
App(model)File: webApp/src/webMain/kotlin/org/roomnav/project/main.kt
Flow:
ComposeViewport {
val db = remember { createDatabase() }
val repo = remember { NotesRepository(db.noteDao()) }
val model = remember { NotesScreenModel(repo) }
App(model)
}- Open the root folder: RoomAndNavigation3
- Wait for Gradle sync to finish.
- If Android Studio asks for a JDK, use
JDK 17. - Select the
androidApprun configuration. If it is missing, open MainActivity.kt and run it from the gutter. - Start an emulator or connect a real Android device.
- Press
Run.
- Open iosApp/iosApp.xcodeproj.
- Select the
iosAppscheme. - Pick an iPhone simulator such as
iPhone 16. - Press
Run. - The Xcode build phase will call
./gradlew :shared:embedAndSignAppleFrameworkForXcodefor you. - If the first launch fails because the Kotlin framework is not ready yet, run:
./gradlew :shared:compileKotlinIosSimulatorArm64and then press Run again in Xcode.
Development:
./gradlew :webApp:jsBrowserDevelopmentRunProduction bundle:
./gradlew :webApp:jsBrowserDistributionDevelopment:
./gradlew :webApp:wasmJsBrowserDevelopmentRunProduction bundle:
./gradlew :webApp:wasmJsBrowserDistribution./gradlew :shared:compileKotlinJs :shared:compileKotlinWasmJs :webApp:compileKotlinJs :webApp:compileKotlinWasmJsOpen in Android Studio and run the androidApp configuration.
Run the desktop entry:
./gradlew :desktopApp:runIf you only want to confirm the shared iOS code compiles:
./gradlew :shared:compileKotlinIosSimulatorArm64When everything is working:
- App starts on Screen A
- You type a title and description
- Press
Add - Note appears in the list
- Press
Editto load note into the form - Press
Saveto update it - Press
Deleteto remove it - Press
Go to Screen B - Press
Go to Screen A - Notes remain because they are stored in Room
If you want to explain this project to a child, say it like this:
NoteEntityis one noteNoteDaoknows how to save and read notesAppDatabaseis the notes boxNotesRepositoryis the helper who talks to the boxNotesScreenModelis the brain for the screenScreenAis what the user seesApp.ktdecides which screen to show- each platform creates the same app with its own database path
- JS and Wasm both run
- Wasm needed extra setup
- web uses a worker
- the custom HTML must include
webApp.js - dev server headers help the browser allow OPFS features
- if OPFS is not available, the worker fallback keeps the app alive
Add the plugin at the root in build.gradle.kts:
id("androidx.room3") version "3.0.0-alpha06" apply falseThen apply it in shared/build.gradle.kts:
id("androidx.room3")Add this in shared/build.gradle.kts:
room3 {
schemaDirectory(layout.projectDirectory.dir("schemas"))
}Do not write ksp("...") like a dependency string in Kotlin DSL configuration blocks. Use normal dependencies or KSP target lines like these in shared/build.gradle.kts:
add("kspAndroid", libs.room3.compiler)
add("kspJvm", libs.room3.compiler)
add("kspIosSimulatorArm64", libs.room3.compiler)
add("kspIosArm64", libs.room3.compiler)
add("kspJs", libs.room3.compiler)
add("kspWasmJs", libs.room3.compiler)This usually means the module using AppDatabase does not have the Room 3 runtime on its classpath through shared. Make sure shared/build.gradle.kts has:
commonMain.dependencies {
api(libs.room3.runtime)
}Put the shared web database builder in:
and call it from:
The package name must match.
Do not instantiate the web driver in webMain. Keep only:
expect fun createWebWorkerDriver(): SQLiteDriverin shared/src/webMain/kotlin/org/roomnav/project/WebWorkerDriver.kt, then create the actual driver separately in:
- shared/src/jsMain/kotlin/org/roomnav/project/WebWorkerDriver.js.kt
- shared/src/wasmJsMain/kotlin/org/roomnav/project/WebWorkerDriver.wasmJs.kt
The worker package must be available to the web bundle. Keep:
and keep the Wasm sync tasks in webApp so webpack can see the worker package.
Check these three things:
- webApp/src/webMain/resources/index.html must include:
<script src="webApp.js"></script>"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",- The worker fallback in sqliteWasmWorker/worker/worker.js should still handle browsers that do not expose
OpfsDb.
Easy next steps:
- add search
- add note timestamps to the UI
- add note details screen
- add validation messages
- split UI into smaller composables
- add tests for DAO and repository