Scaffold — create-tempest-app
create-tempest-app is the official Tempest scaffolding CLI. With one command you create a React 19 + Vite + TypeScript app already wired up with tempest-react-sdk: providers, routing, an auth store, and an HTTP client come ready to run. The CLI is not a separate package — it ships inside tempest-react-sdk itself as the package's bin (create-tempest-app), alongside a bundled template/ in the tarball.
"bin": { "create-tempest-app": "./bin/create-tempest-app.mjs" }
This page is a tutorial: we go from an empty command to the app running in your browser, then walk through every generated file to understand which SDK feature it shows off. 🚀
Versioned with the SDK
Because the CLI lives inside tempest-react-sdk, it's versioned together with the SDK. Pinning a version means pinning the SDK version: npx -p tempest-react-sdk@0.5.1 create-tempest-app …. And the generated app's tempest-react-sdk dependency is stamped to the very SDK version that produced it — no hardcoded number that drifts out of date.
Create your first app
The recommended path is to create the folder yourself and scaffold into it with .:
mkdir my-app
cd my-app
npx -p tempest-react-sdk create-tempest-app .
npm install
cp .env.example .env
npm run dev # http://127.0.0.1:5173
Open http://127.0.0.1:5173 — the app is already live with providers, routes, and the store working.
What each piece of the command does
| Piece | What it does |
|---|---|
npx |
Downloads and runs a binary without installing anything globally — then throws the download away. |
-p tempest-react-sdk |
Says which package the binary comes from. Needed because the CLI lives inside the SDK, under a different name. |
create-tempest-app |
The bin name inside that package — the thing that actually runs. |
. |
The destination: the current directory (recommended). A name instead of . creates the folder and requires it empty. |
Prefer . over create-tempest-app my-app
Both work, but . is nicer day to day:
- You own the folder and its name — the
package.jsonnamecomes from the current directory, with no risk of ending up withmy-app/my-appbecause you were already inside the folder. - It coexists with what's there —
git init,README.md,LICENSEand friends are preserved (the CLI lists what it skipped). The named mode aborts when the folder isn't empty. - One command for both a fresh folder and an existing project.
No argument = .
npx -p tempest-react-sdk create-tempest-app (nothing after it) does exactly what . does: scaffold into the current directory. The CLI does not prompt for a project name.
npm create tempest-app does not work
There is no create-tempest-app package on npm — the CLI is the bin of tempest-react-sdk, so npm create tempest-app fails with a 404. Use npx -p tempest-react-sdk create-tempest-app .; inside a project that already has the SDK installed, -p is unnecessary.
In named mode the folder must be empty
With create-tempest-app my-app, the target directory must not exist or must be empty — this keeps you from overwriting one of your projects by accident. If the folder already has files, the CLI suggests using . to merge into the current directory instead of aborting (see the next section).
The two modes, side by side
| You type | Destination | If the folder has files | Project name |
|---|---|---|---|
create-tempest-app . |
current directory | preserves yours and reports what it skipped | current folder name |
create-tempest-app (bare) |
current directory | same | current folder name |
create-tempest-app my-app |
./my-app |
aborts if it exists and isn't empty | my-app |
Scaffold into an existing project
If you already have a project that depends on the SDK, you can generate src/ + configs into the current directory, without creating a new folder:
npm install tempest-react-sdk
npx create-tempest-app .
Here npx create-tempest-app resolves the bin from the tempest-react-sdk you just installed — no -p needed. Running with no argument behaves the same as ..
In this "current directory" mode:
- Existing files are left untouched — the CLI skips each one that already exists and reports what it skipped, never overwriting anything of yours.
- An existing
package.jsonhas the Tempest scripts and deps merged in: yourname/versionand the scripts/deps already there are preserved, andtempest-react-sdkis pinned to the SDK's own version running the scaffold.
The .env
.env.example declares VITE_API_URL, the base used by the HTTP client in src/lib/api.ts. Copy it to .env and point it at your backend.
Available scripts
The generated package.json ships these scripts:
| Script | What it does |
|---|---|
npm run dev |
vite — dev server at 127.0.0.1:5173 |
npm run build |
tsc --noEmit && vite build — type-check and bundle |
npm run preview |
vite preview — serve the production build |
npm run typecheck |
tsc --noEmit — type-check only |
npm run lint |
eslint . — ESLint 9 flat config (react-hooks + refresh) |
npm run lint:fix |
eslint . --fix — auto-fix what it can |
Tour of what gets generated
The generated project is lean on purpose: each file exists to demonstrate an SDK feature you'll reuse. Here's the full structure:
my-app/
├── index.html
├── package.json # deps: react, react-dom, tempest-react-sdk; devDeps: vite, @vitejs/plugin-react, typescript, @types/*
├── tsconfig.json # @ -> ./src alias in "paths"
├── vite.config.ts # export default createViteConfig()
├── .env.example # VITE_API_URL
├── .gitignore
└── src/
├── main.tsx # createRoot + "tempest-react-sdk/styles.css" + <App/>
├── App.tsx # <AppProviders> wrapping <AppRouter routes fallback/>
├── routes.tsx # defineRoutes([...]) with index, login, and a lazy + guarded dashboard
├── layouts/RootLayout.tsx # nav (Link) + <Outlet/>, reads useAuth.use.isAuthenticated()
├── pages/Home.tsx
├── pages/Login.tsx # fakes a session via useAuth.use.setSession() then navigate("/dashboard")
├── pages/Dashboard.tsx # default export (lazy), protected route
├── stores/auth.ts # createSelectors(createAuthStore<User>({ name: "app-auth" }))
└── lib/api.ts # createApiClient({ baseURL, getToken, onUnauthorized }) + createQueryKeys
Each file → the SDK feature it shows
| File | SDK feature demonstrated |
|---|---|
vite.config.ts |
createViteConfig — Vite config ready for the SDK |
src/App.tsx |
AppProviders + AppRouter — providers and routing |
src/routes.tsx |
defineRoutes with a lazy route + auth guard |
src/stores/auth.ts |
createAuthStore + createSelectors — typed auth store |
src/lib/api.ts |
createApiClient + createQueryKeys — HTTP client + cache |
Let's look at the three most important ones.
vite.config.ts → createViteConfig
import { createViteConfig } from "tempest-react-sdk/vite";
export default createViteConfig();
One line. createViteConfig already wires up the React plugin, the @ -> ./src alias, and the defaults the SDK expects. See the Vite Config page to customize.
src/App.tsx → AppProviders + AppRouter
import { AppProviders, AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";
export function App() {
return (
<AppProviders errorBoundary={{ fallback: <p>Something went wrong.</p> }}>
<AppRouter routes={routes} fallback={<p>Loading…</p>} />
</AppProviders>
);
}
AppProviders mounts React Query, the error boundary, the theme, and the router in one shot. AppRouter consumes the routes array and renders the fallback while lazy routes load. Details in App Providers.
src/stores/auth.ts → createAuthStore + createSelectors
import { createAuthStore, createSelectors } from "tempest-react-sdk";
export interface User {
id: string;
name: string;
email: string;
}
export const useAuth = createSelectors(createAuthStore<User>({ name: "app-auth" }));
createAuthStore<User> creates a persisted Zustand auth store (name: "app-auth" is the storage key). createSelectors gives you atomic access via useAuth.use.<field>() — that's how RootLayout.tsx reads useAuth.use.isAuthenticated() and Login.tsx calls useAuth.use.setSession(). More patterns in State.
The rest is self-explanatory
routes.tsx uses defineRoutes([...]) with an index route, a login route, and a dashboard that is both lazy and guarded. lib/api.ts instantiates createApiClient with baseURL/getToken/onUnauthorized and exports createQueryKeys so you can organize your cache keys.
PWA mode (--pwa)
Want the app to be installable (home-screen icon), able to emit web push notifications, and to work offline (app shell + cache) out of the box? Pass the --pwa flag:
npx -p tempest-react-sdk create-tempest-app . --pwa
The flag works in both modes (new folder and ./merge). It overlays the PWA template on top of the base: everything from the normal app stays the same, plus a few new files and a few overwritten ones.
No vite-plugin-pwa, no Workbox
Install, push and offline caching are assembled from the SDK's own helpers (tempest-react-sdk/sw + tempest-react-sdk/vite) and bundled by a dedicated build. No extra PWA dependency — the PWA app uses the exact same deps as the base app. (Full comparison vs vite-plugin-pwa below.)
What the flag adds
my-app/
├── index.html # (overwritten) manifest link + theme-color + apple metas
├── vite.config.ts # (overwritten) createViteConfig + tempestPwaIcons + Manifest + DevSw
├── vite.sw.config.ts # dedicated build that bundles src/sw.ts -> dist/sw.js
├── public/
│ ├── manifest.webmanifest # install metadata (points at the generated PNGs)
│ └── icon.svg # source icon (swap for yours — the PNGs come from it)
└── src/
├── sw.ts # service worker: push + notificationclick + skip-waiting + cache
├── main.tsx # (overwritten) registers /sw.js in dev and prod
├── vite-env.d.ts # (overwritten) types VITE_VAPID_PUBLIC_KEY
└── pages/Dashboard.tsx # (overwritten) Install button + notifications toggle
package.json is patched too: the build script now bundles the SW (tsc --noEmit && vite build && npm run build:sw) and gains a build:sw; sharp is added as a devDependency (generates the icons). The build emits a dist/precache-manifest.json (the asset list for offline caching) via tempestPwaManifest() and the PNG icon set (dist/icons/*.png + apple-touch-icon.png) via tempestPwaIcons().
In merge mode, your files are preserved
In . mode (merging into an existing project), the CLI never overwrites a file of yours — only the ones it just generated. If you already had an index.html, it is skipped and reported, and its PWA bits are up to you.
The five pieces
1. Install → useBeforeInstallPrompt
index.html links the manifest.webmanifest, and Dashboard.tsx shows an Install button only when the browser offers the prompt:
const install = useBeforeInstallPrompt();
// ...
{
install.installable && <Button onClick={() => void install.prompt()}>Install app</Button>;
}
2. Service worker → tempest-react-sdk/sw
src/sw.ts is just glue over the SDK helpers:
/// <reference lib="webworker" />
import {
installNotificationClickHandler,
installPushHandler,
installSkipWaitingListener,
} from "tempest-react-sdk/sw";
installPushHandler({ defaultTitle: "Notificação", defaultIcon: "/icon.svg" });
installNotificationClickHandler();
installSkipWaitingListener();
vite.sw.config.ts bundles that file (and the helpers it imports) into a classic service worker at dist/sw.js, and main.tsx registers it via registerServiceWorker. In dev, the tempestPwaDevSw() plugin compiles sw.ts on the fly and serves it at /sw.js — so push and caching work under npm run dev too (without it, the SW would only exist in the build). Compilation goes through one incremental esbuild context, created on the first request: the browser re-fetches the worker script on every navigation and on its own update checks, so a cold bundle per request would mean cold bundles all day. See the helper details in Web Push.
3. Web push → usePushSubscription
Dashboard.tsx wires the notifications toggle to the hook, reading the VAPID key from .env:
const push = usePushSubscription({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY ?? "",
onSubscribe: async (subscription) => {
// send the subscription to your backend to deliver pushes
await api.post("/webpush/subscribe", { body: subscription });
},
onUnsubscribe: async () => {
await api.delete("/webpush/my");
},
});
4. Offline → installPrecache + installRuntimeCache
vite.config.ts adds the tempestPwaManifest() plugin, which emits a dist/precache-manifest.json listing every built asset (the dependency-free counterpart to Workbox's __WB_MANIFEST). In sw.ts, two helpers consume it:
import { installPrecache, installRuntimeCache } from "tempest-react-sdk/sw";
// Specific routes FIRST (they win over the precache catch-all):
installRuntimeCache([
{
match: (url) => url.pathname.startsWith("/api/"),
strategy: "network-first", // or "cache-first" / "stale-while-revalidate"
cacheName: "api",
networkTimeoutSeconds: 5,
maxEntries: 50,
maxAgeSeconds: 60 * 5,
},
]);
// App shell last — launches offline:
installPrecache({ navigateFallback: "/index.html", navigateFallbackDenylist: [/^\/api\//] });
installPrecachecaches the app shell oninstall, serves assets cache-first, and returns thenavigateFallback(SPA) when a navigation happens offline. It versions the cache by the manifest'sversionand cleans up old versions onactivate.installRuntimeCacheapplies per-route strategies (cache-first / network-first / stale-while-revalidate) withmaxEntriesandmaxAgeSeconds.
5. Icons → tempestPwaIcons
vite.config.ts adds tempestPwaIcons({ source: "public/icon.svg" }), which at build time rasterizes a single source SVG into the full icon set — the dependency-free counterpart to @vite-pwa/assets-generator:
import { tempestPwaIcons } from "tempest-react-sdk/vite";
tempestPwaIcons({ source: "public/icon.svg" });
// emits: dist/icons/icon-192.png, icon-512.png, maskable-512.png, dist/apple-touch-icon.png
Rasterization uses sharp (already a template devDependency). The manifest.webmanifest points at the generated PNGs, and tempestPwaManifest() includes them in the precache automatically. Changing the app icon = swapping public/icon.svg.
sharp is optional
The plugin imports sharp lazily: if it isn't installed the build doesn't fail — it just logs a warning and skips generation. The template ships sharp as a devDep, so it works out of the box.
Push and offline only work in a production build
Icon generation happens at build time, and the app shell is only precached after npm run build. In npm run dev the tempestPwaDevSw() plugin serves the SW (push + runtime caching work), but offline precache and the PNGs only exist in the build. To test full install + offline, run npm run build && npm run preview (then tick Offline under DevTools › Network to watch the app shell serve).
--pwa vs vite-plugin-pwa
--pwa now covers the same ground as vite-plugin-pwa for the common case, with no new runtime dependency:
| Feature | vite-plugin-pwa (Workbox) |
--pwa (SDK) |
|---|---|---|
| Manifest + installable | ✅ | ✅ |
| Install prompt | manual | ✅ useBeforeInstallPrompt |
Web push + notificationclick |
you write it | ✅ SDK helpers |
| Update flow (skip-waiting) | ✅ | ✅ registerServiceWorker |
| App-shell precache | ✅ (__WB_MANIFEST) |
✅ tempestPwaManifest + installPrecache |
| Runtime caching (cache/network/SWR) | ✅ | ✅ installRuntimeCache |
navigateFallback (SPA offline) |
✅ | ✅ |
| Old-cache cleanup | ✅ | ✅ (version on activate) |
| Automatic icon generation | ✅ (sharp) | ✅ tempestPwaIcons (sharp, optional) |
| SW in dev | ✅ (devOptions) |
✅ tempestPwaDevSw (esbuild) |
| Background Sync | ✅ (BackgroundSyncPlugin) |
✅ installBackgroundSync |
| Range requests (media) | ✅ (RangeRequestsPlugin) |
✅ installRuntimeCache({ rangeRequests }) |
| Splash screens (Apple) | ✅ (assets generator) | ✅ tempestPwaIcons({ appleSplash }) |
Full coverage of the common case. For very specific edges (partial-content precache, exotic Workbox strategies), vite-plugin-pwa still exists and can be passed via plugins: [...] in createViteConfig.
Advanced features
Background Sync — offline mutations that resend themselves
installBackgroundSync queues POST/PUT/PATCH/DELETE that fail offline (in IndexedDB) and replays them when connectivity returns — via the Background Sync API where available, opportunistically (on the next request) where not:
import { installBackgroundSync } from "tempest-react-sdk/sw";
installBackgroundSync({ match: (url) => url.pathname.startsWith("/api/") });
The original fetch still rejects for now (your app shows an offline state), but the request is replayed later. maxRetentionMinutes drops stale entries; 4xx responses are discarded (a client error won't fix itself).
Range requests — offline audio/video seeking
Mark a route with rangeRequests: true to serve 206 Partial Content by slicing the cached resource — without it, media can't seek offline:
installRuntimeCache([
{
match: (url) => /\.(mp3|mp4|webm)$/.test(url.pathname),
strategy: "cache-first",
cacheName: "media",
rangeRequests: true,
},
]);
The createPartialResponse(request, response) helper is also exported for manual use.
Apple splash screens — iOS launch images
tempestPwaIcons({ appleSplash: true }) generates the per-device launch images (iPhone/iPad, portrait) and injects the <link rel="apple-touch-startup-image" media=...> tags into index.html. Pass an array to customize the sizes:
tempestPwaIcons({
source: "public/icon.svg",
appleSplash: [{ width: 390, height: 844, ratio: 3 }],
});
PWA mode recap
--pwa hands you manifest + service worker + push + offline cache + generated icons + SW in dev + background sync + range requests + splash screens, already wired on top of the same base app, using tempest-react-sdk/sw (installPushHandler, installPrecache, installRuntimeCache, installBackgroundSync, createPartialResponse), tempest-react-sdk/vite (tempestPwaManifest, tempestPwaIcons, tempestPwaDevSw), usePushSubscription and useBeforeInstallPrompt — without vite-plugin-pwa. Generate the VAPID key on your backend, fill in VITE_VAPID_PUBLIC_KEY in .env, swap public/icon.svg, and test with npm run build && npm run preview. 🚀
Next steps
With the app running, here's how to grow from it:
1. Add a page
Create src/pages/About.tsx and register the route in src/routes.tsx:
import { defineRoutes } from "tempest-react-sdk";
export const routes = defineRoutes([
// ...existing routes
{ path: "/about", element: <About /> },
]);
2. Add a store
For non-auth state, use the SDK's createStore:
import { createStore } from "tempest-react-sdk";
export const useCounter = createStore<{ count: number; inc: () => void }>((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}));
3. Fetch data with React Query + queryKeys + api
Combine the HTTP client, the query keys, and useQuery (already available through AppProviders):
import { useQuery } from "@tanstack/react-query";
import { api, queryKeys } from "@/lib/api";
export function UserList() {
const { data, isLoading } = useQuery({
queryKey: queryKeys.users.all,
queryFn: () => api.get("/users"),
});
if (isLoading) return <p>Loading…</p>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
Recap
- The CLI is
tempest-react-sdk's ownbin(create-tempest-app), with a bundledtemplate/in the tarball — versioned together with the SDK, not a separate package. ✅ - Recommended path:
mkdir my-app && cd my-app && npx -p tempest-react-sdk create-tempest-app .—.(same as running with no argument) generates into the current directory, preserves existing files and takes the project name from the folder. Use@Xon-pto pin the SDK version. - Named mode:
npx -p tempest-react-sdk create-tempest-app my-appcreates the folder but aborts if it already exists and isn't empty. The CLI never prompts for a project name. - Existing project:
npm install tempest-react-sdkthennpx create-tempest-app .(no-p— thebincomes fromnode_modules) —package.jsonhas scripts/deps merged (tempest-react-sdkpinned to the SDK version). npm install && cp .env.example .env && npm run devtakes you to http://127.0.0.1:5173 with providers, routes, and auth working.- Every generated file demonstrates a feature:
createViteConfig,AppProviders+AppRouter,defineRoutes(lazy + guard),createAuthStore+createSelectors,createApiClient+createQueryKeys. - To grow: add pages in
pages/+ entries inroutes.tsx, create stores withcreateStore, and fetch data withuseQuery+queryKeys+api.