create-fronton
v0.7.0
Published
Scaffolds a new frontend project on fronton — an opinionated Vite + React + TypeScript + Tailwind setup with build-time Tailwind class obfuscation, gzip/brotli compression, and backon backend wiring baked in. Runs via bunx/bun create or npx/npm create.
Readme
create-fronton
Scaffolds a new frontend project on @mednours/fronton — like bun create vite@latest, but opinionated by default: Vite + React 19 + TypeScript + Tailwind v4, with build-time Tailwind class obfuscation and gzip/brotli compression already wired in, and a backon backend's dev-server proxy set up automatically if one is found next to it.
Usage
bun create fronton my-appOr, without the bun create shorthand:
bunx create-fronton my-appLeave the name off (or use . to scaffold into the current directory, same as bun create vite@latest .) to be prompted for one:
bunx create-frontonEither way, you'll always be asked two questions: where's this deploying? (Vercel / Hostinger or another static/shared host / other — not sure yet), since that changes which file gets written, and a one-line site description, used to fill in index.html's SEO/social-preview meta tags (leave it blank and a generic placeholder goes in instead — nothing blocks on it):
my-app/
package.json
tsconfig.json
index.html
vite.config.ts
vercel.json <- Vercel only
public/
.htaccess <- Hostinger (or another static/shared host) only
.env.example
src/
main.tsx
App.tsx
index.cssPicking "other / not sure yet" skips both — no host-specific file, just a README pointer to build dist/ and deploy it wherever you land.
To skip every question and take the defaults (Vercel, no description, no native wrapper), pass --yes:
bunx create-fronton my-app --yesThe prompts need a real TTY — @clack/prompts throws ERR_TTY_INIT_FAILED without one — so --yes is what makes this usable from CI, a container build, or any other script. A project name is required in that mode, since there's nothing to prompt.
index.html always gets the full title/description/Open Graph/Twitter card block, not just Vercel/Hostinger-conditional files — checked against six real fronton-adjacent projects, five of which hand-write 13-16 of these tags with no helper library, so it's the one piece of boilerplate worth generating regardless of deploy target. og:image/twitter:image point at /og-image.png, which doesn't exist yet — add a real 1200x630 image at public/og-image.png once you have one.
What's opinionated, and why there's barely a prompt for it
Unlike create-backon (which asks about framework, database, and deploy target), create-fronton asks about almost nothing else — the entire point is not re-deciding the same handful of things per project:
- React only, on Vite. No framework prompt.
- Tailwind class obfuscation and gzip/brotli compression are always on in the generated
vite.config.ts. Both are one line to disable if you don't want them (obfuscateTailwind({ enabled: false }), or remove thefrontonCompression()line) — see@mednours/fronton's README for exactly what each one does and its limitations.
None of this is hard-locked into the generated code — it's all just the starting vite.config.ts, plain and editable. The opinion is about what you get without asking, not about taking anything away.
Not wired in by default: @mednours/fronton also ships compressImages() and compressVideos() (sharp and ffmpeg-based build-time media compression). They're left out of the generated vite.config.ts on purpose — their dependencies are large native binaries most projects have no use for — see @mednours/fronton's README for how to add either one.
Installable web app (PWA)
Answer yes to "Make it installable as a PWA?" and the generated project gets
vite-plugin-pwa wired in: a web manifest built from the project
name and description you gave, placeholder icons at public/icon-192.png and public/icon-512.png,
and a service worker that precaches the built app shell. bun run build produces sw.js and
manifest.webmanifest alongside the usual output; nothing else to configure.
What it deliberately doesn't do: push notifications, background sync, or runtime caching of API
responses. The last one especially is a decision about your backend's data — cache the wrong
endpoint and you serve stale responses with no obvious cause — so vite.config.ts is left as the
place to add a workbox.runtimeCaching rule when you actually know what you want.
Updates use registerType: "prompt", so a newly deployed version waits rather than reloading the
page under someone mid-task. The generated README shows how to surface that prompt.
This question is only asked when you haven't picked a native wrapper below: Capacitor and Tauri already produce an installed app loading its assets locally, so a service worker precaching the same shell is a second caching layer that buys nothing.
Native app wrappers (Capacitor / Tauri)
A fourth question — "Add a native app wrapper?" — is always asked too: none (the default), Capacitor, or Tauri. The two aren't offered as a combined "pick several" choice on purpose — see templates/mobile.ts and templates/tauri.ts's own header comments for why each is scoped the way it is, and pick whichever fits: Capacitor if you only need Android and want the smaller, JS-only footprint; Tauri if you want desktop too (macOS/Linux/Windows) from the same codebase, and don't mind Rust being part of the project.
Capacitor (Android only)
my-app/
capacitor.config.ts
.github/
workflows/
mobile.ymlplus a relative base: "./" in vite.config.ts and safe-area CSS in src/index.css (a Capacitor WebView loads the built app from a file:// origin and draws edge-to-edge under the status bar/gesture nav by default — both are inert in a normal browser tab), @capacitor/core/@capacitor/android/@capacitor/cli in package.json, and cap:add:android/cap:sync/cap:open:android scripts.
What it deliberately doesn't do: write a real android/ directory to disk. That step needs network access and @capacitor/android already resolved, so rather than risk scaffolding failing for reasons that have nothing to do with writing files, it's a documented bun run cap:add:android you run once, when you're ready. .github/workflows/mobile.yml doesn't wait on that either — it runs cap add android itself if the directory isn't already committed, builds a debug APK, and uploads it as a workflow artifact, so a brand-new scaffold gets a working CI build on its first push.
Two things worth doing before this is a real app rather than a placeholder: swap capacitor.config.ts's appId away from the com.example.* placeholder (Android treats it as the app's permanent identity, not something safe to change after the fact), and add real signing once you need a release build — the CI workflow only produces an unsigned debug APK. iOS isn't wired in yet; that needs a Mac to build, which this template's CI doesn't have.
Tauri (Android + desktop, one codebase)
my-app/
src-tauri/
Cargo.toml
build.rs
src/
main.rs
lib.rs
tauri.conf.json
capabilities/
default.json
icons/
icon.png
.github/
workflows/
tauri.ymlsrc-tauri/ is a real Rust crate — one run() in src/lib.rs, shared by both the desktop build and the Android build via a #[cfg_attr(mobile, tauri::mobile_entry_point)] attribute, which is the actual "one codebase" difference from Capacitor's mobile-only wrapper. vite.config.ts also gets Tauri's own requirements: a fixed dev-server port (1420), clearScreen: false, src-tauri/ excluded from Vite's file watcher, and TAURI_DEV_HOST support for testing on a physical Android device — merged with the backon dev-proxy config when one applies, not replacing it. @tauri-apps/api, @tauri-apps/plugin-opener, and @tauri-apps/cli get added to package.json, plus a tauri script.
icons/icon.png is the one binary file in an otherwise pure-text scaffold — a solid-color placeholder, but a real, valid PNG, because tauri::generate_context!() (a compile-time macro) requires an actual icon file on disk just to cargo check, independent of whatever tauri.conf.json's bundle.icon says. Swap it with bun run tauri icon /path/to/art.png once you have real artwork.
Same "don't shell out during scaffold" discipline as Capacitor: nothing here runs cargo or tauri android init, so src-tauri/gen/android isn't written to disk yet either. .github/workflows/tauri.yml builds desktop bundles for macOS (Intel + Apple Silicon), Linux, and Windows, plus a debug Android APK — both jobs generate what they need fresh in CI if it isn't committed, so a brand-new scaffold gets a working CI build on its first push, same as Capacitor's.
Requires Rust locally for bun run tauri dev/build (see rust-lang.org/tools/install); Android additionally needs the Android SDK/NDK (see Tauri's prerequisites guide) — CI needs none of this set up locally first. Two things worth doing before this is a real app: swap tauri.conf.json's identifier away from the com.example.* placeholder (same permanent-identity caveat as Capacitor's appId), and add code signing before a release build — the CI workflow only produces unsigned/debug output.
Backend detection
If a sibling backend/, server/, or api/ folder exists next to where you're scaffolding, and its package.json depends on @mednours/backon, create-fronton wires up the dev-server proxy automatically:
workspace/
backend/ <- a backon project, package.json depends on @mednours/backon
frontend/ <- scaffolded here — proxy to ../backend is wired in automaticallyThe generated vite.config.ts gets a server.proxy entry (via backonProxy() from @mednours/fronton) pointed at the backend's port — read from its .env/.env.example, defaulting to 3000 (create-backon's own default) if neither declares one. PORT=3000, PORT="3000", PORT = 3000 and export PORT=3000 all parse; a commented-out # PORT= correctly doesn't, and falls through to the next file. WebSocket upgrades on the same prefix are forwarded too, so a backon defineWebSocket() route works through bun run dev without extra setup. src/App.tsx also gets a small real example: a fetch through the proxy on mount, showing whether the backend actually responded, instead of a placeholder claiming it works.
No backend detected? The project generates exactly the same way, just without that piece — the README it writes explains how to add the proxy by hand later.
This is intentionally the extent of the backon integration for now — wiring (proxy, env, CORS guidance), not generated data-fetching hooks around backon's own generate-client output. See the monorepo README for where that might go next.
Routing and pages
Answer yes to "Add routing?" and the generated project gets react-router, an explicit route table at
src/routes.tsx, and pages under src/pages/:
bun run add-page about # -> src/pages/About.tsx at /about
bun run add-page users/:id # -> src/pages/UsersId.tsx at /users/:id
bun run add-component button # -> src/components/Button.tsxadd-page writes the component and inserts its lazy import and route entry into src/routes.tsx,
above the catch-all, at the fronton:pages / fronton:routes markers. That second edit is the
reason the command exists — it's the step that gets forgotten, and forgetting the lazy() wrapper
specifically is how code-splitting quietly stops happening.
Components are grouped by the page they belong to, or shared/ for anything used by more than one:
src/components/
shared/NavBar.tsx # used across pages
home/Hero.tsx # only on /
about/Faq.tsx # only on /aboutadd-component hero goes to shared/; add-component home/hero goes to home/. The grouping is
the point — a component's location says where it's used, so deleting a page tells you exactly which
components can go with it, which a flat src/components/ stops answering about ten components in.
If the group matches no page in src/routes.tsx, you get a warning rather than a refusal: hom/hero
is usually a typo, but grouping by a feature instead of a route is a legitimate choice.
Generated components have the imports already there (clsx, ReactNode), a named export, and an
exported props interface — named rather than default because components aren't lazy-loaded the way
pages are, and a named export is what makes a rename propagate through editor tooling.
What they deliberately don't have is memo/useCallback/useMemo. See below.
React Compiler
Opt-in at scaffold time. It wires react({ compiler: true }) in vite.config.ts and adds
oxc-transform-react, the Rust port of React Compiler that @vitejs/plugin-react 6 loads as an
optional peer dependency. The compiler auto-memoizes, so hand-written memo/useCallback/useMemo
stop being something to think about — which is why generated components carry none.
Two honest caveats. @vitejs/plugin-react still labels native compiler support experimental, so
this is off by default. And it isn't free: it emits a small compiler-runtime chunk and grows each
compiled component (a 450-byte component became 977 bytes in a real build here), so what it buys is
fewer re-renders, not a smaller bundle.
Without it, the advice in the generated components still holds — reach for memo when a profile
says to, not by reflex. memo on a component taking an object or callback prop re-renders anyway,
since a fresh reference fails the shallow compare.
Why an explicit table, not file-based routing
File-based routing (vite-plugin-pages, generouted) makes a file in src/pages/ into a route with
no table at all, which is genuinely less to maintain — and for a long-lived project it's probably
the better call. It loses on three counts here: it's another build plugin that can misbehave,
routing moves into filename conventions you have to remember, and nothing answers "what routes exist"
except the file tree.
Its real advantage — a lazy chunk per page, so a first visit downloads only the route it landed on —
isn't exclusive to it, and isn't given up here: every generated route is React.lazy(), and
add-page writes new ones the same way. Verified on a real build: four pages come out as four
separate chunks, and a first paint loads the landed route's chunk alone.
Routing also adds an SPA fallback rewrite to vercel.json, without which a refresh or a shared deep
link on /about 404s. (Vercel applies rewrites after the filesystem check, so it can't shadow the
hashed assets.) The Hostinger .htaccess already did this job.
Typed API client (sync-api)
bunx create-fronton sync-api # or `bun run sync-api` inside a generated projectbackon already generates a typed client — bun run generate-client reads its own src/endpoints/
and writes client/api.ts, one function per mounted method. The problem is that it lands inside
the backend project, gitignored there as a build artifact, with no route to the frontend but
copy-paste, which reintroduces exactly the drift the generator existed to prevent.
sync-api re-runs that generator in the sibling backend, then copies the result to
src/lib/api.ts. Calling the API becomes usersApi.getUser(id) instead of a hand-written fetch
you keep in step by remembering to.
Copying it verbatim doesn't work, which is the reason this is a command and not a documentation note. Three things are rewritten:
process.env.API_BASE_URLbecomesresolveApiBaseUrl(). In a Vite bundleprocessdoesn't exist, and backon's guarded version doesn't throw — it falls through to"", so a split-origin deploy quietly calls its own origin.- The base URL gains the
/apiprefix in dev. The generated paths are the backend's real ones (/users), and the dev proxy is what owns/apiand strips it again before forwarding. Verbatim, the request never matches the proxy, Vite servesindex.htmlas an SPA fallback, and the call fails withUnexpected token '<', "<!doctype "... is not valid JSON— verified against a real dev server, which is how this was found. - An unused
import type { z } from "zod"is dropped. backon emits it either way, and an unused import still has to resolve, so it failsbun run typecheckon a frontend with no zod.
The synced file is committed rather than gitignored, unlike backon's copy: a deploy builds the frontend on its own, with no backend checkout next to it to regenerate from.
Requirements
Bun >= 1.3.
Part of the fronton monorepo.
