Getting started
Install Bullet and configure your dev server, bundler, and entry points
This guide walks you through adding Bullet to a React Native project so your app
runs on Android, iOS, and the web from a single dev server embedded in an
H3 application. By the end, you'll have a working
react-native start command backed by Rspack and streaming React Native Web
SSR.
When you finish, your project's key files look like this:
- application.tsx
- index.tsx
- index.server.tsx
- babel.config.cjs
- react-native.config.cjs
- rspack.config.ts
- server.ts
Install Bullet
Add the Bullet server and configuration packages, along with the H3 and crossws runtime dependencies.
Register the CLI commands
Point the React Native CLI at Bullet's commands so react-native start and
react-native bundle run through Bullet instead of Metro.
module.exports = {
commands: require("@clutchify/bullet-core/command"),
};Configure the bundler
Create rspack.config.ts with four named configurations: client and server
for the web build, and android and ios for the native builds. Each builder
takes an entry point and a configuration name.
import {
createClientConfiguration,
createNativeConfiguration,
createServerConfiguration,
} from "@clutchify/bullet-core";
import { defineConfig } from "@rspack/cli";
export default defineConfig([
createClientConfiguration({ name: "client", entry: "./src/index.tsx" }),
createServerConfiguration({ name: "server", entry: "./src/index.server.tsx" }),
createNativeConfiguration({ name: "android", entry: "./src/index.tsx", platform: "android" }),
createNativeConfiguration({ name: "ios", entry: "./src/index.tsx", platform: "ios" }),
]);Configure Babel
Create babel.config.cjs. The SWC loader passes the target platform through the
Babel caller, so you enable babel-plugin-react-native-web only for the web
build and use the React Native preset everywhere.
module.exports = (api) => {
const platform = api.caller((caller) => caller?.platform);
return {
presets: ["module:@react-native/babel-preset"],
plugins: [
"module:babel-plugin-react-compiler",
platform === "web" && "module:babel-plugin-react-native-web",
].filter(Boolean),
};
};Create the development server
Create server.ts. It mounts bullet() into an H3 app, installs the crossws
server plugin, and maps each environment name to the matching configuration name
from rspack.config.ts. Bullet wires up the React Native DevTools, dev menu,
and inspector sockets internally.
import { bullet } from "@clutchify/bullet-server";
import { plugin as websocketPlugin } from "crossws/server";
import { H3 } from "h3";
import { serve } from "h3/node";
import configuration from "./rspack.config.ts";
const port = Number(process.env.REPACK_PORT ?? 8081);
const host = process.env.REPACK_HOST;
const bulletApplication = await bullet({
configuration,
mode: process.env.NODE_ENV === "production" ? "production" : "development",
environments: {
client: "client",
server: "server",
android: "android",
ios: "ios",
},
development: { port, host },
});
const application = new H3().mount("", bulletApplication);
serve(application, { hostname: host, port, plugins: [websocketPlugin({})] });Create your root component
Both entry points render the same component tree, so define it once in
src/application.tsx. Application is your app's root component, and Document
is the HTML shell used for web rendering.
import type { PropsWithChildren, ReactNode } from "react";
import { Text, View } from "react-native";
export function Application() {
return (
<View>
<Text>Hello from Bullet</Text>
</View>
);
}
export type DocumentProps = PropsWithChildren<{ head?: ReactNode }>;
export function Document({ children, head }: DocumentProps) {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
{head}
</head>
<body>{children}</body>
</html>
);
}Add the client entry
Create src/index.tsx. It registers your app with AppRegistry for native
platforms and hydrates the server-rendered HTML on the web.
import { StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { AppRegistry, Platform } from "react-native";
import { Application, Document } from "./application";
AppRegistry.registerComponent("main", () => Application);
if (Platform.OS === "web") {
const { element } = AppRegistry.getApplication("main");
hydrateRoot(
document,
<StrictMode>
<Document>{element}</Document>
</StrictMode>,
);
}Add the server entry
Create src/index.server.tsx. It uses createServerHandler to render your app
to a stream on the server and injects the asset manifest so the client can
hydrate.
import { createServerHandler } from "@clutchify/bullet-server/entry";
import { StrictMode } from "react";
import { renderToReadableStream } from "react-dom/server";
import { AppRegistry } from "react-native";
import { Application, Document } from "./application";
export default createServerHandler(async ({ manifest }) => {
AppRegistry.registerComponent("main", () => Application);
const { element, getStyleElement } = AppRegistry.getApplication("main", { initialProps: {} });
const stream = await renderToReadableStream(
<StrictMode>
<Document head={<>{getStyleElement()}</>}>{element}</Document>
</StrictMode>,
{
bootstrapScriptContent: `window.assetMap = ${JSON.stringify(manifest)};`,
bootstrapScripts: Object.values(manifest).filter((item) => !item.endsWith(".map")),
},
);
return new Response(stream, { headers: { "content-type": "text/html" } });
});Open the printed URL to see the web build, or launch the app on Android or iOS to load the native bundle.