Guides

Sparkling

TikTok's open-source native shell for Lynx, covering containers, scheme routing, the typed native bridge, and where a Vue Lynx app fits into it.

Lynx is built to be embedded, so it ships the renderer and the bundler and leaves the application shell to whatever hosts it. rspeedy build produces a main.lynx.bundle, Lynx Go loads it from a QR code, and the rest is the host's job: the Activity or view controller that holds a LynxView, the mechanism that opens a second page, and every call that crosses from JavaScript into native code. Sparkling is TikTok's shell for that, open-sourced in November 2025 under Apache-2.0 and described in its own documentation as unlocking "Lynx at TikTok scale".

What it actually gives you is the part of a Lynx app that is not the UI. Native SDKs for iOS and Android host the runtime, a scheme URL format identifies pages, a code generator produces the JavaScript-to-native bridge from a TypeScript declaration, and a CLI wraps the build, the dev server, and the simulator launch. The published modules cover routing, key-value storage, and media picking, which are the three things every app needs and nobody enjoys writing twice.

Containers hold the runtime

Sparkling content always runs inside a native container, and there are two kinds because there are two ways to adopt Lynx.

A full-page container is a screen the platform owns: SPKViewController on iOS, SparklingActivity on Android. The native layer manages the navigation bar, the status bar, orientation, and the loading and error views, and it forwards viewDidAppear and onResume into the Lynx runtime for you.

An embedded container is a plain view you place yourself, SPKContainerView or SparklingView, sized and positioned by the surrounding native layout. That is the one that makes brownfield adoption real, because a card, a banner, or one panel of an otherwise native screen can come from a Lynx bundle without the host app surrendering navigation:

MainActivity.kt
val ctx = SparklingContext().apply {
  scheme = "hybrid://lynxview?bundle=card.lynx.bundle"
}
val spkView = Sparkling.build(this, ctx).createView()
container.addView(spkView)
spkView?.loadUrl()

The trade is that the SDK does not own your view controller, so visibility has to be forwarded by hand through handleViewDidAppear and handleViewDidDisappear on iOS, or onShowEvent(), onHideEvent(), and release() on Android. Forgetting release() leaves a runtime alive behind a screen the user has already left.

Schemes are the contract

Both container types are opened by a URL rather than by an API call, which is what lets JavaScript open a native screen without knowing anything about native code:

hybrid://lynxview_page?bundle=detail.lynx.bundle&title=Detail&hide_nav_bar=1

The host selects the container (lynxview_page for a standard Lynx page, lynxview_card for an Android card, webview for a WebView), and the query string configures it. bundle is the only required parameter; the rest are chrome, including title, title_color, nav_bar_color, container_bg_color, hide_nav_bar, hide_status_bar, trans_status_bar, force_theme_style, hide_loading, and hide_error. Anything Sparkling does not recognise is passed through to the page in lynx.__globalProps.queryItems, so custom parameters cost nothing.

Colors must be six-digit hex, because the two platforms disagree about how to read the eight-digit form, and a literal # in a value has to be encoded as %23.

Pages are build entries, not registered routes

Every entry in source.entry becomes a bundle, and every bundle is navigable the moment it exists. There is no route table to keep in sync, which for a multi-page app is a genuine simplification:

app.config.ts
const config = {
  lynxConfig,
  appName: 'MyApp',
  router: {
    main: { path: './src/pages/main' },
    detail: { path: './src/pages/detail' },
  },
  platform: {
    android: { packageName: 'com.example.myapp' },
    ios: { bundleIdentifier: 'com.example.myapp' },
  },
}

Navigation from JavaScript then names a bundle and passes parameters, and the destination reads them back off global props:

import { navigate } from 'sparkling-navigation'

navigate(
  { path: 'pages/detail.lynx.bundle', options: { params: { title: 'Detail', itemId: '123' } } },
  res => console.log(res.code, res.msg),
)

// on the detail page
const { itemId } = lynx.__globalProps.queryItems

Each page is its own container with its own LynxView, so state does not survive a navigation the way it would in a single-page router, and a shared store has to live in storage or in global props rather than in memory.

The method bridge is generated

Custom native calls start as a TypeScript declaration file with no implementation, which is the single source of truth:

src/my-greeting/my-greeting.d.ts
export interface HelloRequest {
  name: string
  style?: 'formal' | 'casual'
}

declare function hello(
  params: HelloRequest,
  callback: (result: HelloResponse) => void,
): void

npx sparkling-method-cli codegen turns that into an abstract Kotlin class, a Swift class, a typed TypeScript wrapper, and a metadata file, leaving you to implement execute on each platform. Interfaces become model classes and union types become enums, so a parameter renamed in the .d.ts breaks both native builds rather than failing silently at runtime, which is the main thing this design buys.

It does not make the native work disappear. Every custom method is still Kotlin plus Swift plus a review of both, so the ceiling on how much of your app can be pure TypeScript is set by how many native capabilities you need.

The development loop

npx sparkling dev starts an Rspeedy dev server on port 5969, chosen because it spells LYNX on a phone keypad, serves one endpoint per entry, and prints a QR code that Sparkling Go or LynxExplorer can scan. npx sparkling run:ios and run:android build the native shell in debug, which loads bundles over HTTP rather than from assets, and saves push hot updates into the running app. npx sparkling doctor checks Node, the JDK, the Android SDK, Ruby, Xcode, CocoaPods, and the simulator before you waste twenty minutes on a toolchain problem, and it is the first command worth running.

Release builds go through npx sparkling build and copy-assets, which drops the bundles into android/app/src/main/assets and ios/LynxResources for the shell to load locally.

What it means for a Vue Lynx app

Sparkling's scaffolder and templates are ReactLynx, and the documentation is written against React throughout. The seam is lower than that, though: app.config.ts wraps an ordinary Rspeedy config under lynxConfig, the container loads a compiled .lynx.bundle, and vue-lynx emits exactly that artifact, so in principle pluginVueLynx goes where pluginReactLynx goes and the native side never knows the difference. I have not shipped a Vue app inside a Sparkling shell, so treat that as the mechanically obvious path rather than a verified one.

Two pieces of Vy UI already assume Sparkling exists. useSafeArea reads topHeight and bottomHeight from lynx.__globalProps, which are Sparkling's names for the iOS safe-area insets, and normalizes them against Lynx Explorer's safeAreaTop and safeAreaBottom with the os field deciding whether the container has already inset the view natively. The same global props object carries theme and preferredTheme for color mode, and accessibleMode, a bitmask whose low bit means VoiceOver or TalkBack is running.

Whether to use it

Sparkling is the easiest path for most Vue Lynx apps. If you need full control or a smaller shell, building your own native host (see our Xcode guide) is equally valid. For most apps, Sparkling's containers, routing, bridge, and dev workflow cover what you need.

The one case where a custom host still wins is a single-screen app with no native bridge methods, where the Sparkling SDK is more weight than the app needs. Past that threshold — multiple pages, embedded containers, or any native calls — Sparkling pays for itself immediately.

The repository has been public since late November 2025 and is under active development, so expect the surface to keep settling. Nothing in the design is React-specific past the templates, and the rough edges a Vue project meets are the same ones a React project meets. The Sparkling documentation covers the full API surface, and is the reference for anything beyond this overview.

Getting started

npm create sparkling-app@latest my-app
cd my-app
npm run run:ios

The scaffolder produces a Lynx app project with Android and iOS native shells already wired for Sparkling, a working JS-to-native method example, and developer workflow commands (dev, build, run:android, run:ios). Add Sparkling's published modules as needed:

npm install sparkling-storage

Node.js 22 or 24 is required. For native targets you need Android Studio / JDK 11 for Android and Xcode 16+ for iOS, plus Ruby (>=3.2.6, <3.4) for the iOS build. npx sparkling doctor checks all of this before you waste time on a toolchain problem.

Testing on device

The QR-code loop with Lynx Go, and the config that has to be right first.

Testing with Xcode

Building a custom iOS host — for apps needing more control or minimal footprint.

rspeedy dev server

The dev URLs underneath sparkling dev, and cycling the QR code.