A batteries-included template for building mobile apps with ClojureScript. Ships with everything wired up so you can skip the boilerplate and start building.
For a real-world example built on this template, see the app branch.
| Layer | Tech |
|---|---|
| Language | ClojureScript via shadow-cljs |
| Runtime | React Native via Expo (SDK 51) |
| UI | Reagent (React wrapper) |
| State | re-frame |
| Navigation | React Navigation (bottom tabs + native stacks) |
| Styling | twrnc (Tailwind CSS for React Native) |
| Storage | AsyncStorage with transit serialization |
| i18n | i18n-js with JSON translation files |
| Dev | nREPL (port 7888) + shadow-cljs hot reload |
| Components | Storybook for React Native |
- Node.js 18+
- Java 11+ (for shadow-cljs / ClojureScript compilation)
- iOS Simulator (macOS) or Android emulator
- An editor with nREPL support (Emacs/CIDER, Calva, Cursive)
# install dependencies
npm install
# terminal 1: start the cljs compiler
npx shadow-cljs server
# terminal 2: start expo
npx expo startshadow-cljs compiles ClojureScript into the app/ directory. Expo's metro bundler picks it up from there.
Connect your editor's REPL to localhost:7888 for interactive development.
shadow-cljs
src/**/*.cljs ──────────────► app/index.js ──► Metro ──► Device/Simulator
(compile) (bundle)
- You write ClojureScript in
src/ - shadow-cljs compiles to JS in
app/ - Expo's metro bundler serves the JS to your device
- Hot reload works automatically — save a file and see changes instantly
src/
app/
app.cljs # entry point, initializes re-frame + i18n
root.cljs # root navigator (bottom tabs)
db.cljs # app-db initial state + specs
events.cljs # re-frame event handlers
subs.cljs # re-frame subscriptions
effects.cljs # re-frame effect registrations
pages/
home.cljs # example page (counter demo)
navigation/
home_stack.cljs # example native stack navigator
common.cljs # shared navigation options
setup/
hot_reload.cljs # dev hot-reload overlay
i18n_resources.cljs # translation file loading
utils/
i18n.cljs # i18n helper functions
helpers.cljs # general utilities
debounce.cljs # debounce/throttle
widgets/
base.cljs # RN component aliases (View, Text, etc.)
button.cljs # pressable button
text.cljs # text utilities
dropdown.cljs # dropdown selector
underlined_input.cljs # text input variants
add_button.cljs # floating action button
bracketed_numeric_input.cljs
react_native/
async_storage.cljs # AsyncStorage wrapper with transit
platform.cljs # platform detection (ios? android?)
expo/
root.cljs # Expo root component registration
stories/
widgets/
button_stories.cljs # example ClojureScript story
.storybook/
index.ts # Storybook entry point
main.ts # Storybook config (story paths, addons)
storybook.requires.ts # auto-generated story index
stories/
Button/ # example TypeScript story
storybook-bridge.js # bridges .storybook/ into shadow-cljs builds
metro.config.js # Metro config (extraNodeModules for storybook)
translations/
en.json # English translation strings
- Create
src/app/pages/my_page.cljs:
(ns app.pages.my-page
(:require [app.widgets.base :refer [view text]]
["twrnc" :refer [style] :rename {style tw}]))
(defn my-page []
[:> view {:style (tw "flex-1 items-center justify-center")}
[:> text {:style (tw "text-xl")} "Hello!"]])- Create a stack in
src/app/navigation/my_stack.cljs:
(ns app.navigation.my-stack
(:require ["@react-navigation/native-stack" :as rnn-stack]
[reagent.core :as r]
[app.pages.my-page :refer [my-page]]
[app.navigation.common :refer [options]]))
(defonce Stack (rnn-stack/createNativeStackNavigator))
(defn my-stack []
(r/with-let [component (fn [props] (r/as-element [my-page props]))]
[:> Stack.Navigator
[:> Stack.Screen {:name "MyPage" :component component :options options}]]))- Add the tab in
src/app/root.cljs.
Register events in events.cljs and subscriptions in subs.cljs:
;; events.cljs
(rf/reg-event-db :my-feature/do-thing
(fn [db [_ value]]
(assoc db :my-feature value)))
;; subs.cljs
(rf/reg-sub :my-feature/value
(fn [db _] (:my-feature db)))Use in components with (rf/subscribe [:my-feature/value]) and (rf/dispatch [:my-feature/do-thing "hello"]).
Add keys to translations/en.json and use them with (i18n/label :t/your-key).
Uses AsyncStorage with transit serialization for persisting re-frame state. For better performance, consider swapping to react-native-mmkv — it's synchronous and significantly faster.
In dev mode, a purple FAB button appears in the bottom-right corner. Tap it to toggle between the app and the Storybook UI.
Two story formats are supported:
- TypeScript stories in
.storybook/stories/— standard@storybook/react-nativeformat (.stories.tsx) - ClojureScript stories in
src/stories/— compiled by shadow-cljs with the*-stories$namespace convention
See .storybook/stories/Button/Button.stories.tsx for a TSX example and src/stories/widgets/button_stories.cljs for a ClojureScript example.
# generate story index (after adding new TSX stories)
npm run storybook:generate
# run on iOS with storybook as the initial view
npm run storybook:iosThe bridge between shadow-cljs and Storybook works via storybook-bridge.js at the project root, resolved through Metro's extraNodeModules config in metro.config.js.
# preview build (APK for Android)
eas build --profile preview --platform android
# production build
eas build --profile production --platform allSet EXPO_PUBLIC_EAS_PROJECT_ID in your environment or eas.json.
MIT