Guides

Testing on your phone

How to run a Vue Lynx app on a real iPhone or Android device with Lynx Go, and where the QR-code workflow stops being enough.

The browser preview rspeedy prints is the one target where Lynx behaves least like itself. Gestures run through mouse events rather than touch, accessibility-* properties are ignored, and animations go through the web runtime. I do not believe anything I have only seen in a browser, and the fastest way onto real hardware is a QR code.

Lynx Go

Lynx Go embeds the Lynx runtime and loads a bundle from a URL, so you get your app onto a real device without building anything native. It ships as Lynx Go — Dev Explorer on the App Store and on Google Play, and it scans the QR code your dev server prints. The Lynx team publishes LynxExplorer builds on the lynx-family/lynx releases page that do the same job, and I use Lynx Go because it installs from a store rather than a sideloaded .ipa.

Wiring up the QR code

@lynx-js/qrcode-rsbuild-plugin prints a scannable code whenever rspeedy dev starts. Add it alongside the Vue plugin:

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy'
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
import { pluginVueLynx } from 'vue-lynx/plugin'

export default defineConfig({
  environments: {
    lynx: {},
    web: {},
  },
  source: {
    entry: { main: './src/index.ts' },
  },
  plugins: [
    pluginQRCode({
      schema(url) {
        return `${url}?fullscreen=true`
      },
    }),
    pluginVueLynx({
      includeWorkletPackages: [/@vyui\//],
    }),
  ],
})

Both environments have to be there, because lynx emits main.lynx.bundle, which is what the QR code points at and Lynx Go loads, while web emits main.web.bundle for the browser preview. Dropping web costs the browser shell, and dropping lynx leaves the QR code pointing at nothing.

Run pnpm dev and scan the code, and saves reload the app in place. The dev server guide covers the terminal shortcuts that cycle the code between entries and URL schemas.

Three things that break before anything renders

A first device run usually fails in one of three ways, all of them build configuration the browser preview forgives.

Gestures crash the main thread. Dragging a VySortable or a VySwipeAction throws TypeError: cannot read property 'bind' of undefined, because vue-lynx's main-thread loader skips node_modules unless you allowlist the package, so the worklets inside @vyui/core never register. The includeWorkletPackages entry above fixes it, and npx @vyui/cli check catches it before you reach a device.

Components render as unstyled boxes. Tailwind rebuilds its content list from the bundled module graph, and the common configuration excludes all of node_modules, which drops every class string inside @vyui/kit. Let the Vy UI packages back through, and see Tailwind on Lynx for the version constraint behind this:

lynx.config.ts
pluginTailwindCSS({
  config: 'tailwind.config.ts',
  exclude: [/[\\/]node_modules[\\/](?!.*@vyui)/],
})

Colors collapse to white and black. Lynx's native CSS engine resolves a single level of var() indirection, so a semantic token pointing at another token evaluates to nothing on device while working in the browser. CSS inheritance and inline variables make the --ui-* token layer resolve natively:

lynx.config.ts
pluginVueLynx({
  enableCSSInheritance: true,
  enableCSSInlineVariables: true,
  includeWorkletPackages: [/@vyui\//],
})

Fonts

Custom fonts loaded with url(...) do not work in a native bundle, because rspeedy rewrites the reference to webpack:///static/font/… and native Lynx cannot fetch that scheme, so the text falls back to a system face without logging anything. Inlining the font as a data URI keeps it inside the bundle and costs one config change:

lynx.config.ts
output: {
  dataUriLimit: {
    font: Number.MAX_SAFE_INTEGER,
  },
}

The alternative registers the font in a host application and references it as local("Family Name"), which is smaller and rules out Lynx Go, since you cannot add resources to an app you did not build.

Reaching a local API

Lynx Go loads your bundle over the network, so localhost inside that bundle means the phone rather than your Mac. Native bundles also have no location global, so the code cannot infer the dev machine's address, and inlining the LAN IP at build time is the least painful fix I have found:

lynx.config.ts
import { networkInterfaces } from 'node:os'

function lanHost(): string {
  for (const nets of Object.values(networkInterfaces())) {
    for (const net of nets ?? []) {
      if (net.family === 'IPv4' && !net.internal) return net.address
    }
  }
  return 'localhost'
}

export default defineConfig({
  source: {
    define: {
      'import.meta.env.PUBLIC_DEV_LAN_HOST': JSON.stringify(lanHost()),
    },
  },
})

Your API client then reads import.meta.env.PUBLIC_DEV_LAN_HOST instead of assuming localhost, and changing networks means restarting the dev server, because the value is baked in at build time.

Where Lynx Go runs out

Lynx Go is a fixed application, so it contains exactly the native modules its authors compiled into it. Camera capture, biometrics, push notifications, secure keychain storage, and any third-party SDK are out of reach, along with UIAppFonts, permission strings in Info.plist, URL scheme handling, and app-level entitlements. All of that lives in a host application you control, and the Xcode guide walks through building one.

Lynx Go stays the right tool for layout, styling, animation, gesture feel, and anything else that only needs the runtime. I keep using it for the fast loop long after a custom host exists.

Testing with Xcode

Build an iOS host app that loads your bundle and compiles in native modules.

Installation

The full Vy UI setup, including Tailwind and worklet configuration.

What is Vue Lynx

The Vue renderer for Lynx and what changes in your code.