One codebase. Three platforms. Ship everywhere.
A production-ready starter for building web, mobile, and desktop apps
with SolidStart, Capacitor, and Tauri.
Prerequisites • Getting Started • Build for Any Platform • Examples • Contributing
Most cross-platform solutions force you to pick one: Electron for desktop, React Native for mobile, or SSR for web. This starter gives you all three from a single SolidJS codebase:
- Web — SSR with SolidStart, deployed as a Node server
- Mobile — Native iOS & Android via Capacitor (real native APIs, not a webview wrapper)
- Desktop — Lightweight native apps via Tauri v2 (~10MB binary, not 200MB Electron)
No code duplication. No framework lock-in. Just SolidJS everywhere.
The project runs on SolidStart (Vinxi + Vite) for file-based routing and SSR, styled with Tailwind CSS v4. Mobile targets iOS and Android via Capacitor 8 — real native APIs, not a webview wrapper. Desktop targets macOS, Linux, and Windows via Tauri v2, producing a ~10MB binary instead of Electron's 200MB. Page transitions are powered by ssgoi — iOS-style slide and Material fade+scale work out of the box. The whole thing is written in strict TypeScript, with ESLint, Prettier, and Vitest build tests preconfigured.
| Requirement | Needed for | Install |
|---|---|---|
| Node.js ≥ 18 | All platforms | nodejs.org |
| Rust + Cargo | Desktop builds only | rustup.rs |
| Xcode | iOS builds only | Mac App Store |
| Android Studio | Android builds only | developer.android.com |
Just exploring? Web development works with Node.js alone — no Rust, Xcode, or Android Studio needed. Add them later when you're ready to target desktop or mobile.
# Clone and install
git clone https://github.com/llupRisinglll/solidjs-crossplatform-starter.git
cd solidjs-crossplatform-starter
npm install
# Start developing
npm run devOpen http://localhost:3456 — you'll see a fully working demo app with interactive samples covering SolidJS reactivity patterns and a native features page that adapts to your current platform.
npm run build:web # SSR build (Node server)
npm run preview # Preview the production buildFirst time? Generate the native projects:
npx cap add ios # creates ios/ directory
npx cap add android # creates android/ directoryThen build and open:
npm run build:mobile # Static build + Capacitor sync
npm run cap:ios # Open in Xcode
npm run cap:android # Open in Android StudioDesktop builds require system libraries. Install once:
Arch Linux
sudo pacman -S webkit2gtk-4.1Ubuntu / Debian
sudo apt install libwebkit2gtk-4.1-dev build-essential libssl-dev libayatana-appindicator3-dev librsvg2-devFedora
sudo dnf install webkit2gtk4.1-devel openssl-develmacOS / Windows
See the Tauri prerequisites guide.
Then build:
npm run build:desktop # Production binary + installer
npm run tauri:dev # Dev with hot reloadThe same SolidJS code runs everywhere. The build system handles the differences:
| Web | Mobile | Desktop | |
|---|---|---|---|
| Rendering | SSR (Node server) | Client-side SPA | Client-side SPA |
| Routing | Standard Router | HashRouter | HashRouter |
| Native APIs | Web APIs | Capacitor plugins | Tauri plugins |
| Output | .output/server/ |
.output/public/ |
Binary + installer |
Web uses SSR (server-side rendering) with Router for full SEO and streaming. Native platforms (desktop, mobile) use static SPA mode with HashRouter because there's no server to render HTML — Tauri and Capacitor load files directly from disk.
This means server functions, streaming, and other SSR features are web-only. Code that runs on native platforms must work entirely on the client. The build system handles this automatically via the PLATFORM env var.
Most apps talk to a backend. The starter provides:
- Vite proxy — Uncomment the proxy config in
app.config.tsto forward/apicalls to your backend during development (avoids CORS) src/lib/api.ts— Platform-aware API base URL resolution. Web uses relative paths (works with proxy in dev, same-origin in prod). Desktop defaults tohttp://localhost:4000for a sidecar backend. SetVITE_API_URLin.envto override.src/lib/auth.ts— Token management andfetchWithAuth()wrapper that attaches Bearer tokens and redirects on 401
The starter includes a provider-agnostic auth pattern:
// Wrap individual components
<AuthGuard fallback={<p>Redirecting...</p>}>
<ProtectedContent />
</AuthGuard>
// Or use route groups — see src/routes/(protected)/
// All routes in (protected)/ are automatically guardedReplace isAuthenticated() in src/lib/auth.ts with your auth provider's check (Supabase, Better Auth, Firebase, etc.).
The starter respects prefers-color-scheme out of the box. All sample components use Tailwind's dark: variants. For manual control, use the ThemeToggle component:
import ThemeToggle from "~/components/ThemeToggle";
// renders a system/light/dark cycle buttonimport { detectPlatform, isMobile, isDesktop } from "~/lib/platform";
// Runtime — adapts UI at runtime
const platform = detectPlatform(); // "web" | "mobile" | "desktop"
// Build-time — tree-shakes platform-specific code
if (import.meta.env.IS_DESKTOP) {
// Only included in desktop builds
}Use dynamic imports so all platforms compile cleanly:
async function triggerHaptic() {
if (detectPlatform() === "mobile") {
const { Haptics, ImpactStyle } = await import("@capacitor/haptics");
await Haptics.impact({ style: ImpactStyle.Medium });
} else if (detectPlatform() === "desktop") {
const { message } = await import("@tauri-apps/plugin-dialog");
await message("Hello from Tauri!", { title: "Native Dialog" });
}
}Pages transition automatically based on the detected design language:
- iOS — Slide push/pop (swipe-back gesture from left edge)
- Material — Fade + scale
Customize in src/assets/css/transitions.css.
The starter includes interactive samples that compile on all platforms. Run the app and visit /samples, or read the source in src/routes/samples/.
| Sample | Route | Concepts |
|---|---|---|
| Counter | /samples/counter |
createSignal, createMemo, createEffect, onCleanup |
| Todo List | /samples/todos |
createStore, <For>, <Show>, event handling |
| Data Fetching | /samples/fetch |
createResource, <Suspense>, <ErrorBoundary> |
| Form Handling | /samples/forms |
Controlled inputs, validation, derived state |
| Store & Produce | /samples/store |
createStore, produce, nested state, derived |
| Server-Sent Events | /samples/sse |
createSSE, real-time updates, auto-reconnect |
Counter — Signals & Reactivity
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => {
if (!autoIncrement()) return;
const id = setInterval(() => setCount((c) => c + 1), 1000);
onCleanup(() => clearInterval(id));
});Todo List — Stores & Fine-Grained Updates
const [todos, setTodos] = createStore<Todo[]>([]);
// Surgically update one property on one item — no re-render of the list
setTodos(
(t) => t.id === id,
"done",
(done) => !done,
);Data Fetching — Resources & Suspense
const [users, { refetch }] = createResource(enabled, fetchUsers);
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
<Suspense fallback={<Spinner />}>
<For each={users()}>{(user) => <UserCard user={user} />}</For>
</Suspense>
</ErrorBoundary>;Form Handling — Validation as Derived State
const [email, setEmail] = createSignal("");
const emailError = createMemo(() =>
email().length > 0 && !email().includes("@") ? "Invalid email" : "",
);
const isValid = createMemo(() => email().includes("@") && !emailError());| Command | Description |
|---|---|
npm run dev |
Start web dev server on port 3456 |
npm run dev:desktop |
Start desktop dev server on port 3457 (SSR disabled) |
npm run dev:all |
Run web + desktop dev servers simultaneously |
npm run build:web |
Build for web (SSR) |
npm run build:mobile |
Build for mobile (static + Capacitor) |
npm run build:desktop |
Build for desktop (static + Tauri) |
npm run tauri:dev |
Desktop dev server with hot reload |
npm run cap:ios |
Open iOS project in Xcode |
npm run cap:android |
Open Android project in Android Studio |
npm run lint |
Run ESLint |
npm run format |
Run Prettier |
npm run test:unit |
Component tests (solid-testing-library) |
npm run test:build |
E2E build tests for all platforms |
npm run test:e2e |
Playwright E2E tests |
solidjs-crossplatform-starter/
├── src/
│ ├── routes/ # File-based routing
│ │ ├── index.tsx # Home page
│ │ ├── native.tsx # Native features showcase
│ │ ├── (protected).tsx # Auth-guarded route group layout
│ │ ├── (protected)/
│ │ │ └── dashboard.tsx # Example protected route → /dashboard
│ │ ├── demo/
│ │ │ ├── index.tsx # Transitions demo
│ │ │ └── detail.tsx # Detail page (push transition)
│ │ ├── docs/
│ │ │ └── [[lang]].tsx # Optional param → /docs, /docs/en
│ │ ├── files/
│ │ │ └── [...path].tsx # Catch-all param → /files/any/path
│ │ └── samples/
│ │ ├── index.tsx # Samples index with inline demo
│ │ ├── counter.tsx # Signals, memos, effects
│ │ ├── todos.tsx # Stores, For, Show
│ │ ├── fetch.tsx # createResource, Suspense
│ │ ├── forms.tsx # Inputs, validation
│ │ ├── store.tsx # createStore, produce, nested state
│ │ └── sse.tsx # Server-Sent Events demo
│ ├── components/
│ │ ├── AuthGuard.tsx # Route guard (redirect if unauthenticated)
│ │ └── ThemeToggle.tsx # Dark/light/system theme switcher
│ ├── lib/
│ │ ├── platform.ts # Runtime platform detection
│ │ ├── api.ts # Platform-aware API base URL + fetch
│ │ ├── auth.ts # Token management + fetchWithAuth
│ │ ├── sse.ts # SSE client with auto-reconnect
│ │ ├── transitions.ts # Transition direction & CSS
│ │ └── swipe-back.ts # iOS-style swipe gesture
│ ├── assets/css/
│ │ ├── app.css # Tailwind, dark mode, scrollbars
│ │ └── transitions.css # iOS slide / Material fade+scale
│ ├── app.tsx # Root layout with router + transitions
│ ├── entry-client.tsx # Client entry point
│ └── entry-server.tsx # Server entry point
├── src-tauri/ # Tauri config + Rust backend
├── tests/
│ ├── build/ # Build verification tests
│ └── unit/ # Component tests (solid-testing-library)
├── e2e/ # Playwright E2E tests
├── .github/workflows/ci.yml # CI: lint, test, E2E
├── docs/ # Architecture decisions & guides
├── app.config.ts # SolidStart + Vite + devtools config
├── capacitor.config.ts # Capacitor config
├── platform.config.ts # Enable/disable platforms
├── eslint.config.js # ESLint config
└── .prettierrc # Prettier config
Everything in src/routes/samples/ and src/routes/demo/ is demo content — safe to delete once you start building your app.
Edit platform.config.ts to disable platforms you don't need:
export const platformConfig = {
web: true,
mobile: false, // disables Capacitor
desktop: true,
};This prevents unused native dependencies from being bundled into other platform builds.
Drop .tsx files into src/routes/ — SolidStart picks them up automatically via file-based routing:
src/routes/
├── index.tsx # → /
├── about.tsx # → /about
└── dashboard/
└── index.tsx # → /dashboard
Route precedence: Static routes always take priority over dynamic
[param]routes. If you have bothsrc/routes/admin.tsxandsrc/routes/[slug].tsx, visiting/adminwill always render the static route. No need for areserved-routes.tsguard.
Use dynamic imports so all platforms compile cleanly, regardless of which native SDKs are installed:
const platform = detectPlatform(); // "web" | "mobile" | "desktop"
if (platform === "mobile") {
const { Camera } = await import("@capacitor/camera");
// iOS/Android only
} else if (platform === "desktop") {
const { open } = await import("@tauri-apps/plugin-dialog");
// macOS/Linux/Windows only
}This starter ships with CLAUDE.md and llms.txt preconfigured for AI coding assistants (Claude Code, Cursor, Copilot, etc.). Edit CLAUDE.md to add your project-specific rules, conventions, and gotchas — your AI assistant will follow them automatically.
CLAUDE.md — project rules, commands, patterns, gotchas
llms.txt — file index so AI tools understand the codebase structure
Contributions are welcome! Please read the Contributing Guide before submitting a pull request.
This project follows the Contributor Covenant Code of Conduct.