React Native and Flutter have dominated cross-platform mobile development for years, but a serious new contender has entered the arena. Lynx is the open-source framework built by ByteDance — the same engine that powers surfaces inside TikTok used by hundreds of millions of people daily. Its promise is bold: write once with familiar web technologies (CSS and React), render with truly native UI, and keep interactions smooth thanks to a unique dual-thread architecture.
In this tutorial, you will build a working mobile app with Lynx and ReactLynx (its official React flavor) from scratch: scaffolding the project with Rspeedy, composing UI with Lynx elements, styling with real CSS, managing state, rendering performant lists, fetching remote data, and using main-thread scripting to eliminate interaction lag.
Prerequisites
Before starting, ensure you have:
- Node.js 18+ installed (Node 20 LTS recommended)
- Basic knowledge of React (components, hooks, props)
- Familiarity with TypeScript fundamentals
- A smartphone (iOS or Android) to preview your app with the LynxExplorer app, or a simulator
- A code editor (VS Code recommended)
No prior mobile development experience is required — that is precisely Lynx's selling point.
What You'll Build
You will build DevPulse, a small but complete mobile app that displays a scrollable feed of trending developer tools. Along the way you will learn:
- How the Lynx dual-thread architecture keeps the UI responsive
- How to compose screens from
<view>,<text>,<image>, and<list>elements - How to style with standard CSS — including gradients, transitions, and animations
- How to handle taps and state with React hooks you already know
- How to fetch and render remote data
- How to move interaction-critical code to the main thread with the
'main thread'directive
Understanding the Lynx Architecture
Before writing code, it helps to understand why Lynx feels different from React Native.
Most JavaScript-driven mobile frameworks run all your code on a single JavaScript thread. When that thread is busy reconciling components or crunching data, taps and scrolls stutter. Lynx splits the work across two threads:
- The main thread handles UI rendering, touch gestures, and synchronous visual updates. It runs a lightweight engine called PrimJS.
- The background thread runs your application logic — component code, effects, data fetching, and business rules.
The result: even if your app logic is busy, scrolling and touch feedback stay fluid because the main thread is never blocked by your business code. Lynx also supports Instant First-Frame Rendering (IFR), which produces a visible, non-blank first screen synchronously at launch.
ReactLynx is the React-based programming model on top of this engine. You write components with JSX and hooks, and the framework decides what runs where.
Step 1: Scaffold the Project with Rspeedy
Lynx projects are built with Rspeedy, a Rspack-based build tool that provides fast bundling and hot reload. Create a new project:
npm create rspeedy@latestThe interactive prompt asks a few questions. Choose these answers:
? Project name: dev-pulse
? Select framework: ReactLynx
? Select language: TypeScript
? Select additional tools: (none needed for now)Then install dependencies and start the dev server:
cd dev-pulse
npm install
npm run devThe terminal prints a QR code. To preview the app on a real device:
- Install the LynxExplorer app on your phone (available for iOS simulators and Android as prebuilt binaries from the Lynx website).
- Scan the QR code with LynxExplorer.
- Your app appears on the device, with hot reload active — edit code and watch it update live.
Step 2: Explore the Project Structure
Open the project in your editor. The important files:
dev-pulse/
├── lynx.config.ts # Rspeedy build configuration
├── src/
│ ├── index.tsx # Entry point, mounts the root component
│ ├── App.tsx # Root component
│ └── App.css # Standard CSS styles
├── package.json
└── tsconfig.jsonThe entry point is refreshingly minimal:
// src/index.tsx
import { root } from '@lynx-js/react'
import { App } from './App.js'
root.render(<App />)Notice the import comes from @lynx-js/react, not react. ReactLynx provides the same hooks API (useState, useEffect, useCallback, and friends) tuned for the dual-thread runtime.
Step 3: Compose UI with Lynx Elements
Lynx does not render HTML. Instead, it ships a set of built-in elements that map to native views on each platform:
| Lynx element | Closest web equivalent | Native rendering |
|---|---|---|
<view> | <div> | UIView / ViewGroup |
<text> | <span> / <p> | UILabel / TextView |
<image> | <img> | Native image view |
<scroll-view> | scrollable <div> | Native scroll container |
<list> | virtualized list | Recycling native list |
One rule to remember: all text must live inside a <text> element. Putting raw text directly inside a <view> will not render.
Replace src/App.tsx with a first version of the DevPulse header:
// src/App.tsx
import './App.css'
export function App() {
return (
<view className="page">
<view className="header">
<text className="header-title">DevPulse</text>
<text className="header-subtitle">
Trending developer tools, refreshed daily
</text>
</view>
</view>
)
}Step 4: Style with Real CSS
Unlike React Native's JavaScript style objects, Lynx supports actual CSS files — selectors, custom properties, gradients, transitions, and keyframe animations included. Replace src/App.css:
/* src/App.css */
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #0b1020;
}
.header {
padding: 48px 24px 24px;
background: linear-gradient(135deg, #1e2a5a 0%, #0b1020 100%);
}
.header-title {
font-size: 32px;
font-weight: 700;
color: #ffffff;
}
.header-subtitle {
margin-top: 6px;
font-size: 14px;
color: #8b93b5;
}Save, and hot reload updates the device instantly. Flexbox is the default layout system, and display: flex behaves the way you expect from the web.
A note on units: Lynx supports px plus responsive units like rpx (responsive pixel, relative to screen width), which is handy for consistent sizing across devices.
Step 5: State and Events
Event handlers in Lynx use the bind prefix instead of React DOM's on prefix. The tap event — the mobile equivalent of click — is wired with bindtap. State works exactly like standard React.
Add an interactive category filter to App.tsx:
import { useState } from '@lynx-js/react'
import './App.css'
const CATEGORIES = ['All', 'AI', 'Frontend', 'DevOps'] as const
export function App() {
const [active, setActive] = useState('All')
return (
<view className="page">
<view className="header">
<text className="header-title">DevPulse</text>
<text className="header-subtitle">
Trending developer tools, refreshed daily
</text>
</view>
<view className="filters">
{CATEGORIES.map((cat) => (
<view
key={cat}
className={active === cat ? 'chip chip-active' : 'chip'}
bindtap={() => setActive(cat)}
>
<text className="chip-label">{cat}</text>
</view>
))}
</view>
</view>
)
}Then style the chips:
.filters {
display: flex;
flex-direction: row;
gap: 8px;
padding: 16px 24px;
}
.chip {
padding: 8px 16px;
border-radius: 999px;
background-color: #1a2340;
transition: background-color 0.2s ease;
}
.chip-active {
background-color: #4f6ef7;
}
.chip-label {
font-size: 13px;
color: #ffffff;
}Tap a chip on your device: the highlight moves instantly. Under the hood, your handler runs on the background thread, but Lynx keeps visual feedback snappy — and in Step 8 you will see how to make latency-critical feedback literally instantaneous.
Step 6: Render a Performant Feed with the List Element
For long feeds, <scroll-view> renders everything eagerly — fine for a settings screen, wasteful for a feed. The <list> element gives you native cell recycling, like FlatList in React Native or RecyclerView on Android.
First, define the data model and some seed data. Create src/data.ts:
// src/data.ts
export interface Tool {
id: number
name: string
tagline: string
category: 'AI' | 'Frontend' | 'DevOps'
stars: number
}
export const SEED_TOOLS: Tool[] = [
{ id: 1, name: 'Lynx', tagline: 'Native UI from web tech', category: 'Frontend', stars: 12400 },
{ id: 2, name: 'Rspeedy', tagline: 'Rspack-powered Lynx builds', category: 'Frontend', stars: 3800 },
{ id: 3, name: 'Claude Agent SDK', tagline: 'Build production AI agents', category: 'AI', stars: 9600 },
{ id: 4, name: 'OpenTUI', tagline: 'React for the terminal', category: 'Frontend', stars: 5100 },
{ id: 5, name: 'Kamal', tagline: 'Zero-downtime VPS deploys', category: 'DevOps', stars: 8700 },
{ id: 6, name: 'uv', tagline: 'Blazing-fast Python packaging', category: 'DevOps', stars: 31000 },
]Now create the feed component in src/Feed.tsx:
// src/Feed.tsx
import type { Tool } from './data.js'
interface FeedProps {
tools: Tool[]
}
export function Feed({ tools }: FeedProps) {
return (
<list
className="feed"
list-type="single"
span-count={1}
scroll-orientation="vertical"
>
{tools.map((tool) => (
<list-item
item-key={`tool-${tool.id}`}
key={`tool-${tool.id}`}
>
<view className="card">
<view className="card-row">
<text className="card-name">{tool.name}</text>
<text className="card-stars">★ {tool.stars}</text>
</view>
<text className="card-tagline">{tool.tagline}</text>
<view className="card-badge">
<text className="card-badge-text">{tool.category}</text>
</view>
</view>
</list-item>
))}
</list>
)
}Key details:
- Each child of
<list>must be a<list-item>with a uniqueitem-key— this is what enables recycling. list-type="single"renders a single-column list; grids and waterfall layouts are also supported.
Wire it into App.tsx with the category filter applied:
import { useState } from '@lynx-js/react'
import { Feed } from './Feed.js'
import { SEED_TOOLS } from './data.js'
import './App.css'
const CATEGORIES = ['All', 'AI', 'Frontend', 'DevOps'] as const
export function App() {
const [active, setActive] = useState('All')
const visible =
active === 'All'
? SEED_TOOLS
: SEED_TOOLS.filter((t) => t.category === active)
return (
<view className="page">
{/* header and filters unchanged */}
<view className="filters">
{CATEGORIES.map((cat) => (
<view
key={cat}
className={active === cat ? 'chip chip-active' : 'chip'}
bindtap={() => setActive(cat)}
>
<text className="chip-label">{cat}</text>
</view>
))}
</view>
<Feed tools={visible} />
</view>
)
}Add the card styles:
.feed {
flex: 1;
padding: 0 16px;
}
.card {
margin-bottom: 12px;
padding: 18px;
border-radius: 16px;
background-color: #141c36;
}
.card-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.card-name {
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.card-stars {
font-size: 13px;
color: #f7c948;
}
.card-tagline {
margin-top: 4px;
font-size: 14px;
color: #8b93b5;
}
.card-badge {
margin-top: 12px;
align-self: flex-start;
padding: 4px 10px;
border-radius: 6px;
background-color: #1e2a5a;
}
.card-badge-text {
font-size: 11px;
color: #9db1ff;
}Scroll the feed on your device. Because <list> recycles cells natively, this stays smooth even with thousands of items.
Step 7: Fetch Remote Data
Real apps load data from an API. Lynx provides a fetch API on the background thread, and since your component code already runs there, the pattern looks like everyday React:
// src/App.tsx (data loading portion)
import { useEffect, useState } from '@lynx-js/react'
import type { Tool } from './data.js'
import { SEED_TOOLS } from './data.js'
export function useTools() {
const [tools, setTools] = useState<Tool[]>(SEED_TOOLS)
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
fetch('https://api.example.com/trending-tools')
.then((res) => res.json())
.then((data: Tool[]) => setTools(data))
.catch(() => {
// Keep seed data as a graceful fallback
})
.finally(() => setLoading(false))
}, [])
return { tools, loading }
}Swap SEED_TOOLS in App for the hook's return value and render a lightweight loading state:
const { tools, loading } = useTools()
// inside JSX:
{loading ? (
<view className="loading">
<text className="loading-text">Loading tools…</text>
</view>
) : (
<Feed tools={visible} />
)}Because fetching happens on the background thread, a slow network response never freezes scrolling or taps.
Step 8: Main-Thread Scripting for Instant Feedback
Here is where Lynx genuinely differs from its competitors. Handlers defined normally run on the background thread, which involves an asynchronous hop for each event. For most interactions that is imperceptible, but for gesture-driven UI (drag, swipe, press feedback) you want zero-lag response.
Lynx lets you mark a function to run directly on the main thread with the 'main thread' directive, and bind it with the main-thread: prefix:
import { useMainThreadRef } from '@lynx-js/react'
import type { MainThread } from '@lynx-js/types'
export function PressableCard(props: { children: React.ReactNode }) {
const cardRef = useMainThreadRef<MainThread.Element>(null)
function onPressDown(e: MainThread.TouchEvent) {
'main thread'
cardRef.current?.setStyleProperty('transform', 'scale(0.97)')
}
function onPressUp(e: MainThread.TouchEvent) {
'main thread'
cardRef.current?.setStyleProperty('transform', 'scale(1)')
}
return (
<view
main-thread:ref={cardRef}
main-thread:bindtouchstart={onPressDown}
main-thread:bindtouchend={onPressUp}
className="card"
>
{props.children}
</view>
)
}The press-down scale effect executes synchronously with the touch event — no thread hop, no dropped frames, even if the background thread is busy parsing a large API response. This is the technique behind the "instant" feel of Lynx-powered production apps.
Two rules for main-thread functions:
- They must start with the
'main thread'directive as their first statement. - They can only touch main-thread values (refs created with
useMainThreadRef, event objects, and other main-thread functions) — not your React state directly.
Testing Your Implementation
Verify each capability works before shipping:
- Hot reload: edit
header-titletext and confirm the device updates without restarting. - Filtering: tap each category chip and confirm the feed updates and the active chip highlight moves.
- Scrolling: flick the list rapidly — frame rate should stay smooth thanks to native recycling.
- Fallback data: turn on airplane mode and relaunch; the seed data should render instead of a blank screen.
- Press feedback: press and hold a card — the scale-down should be instantaneous with no perceptible delay.
To produce a release bundle, run:
npm run buildRspeedy outputs an optimized Lynx bundle ready to be loaded by a native host shell (LynxExplorer during development, or your own iOS/Android shell app in production).
Troubleshooting
Text does not appear. Raw strings inside <view> are not rendered. Wrap every string in a <text> element.
QR code scan fails. Ensure your phone and computer are on the same network. If the dev server address is unreachable, pass an explicit host: npm run dev -- --host 192.168.1.x.
List items all look identical or recycle incorrectly. Check that every <list-item> has a unique and stable item-key. Reusing keys breaks the recycler.
'main thread' function throws about accessing values. Main-thread functions cannot close over background-thread state. Pass data through main-thread:ref values or event parameters instead.
Styles silently ignored. Lynx supports a large but not complete CSS subset. Prefer flexbox layout properties; check the official CSS reference when a property has no effect.
Next Steps
- Add a detail screen and explore Lynx's routing options
- Replace the mock endpoint with a real API and add pull-to-refresh with
<scroll-view>events - Explore embedding a Lynx view inside an existing native iOS/Android app — Lynx is designed for incremental adoption
- Compare notes with our Expo React Native tutorial and Tauri 2 desktop tutorial to pick the right cross-platform tool per project
- Building AI features into your mobile app? See our Claude Agent SDK TypeScript guide
Conclusion
You built a complete cross-platform mobile app with Lynx and ReactLynx: scaffolded with Rspeedy, composed from native-rendering elements, styled with genuine CSS, powered by familiar React hooks, and optimized with a recycling list plus main-thread scripting for instant touch feedback.
Lynx's dual-thread architecture is a meaningful rethink of the JavaScript-driven mobile stack — the UI thread is never hostage to your business logic. With ByteDance battle-testing it at TikTok scale and the tooling now open source, Lynx has earned a spot on your shortlist alongside React Native and Flutter for your next mobile project.