Thinking in Backtick
What runs on your server, what runs on the phone, and what crosses between them.
A Backtick screen is one file with code for two places: your server and the phone. Three rules say which code runs where:
- A server component runs on your server, for every request.
- A client script,
cs`…`, runs on the phone. - A splice,
$name, is the only way a value crosses from one to the other.
One screen, both sides
import { cs } from "@backtickjs/core";import { useState } from "@backtickjs/react";import { Pressable, ScrollView, Text } from "@backtickjs/react-native";import { db, type Order, type User } from "./db.js";// A client component: it runs on the phone, with state of its own.const ReorderButton = cs`(props: { order: Order }) => {const [added, setAdded] = $useState(false);return (<$Pressable onPress={() => setAdded(true)}><$Text>{added ? "Added ✓" : "Reorder " + props.order.name}</$Text></$Pressable>);}`;// A server component: it runs on your server, for every request.export async function Home({ user }: { user: User }) {const usual = await db.usualOrder(user.id);return cs`(<$ScrollView><$Text>Good morning, {$user.name}</$Text><$ReorderButton order={$usual} /></$ScrollView>)`;}
Read it by where each part runs:
- On your server:
Home, andawait db.usualOrder(...). It reads its data where the data lives. Queries, secrets and server-only packages stay on your server. - Crossing as data:
$userand$usual. Each splice is a valueHomehas, written into the screen. In the script, it has its server type. - On the phone: the script
Homereturns, andReorderButton. They draw the screen, keep state and handle taps.
What the phone receives
For a request from Sam, the server sends this bundle. It's generated from the screen above by the docs' tests, so it's exactly what the bundler writes:
"use strict";const { ScrollView: $i0 } = require("react-native");const { Text: $i1 } = require("react-native");const { useState: $i2 } = require("react");const { Pressable: $i3 } = require("react-native");const $module0 = require("react/jsx-runtime");const $modules = {"25wlsdb7ez0q4:20:9": (module, exports, require) => {"use strict";Object.defineProperty(exports, "__esModule", { value: true });const jsx_runtime_1 = require("react/jsx-runtime");exports.default = ($splice0, $splice1, $splice2, $splice3, $splice4) => ($ScrollView => /*#__PURE__*/ (0, jsx_runtime_1.jsxs)($ScrollView, {children: [($Text => /*#__PURE__*/ (0, jsx_runtime_1.jsxs)($Text, {children: ["Good morning, ", $splice2().name]}))($splice1()), ($ReorderButton => /*#__PURE__*/ (0, jsx_runtime_1.jsx)($ReorderButton, {order: $splice4()}))($splice3())]}))($splice0());},"25wlsdb7ez0q4:7:22": (module, exports, require) => {"use strict";Object.defineProperty(exports, "__esModule", { value: true });const jsx_runtime_1 = require("react/jsx-runtime");exports.default = ($splice0, $splice1, $splice2) => props => {const [added, setAdded] = $splice0()(false);return ($Pressable => /*#__PURE__*/ (0, jsx_runtime_1.jsx)($Pressable, {onPress: () => setAdded(true),children: ($Text => /*#__PURE__*/ (0, jsx_runtime_1.jsx)($Text, {children: added ? "Added ✓" : "Reorder " + props.order.name}))($splice2())}))($splice1());};},};const $exports = {"react/jsx-runtime": $module0,};const $require = (id) => {if (!(id in $exports)) {const module = { exports: {} };$exports[id] = module.exports;$modules[id](module, module.exports, $require);$exports[id] = module.exports;}return $exports[id];};const $cs0 = $require("25wlsdb7ez0q4:20:9").default;const $cs1 = $require("25wlsdb7ez0q4:7:22").default;module.exports = ($cs0(() => ($i0), () => ($i1), () => ({ id: "u-7", name: "Sam" }), () => ($cs1(() => ($i2), () => ($i3), () => ($i1))), () => ({ id: "o-1042", name: "Flat white" })));
- The scripts are compiled to plain JavaScript, one module each. Their JSX
is React's
jsxcalls. - The splices are the last line: Sam's name and order, as data. Nothing of
Homeis there, anddbisn't either: the phone gets results, never the code that produced them. - React and React Native come from the app through
require. The app'sevaluateprovides its own copies, so a screen needs nothing the app doesn't ship.
What can cross
A splice can carry what can be written as data, and scripts:
- Strings, finite numbers, booleans,
nullandundefined - Plain objects and arrays of those
- Scripts:
cs`…`values, client components and client functions - What the adapters export:
$View,$useStateand the rest
Functions and class instances can't cross, because they're code and state on your server. TypeScript stops them where you write the splice:
import { cs } from "@backtickjs/core";import { Pressable, Text } from "@backtickjs/react-native";export async function Signup() {const opensAt = new Date(2026, 9, 6);const track = () => console.log("signup");return cs`(// @ts-expect-error: a function on your server can't cross to the phone.<$Pressable onPress={$track}>{/* @ts-expect-error: nor can a class instance, a Date included. */}<$Text>Opens {$opensAt.toDateString()}</$Text></$Pressable>)`;}
Argument of type '() => void' is not assignable to parameter of type 'Spliceable'.Argument of type 'Date' is not assignable to parameter of type 'Spliceable'.
The fix is to cross what each one is for: the date as a string, and the handler as a client function, which runs on the phone.
import { cs } from "@backtickjs/core";import { Pressable, Text } from "@backtickjs/react-native";export async function Signup() {const opensAt = new Date(2026, 9, 6).toDateString();const track = cs`() => console.log("signup")`;return cs`(<$Pressable onPress={$track}><$Text>Opens {$opensAt}</$Text></$Pressable>)`;}
Optional values
Each $offer in a script is its own splice, so TypeScript doesn't narrow one
by checking another. In {$offer && <$Banner offer={$offer} />}, the second
$offer could still be null as far as TypeScript knows. Read the splice
once into a constant, and check that:
import { cs } from "@backtickjs/core";import { Text } from "@backtickjs/react-native";type Offer = { title: string };// Not every user has an offer.async function offerFor(userId: string): Promise<Offer | null> {return userId === "u-7" ? { title: "2 for 1 cold brew" } : null;}const Banner = cs`(props: { offer: Offer }) => (<$Text>{props.offer.title}</$Text>)`;export async function Promo({ userId }: { userId: string }) {const offer = await offerFor(userId);return cs`{const offer = $offer;return offer && <$Banner offer={offer} />;}`;}
Where code goes
| You need | Write it as |
|---|---|
| Data from a database or an API, secrets, server-only packages | Code in a server component |
| State, effects, taps, animation, device APIs | Code in a client script |
| A server value on the phone | A splice: $name, or ${expression} |
| A piece of UI with its own props and state | A client component: cs`(props) => …`, used as <$Name /> |
| A function the phone calls | A client function: cs`(n: number) => …` |
| A piece of UI built on your server, inside a script | A server component, spliced: {${<Name />}} |
How a screen gets to the phone
Your server bundles a screen when the app asks for it:
import { createServer } from "node:http";import { bundler } from "@backtickjs/bundler";import { db } from "./db.js";import { Home } from "./Home.js";// The versions of React and React Native your app ships.const packageVersions = { react: "19.2.3", "react-native": "0.86.3" };createServer(async (request, response) => {if (request.url === "/screens/home") {const user = await db.userFor(request.headers.authorization);const bundle = await bundler.build({input: <Home user={user} />,packageVersions,});const { code } = bundle.generate({ format: "cjs" });response.setHeader("content-type", "text/javascript");response.end(code);return;}response.statusCode = 404;response.end();}).listen(3000);
Your app fetches the bundle, runs it with its own React and React Native, and draws what it returns:
import { evaluate } from "@backtickjs/react-native-client";import * as React from "react";import { Suspense, use, useState } from "react";import * as JSXRuntime from "react/jsx-runtime";import * as ReactNative from "react-native";import { ActivityIndicator } from "react-native";// What a screen may require: the packages your app is built with.const modules = {react: React,"react/jsx-runtime": JSXRuntime,"react-native": ReactNative,};async function fetchScreen(url: string): Promise<React.ReactNode> {const response = await fetch(url);return evaluate(await response.text(), modules) as React.ReactNode;}export default function App() {const [screen] = useState(() =>fetchScreen("https://api.example.com/screens/home"),);return (<Suspense fallback={<ActivityIndicator />}><Screen screen={screen} /></Suspense>);}function Screen({ screen }: { screen: Promise<React.ReactNode> }) {return use(screen);}
Both are what create-backtick-app sets up, without its development extras.