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:

  1. A server component runs on your server, for every request.
  2. A client script, cs`…`, runs on the phone.
  3. A splice, $name, is the only way a value crosses from one to the other.

One screen, both sides

server/Home.tsx
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, and await db.usualOrder(...). It reads its data where the data lives. Queries, secrets and server-only packages stay on your server.
  • Crossing as data: $user and $usual. Each splice is a value Home has, written into the screen. In the script, it has its server type.
  • On the phone: the script Home returns, and ReorderButton. 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:

server/Home.bundle.js
"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 jsx calls.
  • The splices are the last line: Sam's name and order, as data. Nothing of Home is there, and db isn't either: the phone gets results, never the code that produced them.
  • React and React Native come from the app through require. The app's evaluate provides 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, null and undefined
  • Plain objects and arrays of those
  • Scripts: cs`…` values, client components and client functions
  • What the adapters export: $View, $useState and 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:

server/Signup.tsx
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.

server/Signup.tsx
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:

server/Promo.tsx
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:

server/index.tsx
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:

App.tsx
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.