Write Angular apps with pleasure on Kotlin
βΊοΈ
Benefits:
- Kotlin itself β¨ - no
;, nothis, realnullsafety - Annotations 1:1 with Angular TS (
@Component/@Injectable/@NgModule/@Directive/@Pipe) - JIT and AOT compilation - AOT runs through Angular's own compiler, so
@angular/compilerstays out of the runtime bundle and tracking a new Angular release is a one-line version bump (nong update, no TypeScript to touch) - Coroutines instead of RxJS in your app code - kotlinx.coroutines Flow for Router params, Forms
valueChanges, signals bridge - Ktor + kotlinx.serialization as a Kotlin-native HTTP stack (typed JSON, no
HttpClientboilerplate - or opt into Angular'sHttpClient) - Support of Multiplatform Projects - share code with JVM/Native
- Gradle plugin drives the build - applies the Kotlin/JS + KSP plugins, serves the JIT app, and runs the whole AOT pipeline from one task
Three artifacts: the Gradle plugin (Gradle Plugin Portal) plus the runtime and the
KSP processor (Maven Central, group io.github.vladkalyuzhny).
settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}Your Kotlin/JS app's build.gradle.kts:
plugins {
id("io.github.vladkalyuzhny.angularkt") version "0.1.0" // applies the Kotlin/JS + KSP plugins for you
}
kotlin {
sourceSets {
val jsMain by getting {
dependencies {
implementation("io.github.vladkalyuzhny:angularkt:0.1.0")
}
}
}
}The plugin registers the KSP processor and configures the Kotlin/JS target; you only add
the angularkt runtime dependency. See demo/ for a full setup (the @angular/*
npm versions, Material, JIT vs AOT)
@Injectable(providedIn = "root")
class GreetingService {
fun greet(): String = "AngularKt"
}
@JsExport
@Component(
selector = "app-root",
template = """
<p>Welcome to {{title}}!</p>
"""
)
class AppComponent(private val greeting: GreetingService) : OnInit {
var title = ""
override fun ngOnInit() {
title = greeting.greet()
}
}
@NgModule(
declarations = [AppComponent::class],
imports = [BrowserModule::class],
bootstrap = [AppComponent::class]
)
class AppModule
fun main() {
registerAngularKt() // generated by the KSP processor from the annotations
platformBrowserDynamic(undefined).bootstrapModule(AppModule::class.js)
}Isn't that awesome? Metadata lives on the annotations, 1:1 with Angular TS
(@JsExport is the Kotlin analog of TS export class, so Angular can reach
members by name). The :processor KSP module reads the annotations and generates
registerAngularKt() - the runtime decorator wiring and constructor-DI metadata.
Routing has its own annotation. The root route set (the one nothing references as
children) compiles to whatever the bootstrap style calls for. Under a standalone
bootstrap it becomes provideRouter(routes) at the environment injector - the idiomatic
standalone target, no shim. Under a classic-NgModule bootstrap the processor emits a runtime
@NgModule({ imports: [RouterModule.forRoot(routes)] }) decoration on the route class,
making it an importable forRoot module. (Bootstrap style is independent of the compile mode,
the demo just pairs standalone with AOT and classic with JIT to showcase both.)
@RoutingModule(routes = [
Route(path = "", component = HomeComponent::class),
Route(path = "user/:id", component = UserComponent::class),
Route(path = "product/:id", component = ProductComponent::class,
children = ProductRoutes::class),
Route(path = "admin", loadChildren = AdminModule::class),
])
class AppRoutingModuleThe generated bridge wires whichever the bootstrap uses: a standalone app gets provideRouter;
a classic app has registerAngularKt() decorate AppRoutingModule with RouterModule.forRoot(routes)
and your root @NgModule (e.g. BootstrapModule) import it. The rest of the providers ride along the
same way β appProviders() for the standalone app, the root @NgModule's imports for the classic one.
Router features carry over to either style β e.g. @RoutingModule(useHash = true) (#/path URLs)
becomes provideRouter(routes, withHashLocation()) under a standalone bootstrap and
RouterModule.forRoot(routes, { useHash: true }) under a classic one.
Nested (second-level) routes use children = β¦::class, pointing at another class
annotated with @RoutingModule β Kotlin/Java forbid an annotation referencing its
own type, so children: Array<Route> is impossible; the by-class indirection is how
a Route gains a children: [...] array, and it nests to any depth. The processor
inlines the referenced class's routes into the parent and emits no separate provider
for it. The parent component hosts a nested <router-outlet>:
@RoutingModule(routes = [
Route(path = "", redirectTo = "info", pathMatch = "full"),
Route(path = "info", component = ProductInfoComponent::class),
Route(path = "reviews", component = ProductReviewsComponent::class),
])
class ProductRoutesloadChildren = X::class lazy-loads X, and β exactly like Angular's loadChildren,
which resolves to either an NgModule or a Routes array β what X is picks the form:
Modern (standalone) β point at a @RoutingModule. No @NgModule, no forChild:
@RoutingModule(routes = [
Route(path = "", component = AdminComponent::class), // AdminComponent is standalone
])
class AdminRoutes
// root: Route(path = "admin", loadChildren = AdminRoutes::class)The processor emits a bare routes bundle and
loadChildren: () => import('./AdminRoutes').then(m => m.AdminRoutes) β Angular's
() => ROUTES idiom, today's ng new default.
Classic β point at an @NgModule that imports a plain @RoutingModule. Listing a routing
module in a feature (non-bootstrap) @NgModule's imports is enough β the processor infers
RouterModule.forChild(routes) from that placement (a feature module uses forChild, never
forRoot); there is no flag, just like Angular derives the call from where you write it:
@RoutingModule(routes = [
Route(path = "", component = AdminComponent::class),
])
class AdminRoutingModule
@NgModule(declarations = [AdminComponent::class], imports = [AdminRoutingModule::class])
class AdminModule // imports AdminRoutingModule β it compiles to forChild
// root: Route(path = "admin", loadChildren = AdminModule::class)Either way, keep X out of AppModule's imports so nothing pulls it in eagerly. What
"lazy" means then differs by compilation mode:
- AOT β true code-splitting. The processor emits the
() => import(...).then(...)with no static import, so Angular's toolchain splits the target into a chunk fetched only on the first visit to/admin. - JIT β one webpack bundle, so the target is registered eagerly and
loadChildrenresolves to it with no separate download. Routing behaves lazily (the component is instantiated on navigation); the code is not split.
See app.routing.kt and the two lazy demos
under app/router/lazy (classic LazyFeatureModule
- standalone
LazyStandaloneRoutes).
AngularKt supports two compilation modes, and the task you run picks the mode β any
aot* task builds AOT, everything else builds JIT. There is no mode property to set or
keep in sync.
JIT (the default β any non-aot task) - the Kotlin/JS code is webpack-bundled
together with zone.js + @angular/compiler, and the KSP processor generates
registerAngularKt(), which applies the Angular decorators at runtime. Simplest to
run; ships the compiler in the bundle.
$ ./gradlew :demo:jsBrowserProductionWebpack # optimized production bundle (minified)
$ ./gradlew :demo:jsBrowserDevelopmentRun -t # ...or serve with live reload (.kt/.html/.css) on http://localhost:8080
$ ./gradlew :demo:jsBrowserDevelopmentRun -t -PangularKt.port=9000 # ...on a custom portAOT (any aot* task) - true ahead-of-time compilation by Angular's own
toolchain, so @angular/compiler is not in the runtime bundle. How it works:
:democompiles to an importable ESM library (binaries.library()) plus TypeScript definitions; component/service logic stays 100% Kotlin.- The KSP processor emits one thin TypeScript bridge per
@Component/@Directive/@Injectable/@NgModuleintodemo/build/generated/ksp/.../*.ts. Each bridge carries the real public Angular decorator andextendsthe Kotlin-compiled class; constructor DI becomessuper(inject(Dep), ...). - An Angular workspace is generated into
demo/build/ng-aot(ng new, pinned to the target Angular major - nothing is checked in), wires the Kotlin library in as a localfile:npm dependency, and AOT-compiles the bridges with@angular/build.
The generated main.ts's bootstrap style is decided by aotConfig.bootstrapComponent:
name a standalone root @Component and the entry becomes bootstrapApplication(root, β¦);
leave it unset and the processor falls back to a classic bootstrapModule of your root
@NgModule (the one with a bootstrap = [...] array). This knob is AOT-only β the AOT entry
is generated, so the root's identity has to live somewhere KSP can read it. The JIT entry is
your own hand-written main.kt, where you call bootstrapApplication or
platformBrowserDynamic().bootstrapModule(...) directly, so it needs no equivalent. (Compile
mode and bootstrap style are independent axes; the demo just pairs AOT with standalone and JIT
with classic to showcase both idioms.)
One task drives the whole pipeline - build the library, emit + copy the bridges,
npm install, AOT build:
$ ./gradlew :demo:aotBuild # optimized production bundle, no @angular/compiler
$ ./gradlew :demo:aotServe # ...or serve with live reload on http://localhost:4200
$ ./gradlew :demo:aotServe -PangularKt.port=4300 # ...on a custom portaotBuild chains every stage, but the sub-tasks (aotInit, copyAotBridges,
aotNpmInstall, β¦) can be run on their own β they all carry aot in the name, so the mode
resolves the same way; delete demo/build/ng-aot to force a clean re-scaffold.
Upgrading Angular is one knob - -PangularKt.angularVersion=22 (default 22, floor 16;
a bare major resolves to ~22.0.0). It pins the workspace, npm deps, and bridges together,
so bump-and-rebuild is the whole upgrade.
All issues and pull requests are welcome.
Thank you in advance
This project is licensed under the Apache License, Version 2.0
See the LICENSE.txt file for more details
Copyright 2020-2026 Vlad Kalyuzhnyu
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.