npx create-bini-app@latest my-app
cd my-app
npm run devThat's it - http://localhost:3000 opens automatically. Dependencies are installed during scaffolding (pass --no-install to skip that and run npm install yourself), and a git repository is initialized unless you pass --no-git, git is not installed, or the folder is already inside a repository.
Most React starters give you a bundler and call it a day. Bini.js gives you a framework: file-based routing, a backend, environment handling, a native app story, and a deployment CLI, wired together and configured correctly from the first commit.
- One codebase, every target. The same routes, API handlers, and components compile to a web app, a desktop binary, or a mobile app. Nothing is emulated or wrapped - desktop and mobile builds are real Tauri apps.
- Convention over configuration. Drop a file in
src/app/, get a route. Drop a file insrc/app/api/, get an endpoint. No router config, no manual code splitting, no boilerplate. - Fast by default. Vite + Rolldown for bundling, Oxlint + Oxfmt for linting and formatting - all Rust-based, all pre-configured.
Framework
- File-based routing with nested layouts, dynamic segments, and per-route metadata
.mdxand.mdcontent routes work out of the box alongside.tsx/.jsx- no extra setup- Folder-scoped
loading.tsx,not-found.tsx, anderror.tsx- the nearest one up the folder tree wins, same mental model as layouts - Automatic code splitting - every route is lazy-loaded with no
React.lazy()boilerplate - Auto-imports for common React, React Router, and env hooks - zero import statements needed
Backend
- API routes powered by Hono - plain functions or full Hono apps, colocated in
src/app/api/ - One handler, every runtime: Vite dev middleware,
bini-server, or abini-deploy-generated edge function - no code changes required between them - Environment variable loading with startup banners, across Node.js, Bun, Deno, and edge runtimes
Native
- Desktop (Windows, macOS, Linux), Android, and iOS builds via Tauri from the same project (all require a local Rust toolchain, since Tauri compiles a native Rust binary)
- Add or remove a desktop or mobile target on an existing project at any time with
create-bini-app addandcreate-bini-app remove - Native plugin wiring generated automatically from the web APIs you actually call
- Bundle identifier, app name, and app icons set up at scaffold time; code signing for Windows, macOS, Linux, Android, and iOS set up then or later with
create-bini-app sign, interactively or with a single command and no prompts, either on this machine or only for GitHub Actions (--ci-only) - Build every native target on GitHub Actions with
npm run deploy, from any OS (no Mac needed for iOS)
Developer Experience
- Animated dev overlay with Shiki-highlighted error panels and clickable stack frames
- TypeScript or JavaScript, Tailwind CSS v4, CSS Modules, or no styling - your choice at scaffold time
- Oxlint + Oxfmt - 50-100x faster than ESLint and Prettier, pre-configured out of the box
- GitHub Codespaces support in the generated
vite.config(binds to0.0.0.0, uses polling, and routes HMR over port 443) @/import alias pointing atsrc/- Built-in maintenance commands:
doctor,clean, andupgrade - Every prompt can be skipped with a flag, and
--yesaccepts every default
Deployment
bini-deploy- a bundled CLI (npm run deploy) that generates hosting config for web targets, or GitHub Actions build workflows for desktop and mobile targets, and pushes the project to GitHub- Static Site Generation via
bini-ssg- every route, static or dynamic, is pre-rendered to static HTML at build time (see Build & Static Site Generation) - Hosting config generation for Netlify Edge Functions, Vercel, Cloudflare Workers, Node.js, and Deno
| Node.js | >= 20.19.0 (required by Vite 8; the CLI checks this on startup) |
npx create-bini-app@latest # interactive
npx create-bini-app@latest my-app # skip the name prompt
npx create-bini-app@latest my-app --typescript --tailwind --platform web,desktop
npx create-bini-app@latest my-app --yes --typescript --tailwind --platform web,android --org com.acme # no prompts at all| Command | Description |
|---|---|
create-bini-app [name] |
Create a new project. Run with no arguments inside an existing Bini project, it offers to add a platform instead |
create-bini-app add [targets] |
Add desktop and/or mobile targets to an existing project. With no targets, pick from a list |
create-bini-app remove [targets] |
Remove desktop and/or mobile targets from the project. With no targets, pick from a list |
create-bini-app sign [targets] |
Set up or redo code signing (prompts) |
create-bini-app sign <target> <file> <password> |
Set up code signing without prompts (see Code Signing) |
create-bini-app --app-name [name] |
Inside a project: change the native app name. Prompts when no value is given |
create-bini-app --org [domain] |
Inside a project: change the bundle ID prefix. Prompts when no value is given |
create-bini-app doctor |
Check your machine for Tauri (runs tauri info) |
create-bini-app clean |
Delete build output (Vite and Tauri), keeps node_modules |
create-bini-app upgrade |
Update dependencies to the latest versions and copy the latest template files |
add, remove, sign, doctor, clean, and upgrade must be run inside a Bini.js project. The CLI looks for a package.json with bini-router in the current folder or up to five parent folders. --platform <targets> is accepted as an alias for listing targets after add, remove, and sign; inside a project, create-bini-app --platform android on its own is the same as create-bini-app add android. create-bini-app desktop sign is shorthand for create-bini-app sign desktop (it works for any sign target, or a comma-separated list of them).
Project names may contain lowercase letters, numbers, hyphens, and dots (max 50 characters). They cannot start with . or -, cannot be npm or node, and cannot be a Windows reserved name such as CON, NUL, or COM1. The command names above are reserved.
| Flag | Description |
|---|---|
--typescript |
Use TypeScript (create) |
--javascript / --no-typescript |
Use JavaScript (create) |
--tailwind / --css-modules / --none |
Styling (create). Only one may be used |
--platform <targets> |
web (default) · desktop · android · ios. Comma-separated to pick several, e.g. web,desktop,android. desktop covers Windows, macOS, and Linux |
--app-name [name] |
App name (default: the project name in title case). It becomes the initial site title in src/app/layout.tsx and, for native targets, the Tauri productName and window title. With no value, you are prompted for it. Inside an existing project it renames the native app (see Changing the app name or bundle ID) |
--org [domain] |
Bundle ID prefix as a reverse domain, e.g. com.acme (default com.bini). Lowercase letters and numbers in dot-separated parts, at least two parts, each part starting with a letter, max 80 characters. With no value, you are prompted for it. Inside an existing project it changes the bundle identifier |
--sign / --nosign |
Auto-confirm (you are still prompted for details) or skip code-signing setup. With --sign on an already configured target, only the signing step runs. The two cannot be combined, and --nosign is rejected by the sign command |
--npm / --pnpm / --yarn / --bun |
Force a package manager instead of auto-detecting. Only one may be used, and it must be on your PATH |
--no-install |
Skip installing dependencies (they install automatically by default) |
--no-git |
Skip git init (create) |
--force |
Create: overwrite an existing directory. With sign --generate --ci-only: replace signing values that are already saved |
--yes, -y |
No prompts: accept the default for every question not answered by a flag, and skip every confirmation (create, add, remove, bundle ID change). Signing questions are skipped too - use sign <target> <file> <password> for those |
--version, -v / --help, -h |
Print version / show help |
The sign command has its own options (--alias, --profile, --team, --generate, --ci-only, and others). They are listed under Code Signing and are rejected by every other command.
--typescript/--javascript/--tailwind/--css-modules/--none are ignored (with a warning) in add mode, since an existing project keeps its settings.
When run in a terminal, and when the matching flag is not given, create mode asks for: the project name, TypeScript or JavaScript, the styling solution, and the platforms to include. When a desktop or mobile target is picked it also asks for the app name and the organization used for the bundle identifier (each is skipped when you pass --app-name "X" or --org com.acme). iOS is only offered on macOS; if you pass --platform ios on another system it is dropped with a warning.
--app-name and --org can also be given without a value to force their prompt. For example create-bini-app my-app --platform android --app-name asks for the app name while creating the project, and add android --org asks for the organization while adding. A bare flag is one that comes last, or is followed by another flag or a command name (--app-name add android works; --app-name my-app treats my-app as the value). Without a terminal, a bare flag falls back to the default with a warning. For web-only projects the two prompts are skipped (with a notice) because there is no native app to name.
In non-interactive environments (CI, no TTY) and with --yes, prompts are skipped and defaults apply: TypeScript, Tailwind, web platform, com.bini as the organization, and no code signing. Flags always override the defaults. A project name is required in non-interactive mode.
The package manager is picked in this order:
- Your flag (
--npm,--pnpm,--yarnor--bun). It must be on yourPATH. - For commands run inside an existing project (
add,remove,sign,doctor,upgrade, and the rename commands), the project's lockfile:bun.lockborbun.lock, thenpnpm-lock.yaml,yarn.lock,package-lock.json. - The tool that launched the CLI (
npx,pnpm dlx,yarn dlx,bunx). Creating a new project has no lockfile yet, so this is what decides there. - npm.
The CLI does not probe your PATH for installed package managers. If you run it directly (for example from a global install) to create a project and want something other than npm, pass the flag.
Commands exit with 0 on success and 1 on a fatal error. Cancelling with Ctrl+C or a prompt exits with 130, and SIGTERM exits with 143.
add, sign, remove, upgrade, doctor, and the rename commands exit with 2 when they finished but something needs your attention: a manual follow-up (for example a toolchain that must be fixed before tauri android init can run), signing that is still missing, a failed update step, or tauri info failing. add and the rename commands list the follow-ups at the end of their output. Creating a project does not use exit code 2: warnings and failed setup steps (a failed Tauri init, for example) are printed as they happen, and the command still exits with 0.
Every project works as a web app. Desktop or mobile targets can be added at any time from inside an existing project with the add command. Nothing is recreated, and vite.config, src/main.tsx, and src/app/* are never touched.
npx create-bini-app@latest add android # one target
npx create-bini-app@latest add desktop android # several at once (comma-separated also works)
npx create-bini-app@latest add android --sign # set up code signing right awayThe first native target adds src-tauri/, the native scripts, and the @tauri-apps/api, @tauri-apps/cli, cross-env, and bini-native packages. Existing scripts and versions in your package.json are never overwritten. Dependencies are installed afterwards unless you pass --no-install. All desktop operating systems share one src-tauri/, so adding desktop again only offers code signing. Re-running a command is safe: anything already configured is skipped.
Run add with no targets inside a project to pick from a list. Platforms that are already added are listed above the picker as unselectable, and only the missing ones can be chosen. iOS is only offered on macOS; on Windows and Linux, add ios prints how to build it with npm run deploy instead.
When Tauri is set up for the first time, add asks for the app name and the organization in a terminal (defaults: the project name in title case, and com.bini). Pass --app-name "X" and/or --org com.acme to skip those prompts; with --yes or without a terminal, the flags or the defaults are used. src/app/layout.tsx is not read. If a native target already exists, --app-name and --org are not applied by add; use the commands in Changing the app name or bundle ID instead.
When it finishes, add prints a summary of what was added or skipped, the signing status per target, any manual follow-ups, and the commands that apply.
Commit before you run it so you can review the diff with git diff; the CLI warns if your working tree is dirty.
Your project's README.md is written when the project is created, and it documents every platform. add and remove never edit it, but upgrade does overwrite it (see Maintenance commands).
npx create-bini-app@latest remove android
npx create-bini-app@latest remove desktop android ios --yes # remove everything nativeremove lists what it will delete and asks for confirmation (skip with --yes; without a terminal, --yes is required). Android and iOS delete their generated folders (src-tauri/gen/android, src-tauri/gen/apple), and the scripts for that platform are removed from package.json. desktop shares src-tauri/ with the mobile targets, so it can only be removed together with all of them; asking for desktop while a mobile target would remain prints a warning and skips it. Removing everything deletes all of src-tauri/ (including any Rust code you added), plus the native scripts and the Tauri packages (@tauri-apps/api, @tauri-apps/cli, cross-env, bini-native) from package.json. Scripts are removed only if you haven't changed them. keystore.jks and .env.signing are left in place. web cannot be removed.
The native app name and bundle identifier are set once, when Tauri is first set up, and live in src-tauri/tauri.conf.json. The title in src/app/layout.tsx is web-only and never changes them. To change them later, run from inside the project (with no subcommand and no --platform):
npx create-bini-app@latest --app-name # prompt for a new app name
npx create-bini-app@latest --org # prompt for a new bundle ID prefix
npx create-bini-app@latest --app-name "My App" --org com.acme # apply directly, no prompts--app-nameupdatesproductNameand the first window'stitle. For Android it also updatesapp_nameinstrings.xmlwhen it can be found. The prompt's default is the current name.--orgrebuilds the identifier as<org>.<project>(project name reduced to lowercase letters and numbers, for examplecom.acme.myapp). The prompt's default is the org parsed from the current identifier. The CLI warns that stores treat a new identifier as a different app and asks for confirmation (skip with--yes; without a terminal,--yesis required).- The generated
src-tauri/gen/androidandsrc-tauri/gen/appleprojects keep their old values (the iOS display name, and the bundle identifier on both). The CLI lists these as follow-ups; remove and re-add the platform to regenerate it (commit first), or edit it by hand. - Prompts need an interactive terminal; in CI pass a value instead. The command does nothing if the project has no desktop or mobile target, and exits with code
2when follow-ups remain.
These also exist as scripts in every project (npm run clean, npm run upgrade), and sign and doctor as well once a native target is added. None of them asks for confirmation.
cleandeletesdist/,dist-ssr/, and the generated Android and iOS build folders (src-tauri/gen/android/app/build,src-tauri/gen/android/build,src-tauri/gen/android/.gradle,src-tauri/gen/apple/build,src-tauri/gen/apple/Externals). Ifsrc-tauri/targetexists, it also clears the Rust build cache withcargo clean(or deletes the folder ifcargois unavailable), so the next native build is slow.node_modulesis kept.upgrademoves every registry dependency inpackage.jsonto its latest version with your package manager (file:,link:,workspace:,portal:,patch:, git, URL, andnpm:specs are left alone), runscargo updateifsrc-tauri/Cargo.tomlexists, then copies the latest template files into the project.src/,package.json, andvite.config.*are never overwritten by the template step, but every other file that comes from the template (for exampleREADME.md,index.html, and the lint and format configs) is replaced with the template version, so keep your own changes to those files in git. It also moves an iOSdevelopmentTeamleft intauri.conf.jsonby older versions into the git-ignoredsrc-tauri/tauri.ios.conf.json. Commit first; the CLI warns if your working tree is dirty.doctorrunstauri infoto check Rust, the platform toolchains, and versions. In a project without a native target it just tells you how to add one.
my-app/
├── src/
│ ├── app/
│ │ ├── api/ ← API route handlers
│ │ │ └── hello.ts → /api/hello
│ │ ├── layout.tsx ← Root layout + metadata
│ │ ├── page.tsx ← /
│ │ ├── not-found.tsx ← Custom 404 (optional, folder-scoped)
│ │ ├── loading.tsx ← Custom loading screen (optional, folder-scoped)
│ │ ├── error.tsx ← Custom error boundary (optional, folder-scoped)
│ │ └── globals.css
│ ├── main.tsx ← Entry point + SSG render() export
│ └── App.tsx ← Generated by bini-router - don't edit
├── public/
│ ├── favicon.ico
│ ├── apple-touch-icon.png
│ ├── og-image.png
│ ├── logo.png ← Source icon for native app icons
│ ├── bini.svg ← Logo used by the starter page
│ └── site.webmanifest
├── src-tauri/ ← Native targets only (desktop / Android / iOS)
├── index.html
├── tsconfig.json ← TypeScript projects only
├── .oxlintrc.json
├── .oxfmtrc.json
├── .gitignore
├── README.md ← Project README covering every platform
├── vite.config.ts
└── package.json
not-found.tsx, loading.tsx, and error.tsx aren't scaffolded by default, but bini-router picks them up anywhere in src/app/ if you add them - see Routing. JavaScript projects use .jsx/.js and have no tsconfig.json. With CSS Modules, the starter page also gets src/app/page.module.css. With no styling (--none), the starter page styles live in src/app/globals.css. If an image in public/ is missing from the CLI package, it is skipped with a warning.
| Command | Description |
|---|---|
dev |
Start the Vite dev server with HMR and open the browser (vite --host --open) |
build |
Type-check (TypeScript), build the client and SSR bundles, then pre-render every route to static HTML |
start |
Serve the production build via bini-server |
preview |
Preview the production build |
type-check |
tsc --noEmit (JavaScript projects print a notice instead) |
lint / format / check |
Oxlint / Oxfmt / both + type-check (check skips tsc in JavaScript projects) |
clean |
Delete build output and caches (see Maintenance commands) |
upgrade |
Update every dependency to its latest version and refresh the template files |
deploy |
Run bini-deploy - generate hosting config (web) or GitHub Actions build workflows (native), and push to GitHub |
tauri:dev / tauri:build / tauri:icon (desktop) |
Run the desktop app, build it, or regenerate all icons from public/logo.png |
android / android:build (Android) |
Run or build the Android app |
ios / ios:build (iOS, macOS only) |
Run or build the iOS app |
sign / doctor (any native target) |
Set up code signing / check your machine for Tauri |
tauri (any native target) |
Pass-through to the Tauri CLI, e.g. npm run tauri -- icon public/logo.png |
dev:native (any native target) |
Plain vite, used by Tauri's beforeDevCommand so no browser tab opens |
The native scripts (everything from tauri:dev down) are added together when the first native target is added, whichever target that is, and existing scripts with the same name are kept. The desktop, Android, and iOS scripts only work once that platform exists. deploy is present in every scaffold, regardless of platform - see Deployment for what it does. See Platforms for the command and requirement breakdown per target.
bini-router maps files in src/app/ to URLs. No router config, no manual React.lazy() - every route is code-split automatically.
src/app/
page.tsx → /
about.tsx → /about
about.mdx → /about (MDX also works for pages/content routes)
dashboard/
layout.tsx wraps /dashboard and its children
page.tsx → /dashboard
[id]/page.tsx → /dashboard/:id
not-found.tsx custom 404 (optional, folder-scoped)
loading.tsx custom loading screen (optional, folder-scoped)
error.tsx custom error boundary (optional, folder-scoped)
// src/app/dashboard/[id]/page.tsx - useParams is auto-imported, no import needed
export default function Dashboard() {
const { id } = useParams();
const [count, setCount] = useState(0);
return <h1>Dashboard {id}: {count}</h1>;
}Layouts - root and nested - render children through <Outlet />:
// src/app/dashboard/layout.tsx
export default function DashboardLayout() {
return (
<div>
<aside>Sidebar</aside>
<main><Outlet /></main>
</div>
);
}Export metadata from any layout to control <title>, Open Graph, Twitter cards, and icons. Root metadata is injected into index.html at build time; nested metadata updates document.title at runtime. It never ships to the client bundle. The scaffolded root layout sets title (plus the Open Graph and Twitter site names) to your app name. This is web-only: the desktop and mobile app name lives in src-tauri/tauri.conf.json. The scaffolded layout also adds a JSON-LD WebSite script.
export const metadata = {
applicationName: 'Bini.js',
title: 'Dashboard',
description: 'Your personal dashboard',
openGraph: {
siteName: 'Bini.js',
images: [{ url: '/og-image.png', width: 1200, height: 630 }],
},
};Layouts with an
<html>tag, or without a default export, are excluded from the route chain automatically.layout.tsx,not-found.tsx,loading.tsx, anderror.tsxmust be.tsx/.jsx/.ts/.js- MDX isn't supported for these, since they define app structure rather than content.pagefiles and other flat/content routes support.mdx/.mdin addition.
loading.tsx, not-found.tsx, and error.tsx all use the same "nearest wins" resolution as layouts: a file in a subfolder only affects that subfolder, and shadows (without deleting) the same file in any ancestor folder. A route without a closer match falls through to the nearest ancestor that has one, all the way up to the root, and to a built-in default if nothing exists anywhere.
// src/app/dashboard/error.tsx - only catches errors thrown inside /dashboard/*
export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Something broke in the dashboard</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Files in src/app/api/ become endpoints. Export a plain function or a Hono app - both work identically in dev and in production. The scaffold includes src/app/api/hello.ts (a Hono app that answers any method at /api/hello with a message, timestamp, and the request method) as a starting point.
// src/app/api/hello.ts - plain handler
export default function handler(req: Request) {
return Response.json({ message: 'hello', method: req.method });
}// src/app/api/users/[id].ts - Hono app, dynamic segment
import { Hono } from 'hono';
const app = new Hono();
app.get('/users/:id', (c) => c.json({ id: c.req.param('id') }));
export default app;getEnv and requireEnv are auto-imported for reading environment variables server-side - no dotenv setup needed.
How it routes: in dev and preview, a Vite middleware matches /api/* against src/app/api/ with HMR on every save. In production on the node target, bini-server reads the same handlers directly at request time - no build step, no generated entry file. For other hosting targets, running bini-deploy (via npm run deploy) generates the platform-specific entry file that merges your Hono apps into one instance (netlify/edge-functions/api.ts, api/index.ts, etc.); plain handlers are wrapped automatically. In packaged desktop/mobile apps, bini-native serves /api/* in-process inside the app - no server process, no code changes required.
bini-env loads .env files automatically and prints which ones are active on every dev/preview start, in priority order: .env.local → .env.[mode].local → .env.[mode] → .env.
# .env
BINI_PUBLIC_API_URL=https://api.example.com # client-side: import.meta.env.BINI_*
SMTP_USER=user@smtp.example.com # server-side: requireEnv() in API routesimport.meta.env.BINI_PUBLIC_API_URL // client code
const user = requireEnv('SMTP_USER'); // API routes - throws if missing, auto-importedThe generated vite.config also reads PORT (default 3000) for the dev and preview server port. Tauri's devUrl is fixed to http://localhost:3000, and native scripts fail instead of switching to another port, so leave PORT unset for desktop and mobile development.
⚠️ Never put secrets inBINI_*orVITE_*variables - both prefixes are exposed to the browser bundle.
Web target only. bini-server is a zero-dependency Node.js server - no Express, no Fastify - that serves dist/, proxies /api/* to your Hono handlers, and adds what vite preview doesn't: ETag/304 caching, graceful shutdown, body/handler timeouts, and automatic port fallback.
npm run build
npm start # PORT=8080 npm start to override the portOverride the default directories if needed: BINI_API_DIR (default src/app/api), BINI_DIST_DIR (default dist).
npm run build is one script for every target. For TypeScript projects it runs three steps:
tsc --noEmit && vite build && vite build --ssr src/main.tsxThe first vite build produces the client bundle, and the second builds the SSR bundle (src/main.jsx for JavaScript projects, and no tsc step). The bini-ssg plugin then pre-renders every route, static and dynamic alike, using the render() function exported from src/main.tsx. That function calls React 19's renderToPipeableStream directly against AppRoutes (from the generated src/App.tsx) with a StaticRouter, so the output is real server-rendered markup, not a client-only shell. The client then hydrates it with hydrateRoot on load - no extra fallback wiring or client-side redirect scripts required. There's no separate export command.
Build output goes to dist/, organized into js/, css/, data/, assets/images/, and assets/fonts/.
Desktop and mobile builds run the same script first (it is Tauri's beforeBuildCommand), so the packaged app ships the same pre-rendered HTML as the web.
One codebase, four targets: web, desktop (Windows, macOS, and Linux from one src-tauri/), Android, and iOS. --platform picks which ones you scaffold, and add brings in more later. The first native target adds the shared native setup (src-tauri/, the Tauri packages, and all native scripts); Android and iOS then also generate their own project under src-tauri/gen/. Every target also gets a deploy script for bini-deploy - see Deployment.
npx create-bini-app@latest my-app --platform desktop
npx create-bini-app@latest my-app --platform web,android --app-name "My App" --nosign
npx create-bini-app@latest add desktop # later, inside the projectWhen you add a native target, the CLI runs tauri init (frontend dist ../dist, dev URL http://localhost:3000, before-dev command npm run dev:native, before-build command npm run build, with your package manager's equivalents), then sets the product name, window title, and bundle identifier in src-tauri/tauri.conf.json. The identifier is <org>.<project> with the project name reduced to lowercase letters and numbers, for example my-app with --org com.acme becomes com.acme.myapp (a name starting with a digit gets an app prefix). Replace it with your own reverse-DNS id before publishing to an app store; create-bini-app --org does this for you. Icons are then generated from public/logo.png; if that file is missing, Tauri's default icons are kept.
The tauri:dev, tauri:build, android, android:build, ios, and ios:build scripts set TAURI=true. That makes the dev server bind to TAURI_DEV_HOST (or 0.0.0.0) on port 3000, matching Tauri's devUrl, and it fails instead of switching to another port. bini-native is loaded by vite.config only when TAURI=true (and not for the SSR build); it serves /api/* in-process, opens external links in the system browser, and wires Cargo, capabilities, and manifests for the web APIs you use. Run npm run doctor to check your machine, and see the Tauri prerequisites.
Overview - the default target. A standard Vite + React app with file-based routing, pre-rendered pages, and a Hono API layer.
Features
- Zero-dependency production server (
bini-server) bini-deploygenerates hosting config for Netlify Edge, Vercel, Cloudflare Workers, Node.js, or Deno and pushes to GitHub
Commands
| Command | Description |
|---|---|
npm run dev |
Dev server with HMR |
npm start |
Serve the production build |
npm run build |
Type-check, build, and pre-render every route (static & dynamic) via SSG |
npm run preview |
Preview the production build |
npm run deploy |
Generate hosting config for your chosen platform and push to GitHub |
Requirements - Node.js >= 20.19.0. Nothing else.
Overview - a native desktop binary. It runs inside WebView2 on Windows, WKWebView on macOS, and WebKitGTK on Linux. Bundles are not cross-compiled: build Windows bundles on Windows, macOS bundles on macOS, and Linux bundles on Linux. npm run deploy generates one GitHub Actions workflow that builds all three on their own runners (see Deployment).
Features
- Filesystem, clipboard, notifications, and dialogs via
@tauri-apps/api, auto-wired bybini-native - Code signing for Windows and macOS, and optional GPG signing of the Linux AppImage and RPM (see Code Signing)
Commands
| Command | Description |
|---|---|
npm run tauri:dev |
Run in development mode |
npm run tauri:build |
Build a distributable for the OS you are on |
npm run tauri:icon |
Regenerate icons from public/logo.png |
npm run deploy |
Generate the desktop GitHub Actions workflow and push the project to GitHub via bini-deploy |
deploy does not build the app on your machine. It writes .github/workflows/desktop.yml and pushes, and GitHub then builds Windows, macOS, and Linux bundles when you run the workflow or push a v* tag. To build locally instead, run tauri:build on the OS you want.
Requirements
Windows
- Rust (stable toolchain via
rustup, MSVC toolchain as default) - Tauri compiles a native Rust binary under the hood - Microsoft C++ Build Tools (download) - install with "Desktop development with C++"
- Microsoft Edge WebView2 Runtime (download) - only needed on older PCs; Windows 11 and recent Windows 10 updates ship it pre-installed
macOS
- Rust (stable toolchain via
rustup) - Xcode Command Line Tools -
xcode-select --install - No Homebrew packages are needed for desktop builds, since macOS ships WKWebView
- Full Xcode (Mac App Store) if you also plan to build for iOS
Linux
- Rust (stable toolchain via
rustup) - One of the following system dependency sets:
# Debian/Ubuntu
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
# Fedora
sudo dnf group install "c-development"
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
libappindicator-gtk3-devel librsvg2-devel libxdo-devel
# Arch
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
appmenu-gtk-module libappindicator-gtk3 librsvg xdotoolgpg(GnuPG) only if you want to sign the AppImage or RPM
Overview - a real native APK/AAB via Tauri's Android backend, not a WebView wrapper.
Features
- Camera, filesystem, notifications, and geolocation auto-wired by
bini-native - Back button, status bar, and splash screen configurable in
src-tauri/gen/android - Release signing (keystore +
keystore.properties), configurable at scaffold time or later - The Rust Android targets are added for you when
rustupis installed
Commands
| Command | Description |
|---|---|
npm run android |
Run on an emulator or connected device |
npm run android:build |
Build a release APK/AAB |
npm run deploy |
Generate the Android GitHub Actions workflow and push the project to GitHub via bini-deploy |
deploy does not build on your machine. It writes .github/workflows/android.yml and pushes, and GitHub builds the APK/AAB (signed when signing values are saved, otherwise unsigned) and attaches it to the run as an artifact. Store submission is manual. Commit the generated src-tauri/gen/android folder; only keystore.properties, local.properties, .gradle/, and build output are git-ignored.
Requirements
- Rust (stable toolchain via
rustup) - Tauri compiles a native Rust binary under the hood - Java JDK 17 (
JAVA_HOMEset) - download - Android Studio, with SDK/Build Tools/NDK (
ANDROID_HOMEandNDK_HOMEset) - download - Rust targets:
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android(done automatically by the CLI ifrustupis on your PATH)
If Android initialization fails, the CLI prints the exact npm run tauri -- android init command to run once the toolchain is fixed (with your package manager's equivalent if you don't use npm).
Overview - a real native app via Tauri's iOS backend, running inside WKWebView. macOS + Xcode required.
Features
- Same native plugin wiring story as Android
- Xcode-managed automatic signing for local runs; manual certificate/profile signing for CI
Commands
| Command | Description |
|---|---|
npm run ios |
Run on the Simulator or a connected device (macOS only) |
npm run ios:build |
Build the app (macOS only) |
npm run deploy |
Generate the iOS GitHub Actions workflow and push the project to GitHub via bini-deploy |
Requirements
- Rust (stable toolchain via
rustup) - Tauri compiles a native Rust binary under the hood - Xcode (Mac App Store) + Command Line Tools -
xcode-select --install - CocoaPods -
brew install cocoapods(orsudo gem install cocoapods) - Rust targets:
rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim
Not on a Mac? iOS is left out when you scaffold or add it on Windows or Linux, and the CLI says so. Develop on web or Android, add desktop if the project has no src-tauri/ yet (the iOS project itself is generated in CI), then run npm run deploy and choose iOS: it writes .github/workflows/ios.yml and pushes, and GitHub builds the app on a macOS runner. With Apple credentials saved the build is a signed .ipa; without them it builds for the simulator only. Store submission remains a separate, manual step.
Notes - every native project has a bare "tauri": "tauri" script in package.json. Xcode's build phase calls it directly (npm run tauri -- ios xcode-script ...), bypassing the commands above - builds triggered from inside Xcode fail with Missing script: "tauri" without it. Don't rename or remove it.
Signing is offered when you add a native target, and you can set it up or redo it any time, either with prompts or with a single command:
npx create-bini-app@latest sign # prompts, everything that applies on this machine
npx create-bini-app@latest sign android # prompts, one target
npx create-bini-app@latest sign desktop ./cert.pfx "pw" # no prompts: Windows (.pfx) or macOS (.p12), picked from this OS
npx create-bini-app@latest sign windows ./cert.pfx "pw" --ci-only
npx create-bini-app@latest sign macos ./dev-id.p12 "pw" --api-issuer ID --api-key KEY --api-key-file ./AuthKey.p8 --ci-only
npx create-bini-app@latest sign ios ./dist.p12 "pw" --profile ./app.mobileprovision
npx create-bini-app@latest sign ios --team ABCDE12345 # Xcode automatic signing, no files (on a Mac)
npx create-bini-app@latest sign android ./keystore.jks "pw" --alias upload
npx create-bini-app@latest sign android --generate "pw" # new keystore.jks
npx create-bini-app@latest sign windows --generate --publisher "My Co" --ci-only # GitHub Actions creates the key
npx create-bini-app@latest sign linux --generate --publisher "My Co" --ci-only # one target per command without prompts
npx create-bini-app@latest sign desktop --adhoc # macOS ad-hoc signing for local testing (on a Mac)Targets: desktop (the OS you are running on), windows, macos, linux, android, ios. create-bini-app desktop sign is shorthand for create-bini-app sign desktop.
This machine or GitHub Actions only.
desktop,android, andios(on a Mac) set up signing on this machine. When you give a certificate or keystore, the values are also saved for GitHub Actions.windows,macos, andlinuxcan only be named together with--ci-only. They then only save the values for GitHub Actions and change nothing on this machine. Usedesktopto sign for the OS you are on.ioson Windows or Linux also needs--ci-only.--ci-onlyworks from any OS. With no target, it covers every platform in the project (Windows, macOS, and Linux for the desktop target, plus Android and iOS when added).
Two modes. Give a certificate or keystore file and its password (or a mode flag such as --generate, --thumbprint, --sign-command, --adhoc, or --team for iOS) and nothing is asked. This argument mode takes exactly one target per command; only the prompt mode accepts a list such as sign windows,linux --ci-only. Otherwise sign prompts, which needs an interactive terminal and cannot be combined with --nosign. Without --ci-only and with no target, the prompts cover everything that applies here: desktop for the OS you run on (if the project has a native target), Android once it has been added, and iOS only on macOS. At scaffold time, signing is skipped with no prompts when you pass --nosign or --yes, or when the terminal is non-interactive. Targets that are not yet signed make the command exit with code 2; invalid arguments exit with 1.
Generated keys in CI. --ci-only with --generate (or the prompts under --ci-only) saves only a publisher name for Windows, Linux, and Android, plus a keystore password (minimum 6 characters) and key alias for Android. The GitHub Actions workflow creates the certificate, keystore, or GPG key on the first build, stores it as a GitHub secret, and reuses it afterwards. Add a repository secret SECRETS_PAT (a token with Secrets: read and write) so the workflow can save the key; npm run deploy offers to add it for you. Without it, a new key is created on every build. Linux GPG keys are generated without a passphrase, and the same key signs the AppImage and the RPM. macOS and iOS credentials cannot be generated: use a real certificate (or --adhoc on macOS for local testing). Generated keys are self-signed, so use real credentials for store releases. Real credentials take precedence over generated ones. Re-running --generate --ci-only on a target that is already saved fails unless you pass --force (the prompts ask for confirmation instead), the old real credentials for that target are removed from .env.signing, and a key already created in GitHub will no longer match the new values.
| Target | What is set up | Where it lives |
|---|---|---|
| Windows | A .pfx file (imported into your certificate store when you are on Windows, and saved for CI), the thumbprint of a certificate already in your store (--thumbprint), a free self-signed certificate (--generate, Windows only without --ci-only, created with PowerShell, valid 3 years, name set with --publisher), or a cloud or hardware key through a custom sign command (--sign-command, must contain %1). SHA-256 digest and a DigiCert timestamp URL are configured for store-based signing |
certificateThumbprint, digestAlgorithm, and timestampUrl in the git-ignored src-tauri/tauri.windows.conf.json, which Tauri merges automatically on Windows only (so the committed tauri.conf.json never points at a certificate that only exists on your machine). A sign command is written to src-tauri/tauri.cloud-sign.conf.json so normal local builds never call the signing service; sign with npm run tauri:build -- --config src-tauri/tauri.cloud-sign.conf.json. CI values: WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD, or WINDOWS_PUBLISHER for a key generated in CI |
| macOS | A Developer ID Application .p12 (imported into your login keychain on a Mac), optional notarization (App Store Connect API key, or Apple ID + app-specific password), or ad-hoc signing (--adhoc) for local testing |
signingIdentity in the git-ignored src-tauri/tauri.macos.conf.json, which Tauri merges automatically on macOS only. CI values: APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, KEYCHAIN_PASSWORD (a random password for the temporary keychain the workflow creates), plus APPLE_API_ISSUER / APPLE_API_KEY / APPLE_API_KEY_P8 or APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID for notarization. APPLE_API_KEY_PATH is also written, but it only points at the .p8 file on this machine for local notarization and is not uploaded to GitHub; the workflow writes the .p8 content to a file on the runner instead |
| Linux | GPG signing of the AppImage and the RPM: a new RSA 4096 key valid for 3 years (no passphrase) or a key you already have. Needs gpg (or gpg2). Optional, and skipped if you choose to |
CI values: GPG_PRIVATE_KEY, SIGN=1, SIGN_KEY, APPIMAGETOOL_FORCE_SIGN=1 (so a signing failure aborts the build instead of producing an unsigned AppImage), and TAURI_SIGNING_RPM_KEY for the RPM. APPIMAGETOOL_SIGN_PASSPHRASE and TAURI_SIGNING_RPM_KEY_PASSPHRASE are only added for an existing key that has a passphrase. A key generated in CI is stored as LINUX_PUBLISHER. Local builds only pick these up if you load .env.signing into your shell first (see below) |
| Android | A keystore.jks in the project root (generated with keytool, which ships with the JDK, or one you already have) and a keystore.properties file. The Gradle signingConfigs block is patched into app/build.gradle.kts for you |
src-tauri/gen/android/keystore.properties. CI values: ANDROID_KEY_BASE64, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD, ANDROID_KEYSTORE_PASSWORD, or ANDROID_PUBLISHER for a keystore generated in CI |
| iOS | Automatic Xcode signing with your Team ID (nothing to import), or a manual Apple Distribution .p12 + provisioning profile (.mobileprovision) for CI, stored base64-encoded |
The Team ID is saved as developmentTeam in the git-ignored src-tauri/tauri.ios.conf.json, which Tauri merges automatically on iOS builds, so local builds need no environment variables and CI does not try to sign with it. CI values: IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD, IOS_MOBILE_PROVISION, APPLE_DEVELOPMENT_TEAM. For CI automatic signing with --team, an App Store Connect API key is saved instead: APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8 |
Certificates and profiles are checked before they are saved (when openssl is available): the CLI reports the certificate name and expiry, warns about the wrong kind of certificate (for example a development certificate where a distribution one is needed), a missing private key, a provisioning profile whose bundle ID or team does not match your app, and development profiles that App Store builds cannot use. Keystores are opened with keytool to verify the password and alias. If you have no Apple certificate yet, the interactive prompts can create a certificate request (CSR) on your machine for Apple Developer, or show a short guide for getting one.
| Option | Used with | Description |
|---|---|---|
--alias <name> |
android | Key alias (default upload) |
--key-password <pw> |
android | Key password, when it differs from the keystore password. Ignored for .p12/.pfx keystores, which use one password for both |
--generate |
android, windows, linux | Android: create a new keystore (the password follows the command, minimum 6 characters). Windows: create a self-signed certificate (Windows only, unless used with --ci-only). Linux: create a GPG key with no passphrase. With --ci-only, only the publisher name (and the Android password) is saved and GitHub Actions creates the key |
--publisher <name> |
windows, android, linux | Publisher name for a generated key or certificate |
--thumbprint <sha1> |
windows | Use a certificate already in the Windows store (40 hex characters) |
--sign-command "<cmd>" |
windows | Cloud or hardware signing command containing %1 |
--identity "<name>" |
macos | Signing identity, when it cannot be read from the certificate |
--adhoc |
macos | Ad-hoc signing for local testing |
--api-issuer, --api-key, --api-key-file |
macos, ios | App Store Connect API key (all three are required together): notarization on macOS, CI automatic signing on iOS |
--apple-id, --apple-password, --team |
macos | Notarization with an Apple ID and an app-specific password (all required together). Cannot be combined with the API key options |
--profile <file> |
ios | Provisioning profile for a manual certificate (the certificate file is required too) |
--team <TEAMID> |
ios, macos | Apple Team ID (10 letters and numbers) |
--force |
android, windows, linux | With --generate --ci-only: replace signing values that are already saved |
--ci-only |
all | Only save the CI values; skip the local keychain, certificate store, and Gradle setup. Required to name windows, macos, or linux. Cannot be combined with --sign-command, --thumbprint, --adhoc, or ios --team |
To keep passwords out of shell history and process lists, they can come from the environment instead of the command line: BINI_SIGN_PASSWORD (certificate or keystore password), BINI_KEY_PASSWORD (Android key password), and BINI_APPLE_PASSWORD (Apple ID app-specific password). Use the environment for a password that starts with -, since it would be read as a flag.
.env.signing- created with owner-only permissions and git-ignored. It holds the values GitHub Actions needs, andnpm run deployuploads them as GitHub secrets. Local builds do not need it: they sign from your localtauri.windows.conf.json/tauri.macos.conf.json/tauri.ios.conf.json, your keychain, and Xcode. Each platform only updates its own keys, so platforms can be set up at different times. To notarize a local macOS build, or to sign a local Linux AppImage or RPM, load it first withset -a; source .env.signing; set +a.keystore.jksandsrc-tauri/gen/android/keystore.properties- the Gradle signing config is committed and only activates whenkeystore.propertiesexists, so fresh clones and debug builds keep working. Back upkeystore.jksand its password: every Play Store update must be signed with the same key..bini-signing/- private key and certificate request created when you have no Apple certificate yet. Keep the private key safe; it is the only copy.- Setting up signing adds
.env.signing,.bini-signing/,tauri.windows.conf.json,tauri.macos.conf.json,tauri.ios.conf.json,*.jks,*.keystore,*.p12,*.pfx,*.p8,*.mobileprovision,*.key, andkeystore.propertiesto.gitignore.
Desktop signing without --ci-only covers only the OS you ran sign on. Sign the other systems in CI on their own runners (sign windows,macos,linux --ci-only). A self-signed Windows certificate still shows a SmartScreen warning to other people; use a certificate from a trusted authority for public releases. Tauri's own guides: macOS, Windows, Linux, iOS, Android.
Dev overlay. An animated status badge in the corner reflects loading/idle/error state live. On error, it expands into a Shiki-highlighted code frame with a clickable file link and filtered call stack. Disable with DISABLE_BINI_OVERLAY=true npm run dev.
Auto-imports. These are available in every file under src/app/ (including .mdx/.md) with no import statement:
| From | Symbols |
|---|---|
react |
useState useEffect useRef useMemo useCallback useContext createContext useReducer useId useTransition useDeferredValue |
react-router-dom |
Link NavLink useNavigate useParams useLocation useSearchParams Outlet |
bini-env |
getEnv requireEnv |
A manual import of any of these is detected and injection is skipped - no duplicates.
Linting & formatting. Oxlint and Oxfmt ship pre-configured (.oxlintrc.json, .oxfmtrc.json) - Rust-based, 50-100x faster than ESLint/Prettier, no setup required.
npm run lint # Oxlint
npm run format # Oxfmt
npm run check # both + type-check - CI-readybini-deploy is bundled into every scaffold and exposed as npm run deploy. A project is either a web deploy or a native deploy; the two cannot be combined in one run (run it once for each). What it does depends on which:
- Web - prompts for a hosting target (
node,netlify,vercel,cloudflare, ordeno), generates that platform's entry file and any hosting config (netlify.toml,vercel.json,wrangler.toml) directly in your project, removes files left over from other hosting targets, then commits and pushes everything to the GitHub repository you specify. - Desktop / Android / iOS - removes web hosting files and generates GitHub Actions workflows that build the app on GitHub, then commits and pushes. Builds run on GitHub's runners, so this works from any OS (no Mac needed for iOS). It does not submit anything to an app store - that remains a separate, manual step.
npm run deploy # interactive
npx bini-deploy --platform web --hosting vercel --repo https://github.com/you/app --yes # non-interactive
npx bini-deploy --platform desktop,android,ios --repo https://github.com/you/app -y # native, non-interactive| Option | Description |
|---|---|
--platform <targets> |
web, or any of desktop, android, ios (comma-separated). web cannot be combined with the others |
--hosting <name> |
Web only: node (default with --yes), netlify, vercel, cloudflare, deno |
--repo <url> |
GitHub repository URL |
--generate-entry <host> |
Generate only the production API entry file (netlify, vercel, cloudflare, deno) |
--nosign |
Native only: skip the signing setup and the secrets upload |
--yes, -y |
No prompts; --platform and --repo are required |
| Platform | Files generated |
|---|---|
netlify |
netlify/edge-functions/api.ts, netlify/edge-functions/spa-fallback.ts, and netlify.toml |
vercel |
api/index.ts, vercel.json, and (TypeScript projects) api/tsconfig.json |
cloudflare |
worker.ts and wrangler.toml |
deno |
server/index.ts |
node |
(none - bini-server reads src/app/api/ directly) |
JavaScript projects get .js instead of .ts. The entry file is only generated when src/app/api/ has routes.
Netlify example, generated automatically by bini-deploy when you choose it:
# netlify.toml
[build]
command = "npm run build"
publish = "dist"
[[edge_functions]]
path = "/api/*"
function = "api"Netlify redirects cannot exclude paths with a file extension, so the single-page-app fallback is a second edge function (spa-fallback) that serves index.html only for 404s on extensionless paths. Missing files such as robots.txt stay a real 404.
Edge Functions run on Deno, not Node - packages depending on Node built-ins (
fs,nodemailer) won't work there.
Node.js (Railway, Render, Fly.io, a VPS): npm run build && npm start. bini-server reads handlers directly from src/app/api/, so deploy the whole project - not just dist/. Use pm2 on a bare VPS.
GitHub Pages / subpaths - set base: '/my-repo/' in vite.config.ts, then npm run build for a fully pre-rendered, subpath-aware dist/.
For native targets, bini-deploy writes one workflow per kind of runner into .github/workflows/:
| Workflow | Builds | Runner |
|---|---|---|
desktop.yml |
Windows, macOS, and Linux (one matrix job) | Windows, macOS, and Ubuntu |
android.yml |
Android APK/AAB | Ubuntu |
ios.yml |
iOS app | macOS |
The workflows use your project's own scripts (tauri:build, android:build, ios:build) and your package manager (npm, pnpm, yarn, or bun, detected from the project's lockfile), and are regenerated every time you run deploy. Deploy checks first that src-tauri/tauri.conf.json and the matching build scripts exist; if not, it tells you to run create-bini-app add <target> (on Windows or Linux, add desktop creates the shared src-tauri/, and the iOS project itself is generated in CI).
Run a workflow from the Actions tab, or push a tag such as v1.0.0. The finished builds are attached to the run as Artifacts.
Signing in CI. For each platform, the workflow uses, in order:
- Real or previously saved credentials from GitHub secrets.
- A publisher name saved by
create-bini-app sign <target> --ci-only(Windows, Linux, Android): the workflow creates a self-signed key on the first build, saves it as a secret, and reuses it. This needs theSECRETS_PATsecret. - Nothing: the build is unsigned. macOS falls back to an ad-hoc signature, and iOS builds for the simulator only.
macOS and iOS cannot be generated and need real Apple credentials. During deploy you are offered the signing prompts for each platform you are deploying (skip them with --nosign), then the saved values from .env.signing are uploaded to GitHub as secrets with the GitHub CLI (gh). Install it and run gh auth login first, otherwise deploy prints the secret names to add by hand at https://github.com/<you>/<repo>/settings/secrets/actions. If a generated key is in use and SECRETS_PAT is missing, deploy offers to add it.
Pushing to GitHub. Deploy initializes git if needed, sets origin (an existing remote is never changed), commits, and pushes to main. It asks for a git name and email if none are set (stored for this repository only), and for a GitHub username and Personal Access Token if the push is rejected (add the workflow scope so GitHub accepts the Actions files). If the remote has commits you don't have locally, it merges them in with -X ours: for any file that exists on both sides your local version wins, and files that only exist on the remote are kept.
| Package | Role |
|---|---|
bini-router |
File-based routing, layouts, MDX/Markdown pages, folder-scoped loading/not-found/error, metadata, auto-imports, dev/preview API routing |
bini-ssg |
Pre-renders every route - static and dynamic - to static HTML as part of npm run build |
bini-deploy |
Generates hosting config for web targets and GitHub Actions build workflows for native targets, and pushes the project to GitHub |
bini-native |
Tauri plugin wiring, in-process /api, and external-link handling for desktop and mobile |
@tauri-apps/cli |
Compiles the app to a native Windows/macOS/Linux/Android/iOS binary |
bini-server |
Zero-dependency production server |
bini-overlay |
Dev error overlay |
bini-env |
Environment variable loading |
hono |
API route runtime |
vite |
Dev server + Rolldown bundler |
typescript |
Type checking |
react / react-router-dom |
UI + routing |
oxlint / oxfmt |
Linting + formatting |
- bini.js.org - documentation
- Discord - chat with the community
- Reddit - r/binijs
- LinkedIn - project updates and showcase
- GitHub - source
- npm - package
- Issues - bugs & feature requests
MIT © Binidu Ranasinghe