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

The alternative is building the host yourself, which is what our Xcode guide walks through, and that remains the right call for a single-screen app or when you need control over the container more than you need a router. Sparkling starts paying once you have several pages, a native app to embed into, or more than one bridge method, because those are the parts that are tedious rather than interesting.

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.

Testing with Xcode

Building the iOS host yourself, and compiling native modules into it.

Testing on your phone

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

The rspeedy dev server

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