diff --git a/.github/workflows/codebase-deps.yaml b/.github/workflows/codebase-deps.yaml
index f9a60b9a3..1b54435d5 100644
--- a/.github/workflows/codebase-deps.yaml
+++ b/.github/workflows/codebase-deps.yaml
@@ -34,7 +34,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Resolve Update Mode
id: mode
diff --git a/.github/workflows/dep-freshness.yaml b/.github/workflows/dep-freshness.yaml
index 3c21186b9..361c73feb 100644
--- a/.github/workflows/dep-freshness.yaml
+++ b/.github/workflows/dep-freshness.yaml
@@ -21,7 +21,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Install Dependencies
run: bun install --frozen-lockfile
@@ -47,7 +47,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Install Dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/deps-check.yaml b/.github/workflows/deps-check.yaml
index 878fe811c..ed79113ba 100644
--- a/.github/workflows/deps-check.yaml
+++ b/.github/workflows/deps-check.yaml
@@ -43,7 +43,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Cache Dependencies
uses: actions/cache@v4
@@ -227,7 +227,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Setup Node
uses: actions/setup-node@v4
diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml
index 90a069bd5..75a573a75 100644
--- a/.github/workflows/e2e-test.yaml
+++ b/.github/workflows/e2e-test.yaml
@@ -37,12 +37,19 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- uses: actions/setup-node@v4
with:
node-version: "22"
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - uses: astral-sh/setup-uv@v5
+
- uses: actions/cache@v4
with:
path: |
@@ -84,12 +91,19 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- uses: actions/setup-node@v4
with:
node-version: "22"
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - uses: astral-sh/setup-uv@v5
+
- uses: actions/cache@v4
with:
path: |
@@ -142,12 +156,19 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- uses: actions/setup-node@v4
with:
node-version: "22"
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - uses: astral-sh/setup-uv@v5
+
- uses: actions/cache@v4
with:
path: |
@@ -200,7 +221,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- uses: actions/setup-node@v4
with:
@@ -251,7 +272,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- uses: actions/setup-node@v4
with:
@@ -296,7 +317,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- uses: actions/setup-node@v4
with:
diff --git a/.github/workflows/pr-preview.yaml b/.github/workflows/pr-preview.yaml
index 7941e6f25..70af9dec1 100644
--- a/.github/workflows/pr-preview.yaml
+++ b/.github/workflows/pr-preview.yaml
@@ -28,7 +28,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Cache Dependencies
uses: actions/cache@v4
diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml
index 283ff1898..469599298 100644
--- a/.github/workflows/pr-size.yml
+++ b/.github/workflows/pr-size.yml
@@ -61,7 +61,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
- issues: read
+ issues: write
pull-requests: write
concurrency:
group: pr-size-${{ github.event.pull_request.number }}
diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml
index 2da75570e..5dcd7f7c6 100644
--- a/.github/workflows/smoke-test.yaml
+++ b/.github/workflows/smoke-test.yaml
@@ -59,7 +59,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Setup Node
uses: actions/setup-node@v4
diff --git a/.github/workflows/template-matrix.yaml b/.github/workflows/template-matrix.yaml
index e72420885..c5f223300 100644
--- a/.github/workflows/template-matrix.yaml
+++ b/.github/workflows/template-matrix.yaml
@@ -43,7 +43,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Setup Rust
if: startsWith(matrix.preset, 'rust-')
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 83c7cb14c..cff887e08 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -22,7 +22,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Cache Dependencies
uses: actions/cache@v4
@@ -51,7 +51,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Cache Dependencies
uses: actions/cache@v4
@@ -75,6 +75,9 @@ jobs:
- name: Validate Builder Tech Links
run: bun run --cwd apps/web validate:tech-links
+ - name: Validate Builder Tech Icons
+ run: bun run --cwd apps/web validate:tech-icons
+
- name: Run Lint
run: bun run lint
@@ -89,7 +92,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Setup Deno
uses: denoland/setup-deno@v2
@@ -124,7 +127,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Cache Dependencies
uses: actions/cache@v4
diff --git a/.github/workflows/upstream-gap.yml b/.github/workflows/upstream-gap.yml
index efb04d219..e7569ea6a 100644
--- a/.github/workflows/upstream-gap.yml
+++ b/.github/workflows/upstream-gap.yml
@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.12
- name: Generate upstream gap report
run: bun run scripts/upstream-gap-report.ts --markdown > upstream-gap-report.md
diff --git a/README.md b/README.md
index f589edd54..e8d722c3d 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
-**Scaffold production-ready fullstack apps in seconds. Browse 450+ tools across five ecosystems — the CLI wires everything together.**
+**Scaffold production-ready fullstack apps in seconds. Browse 450+ tools across six ecosystems — the CLI wires everything together.**
@@ -35,7 +35,7 @@
Most scaffolding tools lock you into one framework and one opinion. Better Fullstack doesn't.
- **450+ tools** — frontend, backend, database, auth, payments, AI, DevOps, and more
-- **5 ecosystems** — TypeScript, Rust, Python, Go, Java — with more coming
+- **6 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java — with more coming
- **Visual builder** — configure your stack in the browser, get a ready-to-run CLI command
- **Wired for you** — no manual glue code; every picked integration is preconfigured and working out of the box
@@ -113,8 +113,8 @@ Better Fullstack is organized around the decisions that matter: pick an ecosyste
Only the relevant options surface for the stack you pick.
-5 ecosystems
-TypeScript, Rust, Python, Go, Java.
+6 ecosystems
+TypeScript, React Native, Rust, Python, Go, Java.
One command
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 45f4e37af..6ab277902 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -32,7 +32,7 @@ Configure your stack visually — pick every option from a UI, preview your choi
## Features
- **425 options** — frontend, backend, database, auth, payments, AI, DevOps, and more
-- **5 ecosystems** — TypeScript, Rust, Python, Go, Java
+- **7 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java, Elixir
- **Visual builder** — configure your stack in the browser
- **Wired for you** — every picked integration is preconfigured and working out of the box
@@ -42,7 +42,7 @@ Configure your stack visually — pick every option from a UI, preview your choi
--yes # Accept all defaults
--yolo # Scaffold a random stack — good for exploring
--template # Use a preset (t3, mern, pern, uniwind)
---ecosystem # Start in typescript, rust, python, go, or java mode
+--ecosystem # Start in typescript, react-native, rust, python, go, java, or elixir mode
--version-channel # Dependency channel: stable, latest, beta
--no-git # Skip git initialization
--no-install # Skip dependency installation
diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts
index edeb7abbb..3f8fec3db 100644
--- a/apps/cli/src/constants.ts
+++ b/apps/cli/src/constants.ts
@@ -30,6 +30,8 @@ export function getDefaultConfig() {
pythonAi: [...DEFAULT_CONFIG_BASE.pythonAi],
javaLibraries: [...DEFAULT_CONFIG_BASE.javaLibraries],
javaTestingLibraries: [...DEFAULT_CONFIG_BASE.javaTestingLibraries],
+ elixirLibraries: [...DEFAULT_CONFIG_BASE.elixirLibraries],
+ elixirTesting: [...DEFAULT_CONFIG_BASE.elixirTesting],
aiDocs: [...DEFAULT_CONFIG_BASE.aiDocs],
};
}
diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts
index cde519651..be25ed7a5 100644
--- a/apps/cli/src/create-command-input.ts
+++ b/apps/cli/src/create-command-input.ts
@@ -19,11 +19,22 @@ import {
EcosystemSchema,
EffectSchema,
EmailSchema,
+ ElixirDatabaseSchema,
+ ElixirLibrariesSchema,
+ ElixirTestingSchema,
+ ElixirWebFrameworkSchema,
ExamplesSchema,
FeatureFlagsSchema,
FileStorageSchema,
FileUploadSchema,
FormsSchema,
+ MobileDeepLinkingSchema,
+ MobileNavigationSchema,
+ MobileOTASchema,
+ MobilePushSchema,
+ MobileStorageSchema,
+ MobileTestingSchema,
+ MobileUISchema,
FrontendSchema,
GoApiSchema,
GoAuthSchema,
@@ -98,7 +109,9 @@ export const CreateCommandOptionsSchema = z.object({
.optional()
.default(false)
.describe("Preview generated file tree without writing to disk"),
- ecosystem: EcosystemSchema.optional().describe("Language ecosystem (typescript, rust, python, go, or java)"),
+ ecosystem: EcosystemSchema.optional().describe(
+ "Language ecosystem (typescript, react-native, rust, python, go, java, or elixir)",
+ ),
database: DatabaseSchema.optional(),
orm: ORMSchema.optional(),
auth: AuthSchema.optional(),
@@ -123,6 +136,13 @@ export const CreateCommandOptionsSchema = z.object({
i18n: I18nSchema.optional().describe("Internationalization (i18n) library"),
search: SearchSchema.optional().describe("Search engine solution"),
fileStorage: FileStorageSchema.optional().describe("File storage solution (S3, R2)"),
+ mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation (expo-router, react-navigation)"),
+ mobileUI: MobileUISchema.optional().describe("Mobile UI (tamagui, gluestack-ui, uniwind, unistyles)"),
+ mobileStorage: MobileStorageSchema.optional().describe("Mobile storage (mmkv)"),
+ mobileTesting: MobileTestingSchema.optional().describe("Mobile testing (maestro, react-native-testing-library)"),
+ mobilePush: MobilePushSchema.optional().describe("Mobile push notifications (expo-notifications)"),
+ mobileOTA: MobileOTASchema.optional().describe("Mobile OTA updates (expo-updates)"),
+ mobileDeepLinking: MobileDeepLinkingSchema.optional().describe("Mobile deep linking (expo-linking)"),
frontend: z.array(FrontendSchema).optional(),
astroIntegration: AstroIntegrationSchema.optional().describe(
"Astro UI framework integration (react, vue, svelte, solid)",
@@ -219,6 +239,15 @@ export const CreateCommandOptionsSchema = z.object({
.array(JavaTestingLibrariesSchema)
.optional()
.describe("Java testing libraries"),
+ elixirWebFramework: ElixirWebFrameworkSchema.optional().describe(
+ "Elixir web framework (phoenix, none)",
+ ),
+ elixirDatabase: ElixirDatabaseSchema.optional().describe("Elixir database layer (ecto, none)"),
+ elixirLibraries: z
+ .array(ElixirLibrariesSchema)
+ .optional()
+ .describe("Elixir application libraries"),
+ elixirTesting: z.array(ElixirTestingSchema).optional().describe("Elixir testing libraries"),
aiDocs: z
.array(AiDocsSchema)
.optional()
diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts
index a40bc0398..214cf5010 100644
--- a/apps/cli/src/helpers/core/command-handlers.ts
+++ b/apps/cli/src/helpers/core/command-handlers.ts
@@ -170,6 +170,13 @@ export async function createProjectHandler(
featureFlags: "none",
analytics: "none",
fileStorage: "none",
+ mobileNavigation: "none",
+ mobileUI: "none",
+ mobileStorage: "none",
+ mobileTesting: "none",
+ mobilePush: "none",
+ mobileOTA: "none",
+ mobileDeepLinking: "none",
pythonWebFramework: "none",
pythonOrm: "none",
pythonValidation: "none",
@@ -191,6 +198,10 @@ export async function createProjectHandler(
javaAuth: "none",
javaLibraries: [],
javaTestingLibraries: [],
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: [],
+ elixirTesting: [],
aiDocs: [],
} satisfies ProjectConfig,
reproducibleCommand: "",
diff --git a/apps/cli/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts
index aca78c577..d541dceb0 100644
--- a/apps/cli/src/helpers/core/create-project.ts
+++ b/apps/cli/src/helpers/core/create-project.ts
@@ -21,6 +21,7 @@ import {
runCargoBuild,
runGradleTests,
runMavenTests,
+ runMixDepsGet,
runUvSync,
runGoModTidy,
} from "./install-dependencies";
@@ -66,8 +67,11 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj
if (!isSilent()) log.success("Project template successfully scaffolded!");
- // Skip npm/pnpm/bun install for Rust/Python projects (they use cargo/uv)
- if (options.install && options.ecosystem === "typescript") {
+ // Skip npm/pnpm/bun install for Rust/Python/Go/Java projects (they use native toolchains)
+ if (
+ options.install &&
+ (options.ecosystem === "typescript" || options.ecosystem === "react-native")
+ ) {
await installDependencies({
projectDir,
packageManager: options.packageManager,
@@ -102,6 +106,11 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj
}
}
+ // Run mix deps.get for Elixir projects
+ if (options.install && options.ecosystem === "elixir") {
+ await runMixDepsGet({ projectDir });
+ }
+
await initializeGit(projectDir, options.git);
if (!isSilent()) {
diff --git a/apps/cli/src/helpers/core/install-dependencies.ts b/apps/cli/src/helpers/core/install-dependencies.ts
index 7dc90898b..a491089d0 100644
--- a/apps/cli/src/helpers/core/install-dependencies.ts
+++ b/apps/cli/src/helpers/core/install-dependencies.ts
@@ -122,6 +122,26 @@ export async function runGoModTidy({ projectDir }: { projectDir: string }) {
}
}
+export async function runMixDepsGet({ projectDir }: { projectDir: string }) {
+ const s = spinner();
+
+ try {
+ s.start("Running mix deps.get...");
+
+ await $({
+ cwd: projectDir,
+ stderr: "inherit",
+ })`mix deps.get`;
+
+ s.stop("Elixir dependencies installed successfully");
+ } catch (error) {
+ s.stop(pc.red("mix deps.get failed"));
+ if (error instanceof Error) {
+ consola.error(pc.red(`mix deps.get error: ${error.message}`));
+ }
+ }
+}
+
export async function runMavenTests({ projectDir }: { projectDir: string }) {
const s = spinner();
const mvnw = process.platform === "win32" ? "mvnw.cmd" : "./mvnw";
diff --git a/apps/cli/src/helpers/core/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts
index aa671c163..eee48367b 100644
--- a/apps/cli/src/helpers/core/post-installation.ts
+++ b/apps/cli/src/helpers/core/post-installation.ts
@@ -54,6 +54,12 @@ export async function displayPostInstallInstructions(
return;
}
+ // Handle Elixir projects with different instructions
+ if (ecosystem === "elixir") {
+ displayElixirInstructions(config);
+ return;
+ }
+
// Handle Python projects with different instructions
if (ecosystem === "python") {
displayPythonInstructions(config);
@@ -952,6 +958,49 @@ function getJavaMainSourcePath(projectName: string): string {
return `src/main/java/${getJavaMainClass(projectName).replace(/\./g, "/")}.java`;
}
+function displayElixirInstructions(config: ProjectConfig & { depsInstalled: boolean }) {
+ const {
+ relativePath,
+ depsInstalled,
+ elixirWebFramework,
+ elixirDatabase,
+ elixirLibraries,
+ elixirTesting,
+ } = config;
+
+ const cdCmd = `cd ${relativePath}`;
+ const isPhoenix = elixirWebFramework === "phoenix";
+ const libraries = elixirLibraries.filter((library) => library !== "none");
+ const testingLibraries = elixirTesting.filter((library) => library !== "none");
+
+ let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`;
+ let stepCounter = 2;
+
+ if (!depsInstalled) {
+ output += `${pc.cyan(`${stepCounter++}.`)} mix deps.get\n`;
+ }
+
+ if (testingLibraries.includes("exunit")) {
+ output += `${pc.cyan(`${stepCounter++}.`)} mix test\n`;
+ }
+
+ output += `${pc.cyan(`${stepCounter++}.`)} ${isPhoenix ? "mix phx.server" : "iex -S mix"}\n`;
+
+ output += `\n${pc.bold("Your Elixir project includes:")}\n`;
+ output += `${pc.cyan("•")} Scaffold: ${isPhoenix ? "Phoenix" : "Plain Mix / OTP"}\n`;
+ if (elixirDatabase !== "none") {
+ output += `${pc.cyan("•")} Database: ${elixirDatabase}\n`;
+ }
+ if (libraries.length > 0) {
+ output += `${pc.cyan("•")} Libraries: ${libraries.join(", ")}\n`;
+ }
+ if (testingLibraries.length > 0) {
+ output += `${pc.cyan("•")} Testing: ${testingLibraries.join(", ")}\n`;
+ }
+
+ consola.box(output);
+}
+
function displayJavaInstructions(config: ProjectConfig & { depsInstalled: boolean }) {
const {
projectName,
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index cd7f02615..c935bef08 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -144,6 +144,14 @@ import {
type JavaAuth,
JavaTestingLibrariesSchema,
type JavaTestingLibraries,
+ ElixirWebFrameworkSchema,
+ type ElixirWebFramework,
+ ElixirDatabaseSchema,
+ type ElixirDatabase,
+ ElixirLibrariesSchema,
+ type ElixirLibraries,
+ ElixirTestingSchema,
+ type ElixirTesting,
OPTION_CATEGORY_METADATA,
AiDocsSchema,
type AiDocs,
@@ -416,16 +424,23 @@ export async function createVirtual(
options: Partial>,
): Promise<{ success: boolean; tree?: VirtualFileTree; error?: string }> {
try {
+ const ecosystem = options.ecosystem || "typescript";
+ const isTypeScript = ecosystem === "typescript";
+ const isReactNative = ecosystem === "react-native";
+ const frontend = options.frontend || (isReactNative ? ["native-bare"] : isTypeScript ? ["tanstack-router"] : []);
+ const hasNativeFrontend = frontend.some((item) =>
+ item === "native-bare" || item === "native-uniwind" || item === "native-unistyles"
+ );
const config: ProjectConfig = {
+ ecosystem,
projectName: options.projectName || "my-project",
projectDir: "/virtual",
relativePath: "./virtual",
- ecosystem: options.ecosystem || "typescript",
database: options.database || "none",
orm: options.orm || "none",
- backend: options.backend || "hono",
- runtime: options.runtime || "bun",
- frontend: options.frontend || ["tanstack-router"],
+ backend: options.backend || (isTypeScript ? "hono" : "none"),
+ runtime: options.runtime || (isTypeScript ? "bun" : "none"),
+ frontend,
addons: options.addons || [],
examples: options.examples || [],
auth: options.auth || "none",
@@ -438,11 +453,11 @@ export async function createVirtual(
versionChannel: options.versionChannel || "stable",
install: false,
dbSetup: options.dbSetup || "none",
- api: options.api || "trpc",
+ api: options.api || (isTypeScript ? "trpc" : "none"),
webDeploy: options.webDeploy || "none",
serverDeploy: options.serverDeploy || "none",
- cssFramework: options.cssFramework || "tailwind",
- uiLibrary: options.uiLibrary || "shadcn-ui",
+ cssFramework: options.cssFramework || (isTypeScript ? "tailwind" : "none"),
+ uiLibrary: options.uiLibrary || (isTypeScript ? "shadcn-ui" : "none"),
shadcnBase: options.shadcnBase ?? "radix",
shadcnStyle: options.shadcnStyle ?? "nova",
shadcnIconLibrary: options.shadcnIconLibrary ?? "lucide",
@@ -452,9 +467,9 @@ export async function createVirtual(
shadcnRadius: options.shadcnRadius ?? "default",
ai: options.ai || "none",
stateManagement: options.stateManagement || "none",
- forms: options.forms || "react-hook-form",
- testing: options.testing || "vitest",
- validation: options.validation || "zod",
+ forms: options.forms || (isTypeScript ? "react-hook-form" : "none"),
+ testing: options.testing || (isTypeScript ? "vitest" : "none"),
+ validation: options.validation || (isTypeScript ? "zod" : "none"),
realtime: options.realtime || "none",
jobQueue: options.jobQueue || "none",
animation: options.animation || "none",
@@ -462,6 +477,13 @@ export async function createVirtual(
observability: options.observability || "none",
featureFlags: options.featureFlags || "none",
analytics: options.analytics || "none",
+ mobileNavigation: options.mobileNavigation || (hasNativeFrontend ? "expo-router" : "none"),
+ mobileUI: options.mobileUI || "none",
+ mobileStorage: options.mobileStorage || "none",
+ mobileTesting: options.mobileTesting || "none",
+ mobilePush: options.mobilePush || "none",
+ mobileOTA: options.mobileOTA || "none",
+ mobileDeepLinking: options.mobileDeepLinking || (hasNativeFrontend ? "expo-linking" : "none"),
cms: options.cms || "none",
caching: options.caching || "none",
i18n: options.i18n || "none",
@@ -501,6 +523,10 @@ export async function createVirtual(
javaAuth: options.javaAuth || "none",
javaLibraries: options.javaLibraries || [],
javaTestingLibraries: options.javaTestingLibraries || (options.ecosystem === "java" ? ["junit5"] : []),
+ elixirWebFramework: options.elixirWebFramework || "none",
+ elixirDatabase: options.elixirDatabase || "none",
+ elixirLibraries: options.elixirLibraries || (options.ecosystem === "elixir" ? ["jason"] : []),
+ elixirTesting: options.elixirTesting || (options.ecosystem === "elixir" ? ["exunit"] : []),
// AI documentation files
aiDocs: options.aiDocs || ["claude-md"],
};
@@ -579,6 +605,10 @@ export type {
JavaOrm,
JavaAuth,
JavaTestingLibraries,
+ ElixirWebFramework,
+ ElixirDatabase,
+ ElixirLibraries,
+ ElixirTesting,
AiDocs,
AddResult,
};
diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts
index 4128d97ed..57281426a 100644
--- a/apps/cli/src/mcp.ts
+++ b/apps/cli/src/mcp.ts
@@ -25,6 +25,13 @@ import {
FileUploadSchema,
FormsSchema,
FrontendSchema,
+ MobileDeepLinkingSchema,
+ MobileNavigationSchema,
+ MobileOTASchema,
+ MobilePushSchema,
+ MobileStorageSchema,
+ MobileTestingSchema,
+ MobileUISchema,
GoApiSchema,
GoCliSchema,
GoAuthSchema,
@@ -37,6 +44,10 @@ import {
JavaOrmSchema,
JavaTestingLibrariesSchema,
JavaWebFrameworkSchema,
+ ElixirDatabaseSchema,
+ ElixirLibrariesSchema,
+ ElixirTestingSchema,
+ ElixirWebFrameworkSchema,
I18nSchema,
JobQueueSchema,
LoggingSchema,
@@ -86,7 +97,7 @@ const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce(
0,
);
-const INSTRUCTIONS = `Better-Fullstack scaffolds fullstack projects across TypeScript, Rust, Go, Python, and Java ecosystems with ${OPTION_ENTRY_COUNT} configurable options.
+const INSTRUCTIONS = `Better-Fullstack scaffolds fullstack projects across TypeScript, React Native, Rust, Go, Python, and Java ecosystems with ${OPTION_ENTRY_COUNT} configurable options.
RECOMMENDED WORKFLOW:
1. Call bfs_get_guidance to understand field semantics, required fields, and workflow rules.
@@ -101,10 +112,10 @@ For existing projects:
CRITICAL RULES:
- Dependency installation is ALWAYS skipped in MCP mode (timeout risk). After scaffolding, tell the user to run install manually.
-- Array fields: "frontend", "addons", "examples", "aiDocs", "rustLibraries", "pythonAi", "javaLibraries", and "javaTestingLibraries". Most other option fields are strings.
+- Array fields: "frontend", "addons", "examples", "aiDocs", "rustLibraries", "pythonAi", "javaLibraries", "javaTestingLibraries", "elixirLibraries", and "elixirTesting". Most other option fields are strings.
- "none" means "skip this feature entirely", not "use the default".
- Always specify "ecosystem" first — it determines which other fields are relevant.
-- TypeScript-specific fields (frontend, backend, orm, etc.) are IGNORED for rust/python/go/java ecosystems.
+- TypeScript web-specific fields (web frontend, backend, orm, etc.) are IGNORED for react-native/rust/python/go/java ecosystems.
- The compatibility engine auto-adjusts invalid combinations — always call bfs_check_compatibility first to see adjustments.`;
function getGuidance() {
@@ -119,7 +130,9 @@ function getGuidance() {
],
ecosystems: {
typescript:
- "Full-featured: frontend + backend + database + ORM + auth + payments + 20+ feature categories.",
+ "Full-featured web: frontend + backend + database + ORM + auth + payments + 20+ feature categories.",
+ "react-native":
+ "Mobile: Expo/React Native frontend variants plus mobile navigation, UI, storage, testing, push, OTA, and deep linking.",
rust: "Backend/CLI: web framework (axum/actix-web), ORM (sea-orm/sqlx), gRPC, GraphQL, CLI tools.",
python:
"Backend/AI: web framework (fastapi/django), ORM (sqlalchemy/sqlmodel), AI/ML integrations, task queues.",
@@ -135,7 +148,7 @@ function getGuidance() {
frontend:
"ARRAY of strings. TypeScript only. Supports multiple frontends in one monorepo. Use [] for API-only.",
arrayFields:
- 'Use arrays for frontend, addons, examples, aiDocs, rustLibraries, pythonAi, javaLibraries, and javaTestingLibraries. Use [] for "none" on multi-select fields.',
+ 'Use arrays for frontend, addons, examples, aiDocs, rustLibraries, pythonAi, javaLibraries, javaTestingLibraries, elixirLibraries, and elixirTesting. Use [] for "none" on multi-select fields.',
backend:
'String. "self" means fullstack mode (Next.js/Vinext/TanStack Start/Nuxt/Astro API routes). "none" for frontend-only.',
runtime:
@@ -207,6 +220,13 @@ const SCHEMA_MAP: Record = {
i18n: I18nSchema,
search: SearchSchema,
fileStorage: FileStorageSchema,
+ mobileNavigation: MobileNavigationSchema,
+ mobileUI: MobileUISchema,
+ mobileStorage: MobileStorageSchema,
+ mobileTesting: MobileTestingSchema,
+ mobilePush: MobilePushSchema,
+ mobileOTA: MobileOTASchema,
+ mobileDeepLinking: MobileDeepLinkingSchema,
addons: AddonsSchema,
examples: ExamplesSchema,
packageManager: PackageManagerSchema,
@@ -245,6 +265,10 @@ const SCHEMA_MAP: Record = {
javaAuth: JavaAuthSchema,
javaLibraries: JavaLibrariesSchema,
javaTestingLibraries: JavaTestingLibrariesSchema,
+ elixirWebFramework: ElixirWebFrameworkSchema,
+ elixirDatabase: ElixirDatabaseSchema,
+ elixirLibraries: ElixirLibrariesSchema,
+ elixirTesting: ElixirTestingSchema,
};
const ECOSYSTEM_CATEGORIES: Record = {
@@ -255,6 +279,10 @@ const ECOSYSTEM_CATEGORIES: Record = {
"logging", "observability", "featureFlags", "analytics", "cms", "caching",
"i18n", "search", "fileStorage", "astroIntegration",
],
+ "react-native": [
+ "frontend", "auth", "mobileNavigation", "mobileUI", "mobileStorage",
+ "mobileTesting", "mobilePush", "mobileOTA", "mobileDeepLinking",
+ ],
rust: ["rustWebFramework", "rustFrontend", "rustOrm", "rustApi", "rustCli", "rustLibraries", "rustLogging", "rustErrorHandling", "rustCaching", "rustAuth", "email", "observability", "caching", "search"],
python: ["pythonWebFramework", "pythonOrm", "pythonValidation", "pythonAi", "pythonAuth", "pythonApi", "pythonTaskQueue", "pythonGraphql", "pythonQuality", "email", "observability", "caching", "search"],
go: ["goWebFramework", "goOrm", "goApi", "goCli", "goLogging", "goAuth", "auth", "email", "observability", "caching", "search"],
@@ -270,6 +298,12 @@ const ECOSYSTEM_CATEGORIES: Record = {
"caching",
"search",
],
+ elixir: [
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
+ ],
shared: ["ecosystem", "packageManager", "addons", "examples", "webDeploy", "serverDeploy", "dbSetup"],
};
@@ -347,14 +381,24 @@ function buildProjectConfig(
overrides?: { projectDir: string },
): ProjectConfig {
const projectName = (input.projectName as string) ?? "my-project";
+ const ecosystem = (input.ecosystem as ProjectConfig["ecosystem"]) ?? "typescript";
+ const frontend =
+ (input.frontend as ProjectConfig["frontend"]) ??
+ (ecosystem === "react-native" ? ["native-bare"] : ["tanstack-router"]);
+ const hasNativeFrontend = frontend.some((item) => item.startsWith("native-"));
+ const hasMobileProject = ecosystem === "react-native" || hasNativeFrontend;
return {
projectName,
projectDir: overrides?.projectDir ?? "/virtual",
relativePath: overrides ? `./${projectName}` : "./virtual",
- ecosystem: (input.ecosystem as ProjectConfig["ecosystem"]) ?? "typescript",
- frontend: (input.frontend as ProjectConfig["frontend"]) ?? ["tanstack-router"],
- backend: (input.backend as ProjectConfig["backend"]) ?? "hono",
- runtime: (input.runtime as ProjectConfig["runtime"]) ?? "bun",
+ ecosystem,
+ frontend,
+ backend:
+ (input.backend as ProjectConfig["backend"]) ??
+ (ecosystem === "react-native" ? "none" : "hono"),
+ runtime:
+ (input.runtime as ProjectConfig["runtime"]) ??
+ (ecosystem === "react-native" ? "none" : "bun"),
database: (input.database as ProjectConfig["database"]) ?? "none",
orm: (input.orm as ProjectConfig["orm"]) ?? "none",
api: (input.api as ProjectConfig["api"]) ?? "none",
@@ -362,13 +406,15 @@ function buildProjectConfig(
payments: (input.payments as ProjectConfig["payments"]) ?? "none",
email: (input.email as ProjectConfig["email"]) ?? "none",
fileUpload: (input.fileUpload as ProjectConfig["fileUpload"]) ?? "none",
- effect: "none",
+ effect: (input.effect as ProjectConfig["effect"]) ?? "none",
ai: (input.ai as ProjectConfig["ai"]) ?? "none",
stateManagement: (input.stateManagement as ProjectConfig["stateManagement"]) ?? "none",
forms: (input.forms as ProjectConfig["forms"]) ?? "none",
validation: (input.validation as ProjectConfig["validation"]) ?? "none",
testing: (input.testing as ProjectConfig["testing"]) ?? "none",
- cssFramework: (input.cssFramework as ProjectConfig["cssFramework"]) ?? "tailwind",
+ cssFramework:
+ (input.cssFramework as ProjectConfig["cssFramework"]) ??
+ (ecosystem === "react-native" ? "none" : "tailwind"),
uiLibrary: (input.uiLibrary as ProjectConfig["uiLibrary"]) ?? "none",
shadcnBase: "radix",
shadcnStyle: "nova",
@@ -383,7 +429,18 @@ function buildProjectConfig(
logging: (input.logging as ProjectConfig["logging"]) ?? "none",
observability: (input.observability as ProjectConfig["observability"]) ?? "none",
featureFlags: (input.featureFlags as ProjectConfig["featureFlags"]) ?? "none",
- analytics: "none",
+ analytics: (input.analytics as ProjectConfig["analytics"]) ?? "none",
+ mobileNavigation:
+ (input.mobileNavigation as ProjectConfig["mobileNavigation"]) ??
+ (hasMobileProject ? "expo-router" : "none"),
+ mobileUI: (input.mobileUI as ProjectConfig["mobileUI"]) ?? "none",
+ mobileStorage: (input.mobileStorage as ProjectConfig["mobileStorage"]) ?? "none",
+ mobileTesting: (input.mobileTesting as ProjectConfig["mobileTesting"]) ?? "none",
+ mobilePush: (input.mobilePush as ProjectConfig["mobilePush"]) ?? "none",
+ mobileOTA: (input.mobileOTA as ProjectConfig["mobileOTA"]) ?? "none",
+ mobileDeepLinking:
+ (input.mobileDeepLinking as ProjectConfig["mobileDeepLinking"]) ??
+ (hasMobileProject ? "expo-linking" : "none"),
cms: (input.cms as ProjectConfig["cms"]) ?? "none",
caching: (input.caching as ProjectConfig["caching"]) ?? "none",
i18n: (input.i18n as ProjectConfig["i18n"]) ?? "none",
@@ -433,6 +490,15 @@ function buildProjectConfig(
javaLibraries: (input.javaLibraries as ProjectConfig["javaLibraries"]) ?? [],
javaTestingLibraries:
(input.javaTestingLibraries as ProjectConfig["javaTestingLibraries"]) ?? ["junit5"],
+ elixirWebFramework:
+ (input.elixirWebFramework as ProjectConfig["elixirWebFramework"]) ?? "none",
+ elixirDatabase: (input.elixirDatabase as ProjectConfig["elixirDatabase"]) ?? "none",
+ elixirLibraries:
+ (input.elixirLibraries as ProjectConfig["elixirLibraries"]) ??
+ (ecosystem === "elixir" ? ["jason"] : []),
+ elixirTesting:
+ (input.elixirTesting as ProjectConfig["elixirTesting"]) ??
+ (ecosystem === "elixir" ? ["exunit"] : []),
};
}
@@ -450,7 +516,11 @@ function sanitizePath(input: string): string {
function buildCompatibilityInput(input: Record): CompatibilityInput {
const frontend = input.frontend as string[] | undefined;
+ const webFrontend = (frontend ?? []).filter((item) => !item.startsWith("native-"));
+ const nativeFrontend = (frontend ?? []).filter((item) => item.startsWith("native-"));
const addons = (input.addons as string[] | undefined) ?? [];
+ const ecosystem = (input.ecosystem as CompatibilityInput["ecosystem"]) ?? "typescript";
+ const hasMobileProject = ecosystem === "react-native" || nativeFrontend.length > 0;
const codeQuality = addons.filter((a) =>
["biome", "oxlint", "ultracite", "lefthook", "husky", "ruler"].includes(a),
@@ -462,10 +532,10 @@ function buildCompatibilityInput(input: Record): CompatibilityI
);
return {
- ecosystem: (input.ecosystem as CompatibilityInput["ecosystem"]) ?? "typescript",
+ ecosystem,
projectName: (input.projectName as string) ?? null,
- webFrontend: frontend ?? [],
- nativeFrontend: [],
+ webFrontend,
+ nativeFrontend,
astroIntegration: (input.astroIntegration as string) ?? "none",
runtime: (input.runtime as string) ?? "bun",
backend: (input.backend as string) ?? "hono",
@@ -502,6 +572,15 @@ function buildCompatibilityInput(input: Record): CompatibilityI
cms: (input.cms as string) ?? "none",
search: (input.search as string) ?? "none",
fileStorage: (input.fileStorage as string) ?? "none",
+ mobileNavigation:
+ (input.mobileNavigation as string) ?? (hasMobileProject ? "expo-router" : "none"),
+ mobileUI: (input.mobileUI as string) ?? "none",
+ mobileStorage: (input.mobileStorage as string) ?? "none",
+ mobileTesting: (input.mobileTesting as string) ?? "none",
+ mobilePush: (input.mobilePush as string) ?? "none",
+ mobileOTA: (input.mobileOTA as string) ?? "none",
+ mobileDeepLinking:
+ (input.mobileDeepLinking as string) ?? (hasMobileProject ? "expo-linking" : "none"),
codeQuality,
documentation,
appPlatforms,
@@ -547,6 +626,10 @@ function buildCompatibilityInput(input: Record): CompatibilityI
javaAuth: (input.javaAuth as string) ?? "none",
javaLibraries: (input.javaLibraries as string[]) ?? [],
javaTestingLibraries: (input.javaTestingLibraries as string[]) ?? ["junit5"],
+ elixirWebFramework: (input.elixirWebFramework as string) ?? "none",
+ elixirDatabase: (input.elixirDatabase as string) ?? "none",
+ elixirLibraries: (input.elixirLibraries as string[]) ?? [],
+ elixirTesting: (input.elixirTesting as string[]) ?? [],
};
}
@@ -676,11 +759,11 @@ export async function startMcpServer() {
{ instructions: INSTRUCTIONS, capabilities: { logging: {} } },
);
- const registerTool = server.tool.bind(server) as unknown as (
+ const registerTool = server.tool.bind(server) as unknown as >(
name: string,
description: string,
inputSchema: Record,
- cb: (input: any) => unknown,
+ cb: (input: Input) => unknown,
) => void;
registerTool(
@@ -742,6 +825,13 @@ export async function startMcpServer() {
i18n: I18nSchema.optional().describe("Internationalization library"),
search: SearchSchema.optional().describe("Search engine"),
fileStorage: FileStorageSchema.optional().describe("File storage"),
+ mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation"),
+ mobileUI: MobileUISchema.optional().describe("Mobile UI"),
+ mobileStorage: MobileStorageSchema.optional().describe("Mobile storage"),
+ mobileTesting: MobileTestingSchema.optional().describe("Mobile testing"),
+ mobilePush: MobilePushSchema.optional().describe("Mobile push notifications"),
+ mobileOTA: MobileOTASchema.optional().describe("Mobile OTA updates"),
+ mobileDeepLinking: MobileDeepLinkingSchema.optional().describe("Mobile deep linking"),
dbSetup: DatabaseSetupSchema.optional().describe("Database hosting provider"),
webDeploy: WebDeploySchema.optional().describe("Web deployment target"),
serverDeploy: ServerDeploySchema.optional().describe("Server deployment target"),
@@ -788,6 +878,16 @@ export async function startMcpServer() {
.array(JavaTestingLibrariesSchema)
.optional()
.describe("Java testing libraries"),
+ elixirWebFramework: ElixirWebFrameworkSchema.optional().describe("Elixir web framework"),
+ elixirDatabase: ElixirDatabaseSchema.optional().describe("Elixir database layer"),
+ elixirLibraries: z
+ .array(ElixirLibrariesSchema)
+ .optional()
+ .describe("Elixir application libraries"),
+ elixirTesting: z
+ .array(ElixirTestingSchema)
+ .optional()
+ .describe("Elixir testing libraries"),
}),
async (input: Record) => {
try {
@@ -839,6 +939,13 @@ export async function startMcpServer() {
i18n: I18nSchema.optional().describe("Internationalization (i18n) library"),
cms: CMSSchema.optional().describe("CMS"),
fileStorage: FileStorageSchema.optional().describe("File storage"),
+ mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation"),
+ mobileUI: MobileUISchema.optional().describe("Mobile UI"),
+ mobileStorage: MobileStorageSchema.optional().describe("Mobile storage"),
+ mobileTesting: MobileTestingSchema.optional().describe("Mobile testing"),
+ mobilePush: MobilePushSchema.optional().describe("Mobile push notifications"),
+ mobileOTA: MobileOTASchema.optional().describe("Mobile OTA updates"),
+ mobileDeepLinking: MobileDeepLinkingSchema.optional().describe("Mobile deep linking"),
fileUpload: FileUploadSchema.optional().describe("File upload"),
webDeploy: WebDeploySchema.optional().describe("Web deployment target"),
serverDeploy: ServerDeploySchema.optional().describe("Server deployment target"),
@@ -880,6 +987,13 @@ export async function startMcpServer() {
.array(JavaTestingLibrariesSchema)
.optional()
.describe("Java testing libraries"),
+ elixirWebFramework: ElixirWebFrameworkSchema.optional().describe("Elixir web framework"),
+ elixirDatabase: ElixirDatabaseSchema.optional().describe("Elixir database layer"),
+ elixirLibraries: z
+ .array(ElixirLibrariesSchema)
+ .optional()
+ .describe("Elixir application libraries"),
+ elixirTesting: z.array(ElixirTestingSchema).optional().describe("Elixir testing libraries"),
};
registerTool(
diff --git a/apps/cli/src/prompts/auth.ts b/apps/cli/src/prompts/auth.ts
index 77854864d..012bb74d5 100644
--- a/apps/cli/src/prompts/auth.ts
+++ b/apps/cli/src/prompts/auth.ts
@@ -10,7 +10,7 @@ type AuthPromptContext = {
auth?: Auth;
backend?: Backend;
frontend?: string[];
- ecosystem?: "typescript" | "go";
+ ecosystem?: "typescript" | "react-native" | "go";
};
export function resolveAuthPrompt(context: AuthPromptContext = {}): PromptSingleResolution {
@@ -75,7 +75,7 @@ export async function getAuthChoice(
auth: Auth | undefined,
backend?: Backend,
frontend?: string[],
- ecosystem: "typescript" | "go" = "typescript",
+ ecosystem: "typescript" | "react-native" | "go" = "typescript",
) {
const resolution = resolveAuthPrompt({
auth,
diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts
index 2680c8e78..2f25c6cc3 100644
--- a/apps/cli/src/prompts/config-prompts.ts
+++ b/apps/cli/src/prompts/config-prompts.ts
@@ -14,6 +14,10 @@ import type {
I18n,
Database,
DatabaseSetup,
+ ElixirDatabase,
+ ElixirLibraries,
+ ElixirTesting,
+ ElixirWebFramework,
Ecosystem,
Effect,
Email,
@@ -36,6 +40,13 @@ import type {
JavaWebFramework,
JobQueue,
Logging,
+ MobileDeepLinking,
+ MobileNavigation,
+ MobileOTA,
+ MobilePush,
+ MobileStorage,
+ MobileTesting,
+ MobileUI,
Observability,
ORM,
PackageManager,
@@ -90,12 +101,18 @@ import { getDatabaseChoice } from "./database";
import { getDBSetupChoice } from "./database-setup";
import { getEcosystemChoice } from "./ecosystem";
import { getEffectChoice } from "./effect";
+import {
+ getElixirDatabaseChoice,
+ getElixirLibrariesChoice,
+ getElixirTestingChoice,
+ getElixirWebFrameworkChoice,
+} from "./elixir-ecosystem";
import { getEmailChoice } from "./email";
import { getExamplesChoice } from "./examples";
import { getFileStorageChoice } from "./file-storage";
import { getFileUploadChoice } from "./file-upload";
import { getFormsChoice } from "./forms";
-import { getFrontendChoice } from "./frontend";
+import { getFrontendChoice, getNativeFrontendChoice } from "./frontend";
import { getGitChoice } from "./git";
import {
getGoApiChoice,
@@ -117,6 +134,15 @@ import {
} from "./java-ecosystem";
import { getJobQueueChoice } from "./job-queue";
import { getLoggingChoice } from "./logging";
+import {
+ getMobileDeepLinkingChoice,
+ getMobileNavigationChoice,
+ getMobileOTAChoice,
+ getMobilePushChoice,
+ getMobileStorageChoice,
+ getMobileTestingChoice,
+ getMobileUIChoice,
+} from "./mobile";
import { navigableGroup } from "./navigable-group";
import { getObservabilityChoice } from "./observability";
import { getORMChoice } from "./orm";
@@ -197,6 +223,13 @@ type PromptGroupResults = {
i18n: I18n;
search: Search;
fileStorage: FileStorage;
+ mobileNavigation: MobileNavigation;
+ mobileUI: MobileUI;
+ mobileStorage: MobileStorage;
+ mobileTesting: MobileTesting;
+ mobilePush: MobilePush;
+ mobileOTA: MobileOTA;
+ mobileDeepLinking: MobileDeepLinking;
// Rust ecosystem
rustWebFramework: RustWebFramework;
rustFrontend: RustFrontend;
@@ -232,6 +265,11 @@ type PromptGroupResults = {
javaAuth: JavaAuth;
javaLibraries: JavaLibraries[];
javaTestingLibraries: JavaTestingLibraries[];
+ // Elixir ecosystem
+ elixirWebFramework: ElixirWebFramework;
+ elixirDatabase: ElixirDatabase;
+ elixirLibraries: ElixirLibraries[];
+ elixirTesting: ElixirTesting[];
// Keep at end
aiDocs: AiDocs[];
git: boolean;
@@ -251,6 +289,9 @@ export async function gatherConfig(
ecosystem: () => getEcosystemChoice(flags.ecosystem),
// TypeScript ecosystem prompts (skip if Rust or Python)
frontend: ({ results }) => {
+ if (results.ecosystem === "react-native") {
+ return getNativeFrontendChoice(flags.frontend);
+ }
if (results.ecosystem !== "typescript") return Promise.resolve([] as Frontend[]);
return getFrontendChoice(flags.frontend, flags.backend, flags.auth);
},
@@ -326,6 +367,9 @@ export async function gatherConfig(
if (results.ecosystem === "typescript") {
return getAuthChoice(flags.auth, results.backend, results.frontend, "typescript");
}
+ if (results.ecosystem === "react-native") {
+ return Promise.resolve((flags.auth ?? "none") as Auth);
+ }
if (results.ecosystem === "go") {
return getAuthChoice(flags.auth, undefined, undefined, "go");
}
@@ -336,6 +380,7 @@ export async function gatherConfig(
return getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend);
},
email: ({ results }) => {
+ if (results.ecosystem === "react-native") return Promise.resolve("none" as Email);
return getEmailChoice(flags.email, results.backend, results.ecosystem);
},
effect: ({ results }) => {
@@ -443,6 +488,7 @@ export async function gatherConfig(
return getLoggingChoice(flags.logging, results.backend);
},
observability: ({ results }) => {
+ if (results.ecosystem === "react-native") return Promise.resolve("none" as Observability);
return getObservabilityChoice(
flags.observability,
results.backend,
@@ -462,6 +508,7 @@ export async function gatherConfig(
return getCMSChoice(flags.cms, results.backend);
},
caching: ({ results }) => {
+ if (results.ecosystem === "react-native") return Promise.resolve("none" as Caching);
return getCachingChoice(flags.caching, results.backend, results.ecosystem);
},
i18n: ({ results }) => {
@@ -469,12 +516,80 @@ export async function gatherConfig(
return getI18nChoice(flags.i18n, results.frontend);
},
search: ({ results }) => {
+ if (results.ecosystem === "react-native") return Promise.resolve("none" as Search);
return getSearchChoice(flags.search, results.backend, results.ecosystem);
},
fileStorage: ({ results }) => {
if (results.ecosystem !== "typescript") return Promise.resolve("none" as FileStorage);
return getFileStorageChoice(flags.fileStorage, results.backend);
},
+ mobileNavigation: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobileNavigation);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobileNavigation);
+ }
+ return getMobileNavigationChoice(flags.mobileNavigation);
+ },
+ mobileUI: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobileUI);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobileUI);
+ }
+ if (results.frontend.includes("native-uniwind")) return Promise.resolve("uniwind" as MobileUI);
+ if (results.frontend.includes("native-unistyles")) {
+ return Promise.resolve("unistyles" as MobileUI);
+ }
+ return getMobileUIChoice(flags.mobileUI);
+ },
+ mobileStorage: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobileStorage);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobileStorage);
+ }
+ return getMobileStorageChoice(flags.mobileStorage);
+ },
+ mobileTesting: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobileTesting);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobileTesting);
+ }
+ return getMobileTestingChoice(flags.mobileTesting);
+ },
+ mobilePush: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobilePush);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobilePush);
+ }
+ return getMobilePushChoice(flags.mobilePush);
+ },
+ mobileOTA: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobileOTA);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobileOTA);
+ }
+ return getMobileOTAChoice(flags.mobileOTA);
+ },
+ mobileDeepLinking: ({ results }) => {
+ if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") {
+ return Promise.resolve("none" as MobileDeepLinking);
+ }
+ if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) {
+ return Promise.resolve("none" as MobileDeepLinking);
+ }
+ return getMobileDeepLinkingChoice(flags.mobileDeepLinking);
+ },
// Rust ecosystem prompts (skip if TypeScript or Python)
rustWebFramework: ({ results }) => {
if (results.ecosystem !== "rust") return Promise.resolve("none" as RustWebFramework);
@@ -618,6 +733,23 @@ export async function gatherConfig(
}
return getJavaTestingLibrariesChoice(flags.javaTestingLibraries);
},
+ // Elixir ecosystem prompts (skip if not Elixir)
+ elixirWebFramework: ({ results }) => {
+ if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirWebFramework);
+ return getElixirWebFrameworkChoice(flags.elixirWebFramework);
+ },
+ elixirDatabase: ({ results }) => {
+ if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirDatabase);
+ return getElixirDatabaseChoice(flags.elixirDatabase);
+ },
+ elixirLibraries: ({ results }) => {
+ if (results.ecosystem !== "elixir") return Promise.resolve([] as ElixirLibraries[]);
+ return getElixirLibrariesChoice(flags.elixirLibraries);
+ },
+ elixirTesting: ({ results }) => {
+ if (results.ecosystem !== "elixir") return Promise.resolve([] as ElixirTesting[]);
+ return getElixirTestingChoice(flags.elixirTesting);
+ },
// Keep at end
aiDocs: () => getAiDocsChoice(flags.aiDocs),
git: () => getGitChoice(flags.git),
@@ -627,7 +759,8 @@ export async function gatherConfig(
results.ecosystem === "rust" ||
results.ecosystem === "python" ||
results.ecosystem === "go" ||
- results.ecosystem === "java"
+ results.ecosystem === "java" ||
+ results.ecosystem === "elixir"
)
return Promise.resolve(flags.packageManager ?? getUserPkgManager());
return getPackageManagerChoice(flags.packageManager);
@@ -685,6 +818,13 @@ export async function gatherConfig(
i18n: result.i18n,
search: result.search,
fileStorage: result.fileStorage,
+ mobileNavigation: result.mobileNavigation,
+ mobileUI: result.mobileUI,
+ mobileStorage: result.mobileStorage,
+ mobileTesting: result.mobileTesting,
+ mobilePush: result.mobilePush,
+ mobileOTA: result.mobileOTA,
+ mobileDeepLinking: result.mobileDeepLinking,
// Ecosystem
ecosystem: result.ecosystem,
// Rust ecosystem options
@@ -722,6 +862,11 @@ export async function gatherConfig(
javaAuth: result.javaAuth,
javaLibraries: result.javaLibraries,
javaTestingLibraries: result.javaTestingLibraries,
+ // Elixir ecosystem options
+ elixirWebFramework: result.elixirWebFramework,
+ elixirDatabase: result.elixirDatabase,
+ elixirLibraries: result.elixirLibraries,
+ elixirTesting: result.elixirTesting,
// AI documentation files
aiDocs: result.aiDocs,
};
diff --git a/apps/cli/src/prompts/ecosystem.ts b/apps/cli/src/prompts/ecosystem.ts
index 0cff9d6b8..f81860965 100644
--- a/apps/cli/src/prompts/ecosystem.ts
+++ b/apps/cli/src/prompts/ecosystem.ts
@@ -10,7 +10,12 @@ export async function getEcosystemChoice(ecosystem?: Ecosystem) {
{
value: "typescript" as const,
label: "TypeScript",
- hint: "Full-stack TypeScript with React, Vue, Svelte, and more",
+ hint: "Full-stack TypeScript web with React, Vue, Svelte, and more",
+ },
+ {
+ value: "react-native" as const,
+ label: "React Native",
+ hint: "Expo and React Native mobile apps with native integrations",
},
{
value: "rust" as const,
@@ -27,6 +32,16 @@ export async function getEcosystemChoice(ecosystem?: Ecosystem) {
label: "Go",
hint: "Go ecosystem with Gin, Echo, GORM, and more",
},
+ {
+ value: "java" as const,
+ label: "Java",
+ hint: "Java ecosystem with Spring Boot, Maven, Gradle, and more",
+ },
+ {
+ value: "elixir" as const,
+ label: "Elixir",
+ hint: "Elixir ecosystem with Mix, OTP, Phoenix, and BEAM-native apps",
+ },
];
const response = await navigableSelect({
diff --git a/apps/cli/src/prompts/elixir-ecosystem.ts b/apps/cli/src/prompts/elixir-ecosystem.ts
new file mode 100644
index 000000000..00f0dd2ba
--- /dev/null
+++ b/apps/cli/src/prompts/elixir-ecosystem.ts
@@ -0,0 +1,203 @@
+import type {
+ ElixirDatabase,
+ ElixirLibraries,
+ ElixirTesting,
+ ElixirWebFramework,
+} from "../types";
+
+import { exitCancelled } from "../utils/errors";
+import { isCancel, navigableMultiselect, navigableSelect } from "./navigable";
+import {
+ createStaticMultiPromptResolution,
+ createStaticSinglePromptResolution,
+ type PromptOption,
+} from "./prompt-contract";
+
+const ELIXIR_WEB_FRAMEWORK_PROMPT_OPTIONS: PromptOption[] = [
+ {
+ value: "none",
+ label: "None",
+ hint: "Plain Mix / OTP application without Phoenix",
+ },
+ {
+ value: "phoenix",
+ label: "Phoenix",
+ hint: "Full-featured Elixir web framework with LiveView and Channels",
+ },
+];
+
+const ELIXIR_DATABASE_PROMPT_OPTIONS: PromptOption[] = [
+ {
+ value: "none",
+ label: "None",
+ hint: "No Elixir database layer",
+ },
+ {
+ value: "ecto",
+ label: "Ecto",
+ hint: "Database wrapper and query generator for Elixir",
+ },
+];
+
+const ELIXIR_LIBRARY_PROMPT_OPTIONS: PromptOption[] = [
+ {
+ value: "jason",
+ label: "Jason",
+ hint: "Fast JSON encoder and decoder",
+ },
+ {
+ value: "req",
+ label: "Req",
+ hint: "Modern HTTP client for Elixir",
+ },
+ {
+ value: "oban",
+ label: "Oban",
+ hint: "PostgreSQL-backed background jobs",
+ },
+ {
+ value: "broadway",
+ label: "Broadway",
+ hint: "Concurrent data ingestion and processing pipelines",
+ },
+ {
+ value: "telemetry",
+ label: "Telemetry",
+ hint: "Instrumentation events and metrics foundation",
+ },
+ {
+ value: "nx",
+ label: "Nx",
+ hint: "Numerical Elixir tensors and machine learning",
+ },
+ {
+ value: "none",
+ label: "None",
+ hint: "No extra Elixir libraries",
+ },
+];
+
+const ELIXIR_TESTING_PROMPT_OPTIONS: PromptOption[] = [
+ {
+ value: "exunit",
+ label: "ExUnit",
+ hint: "Built-in Elixir test framework",
+ },
+ {
+ value: "mox",
+ label: "Mox",
+ hint: "Explicit contract-based mocks",
+ },
+ {
+ value: "stream-data",
+ label: "StreamData",
+ hint: "Property-based testing for ExUnit",
+ },
+ {
+ value: "none",
+ label: "None",
+ hint: "No extra Elixir testing setup",
+ },
+];
+
+export function resolveElixirWebFrameworkPrompt(elixirWebFramework?: ElixirWebFramework) {
+ return createStaticSinglePromptResolution(
+ ELIXIR_WEB_FRAMEWORK_PROMPT_OPTIONS,
+ "none",
+ elixirWebFramework,
+ );
+}
+
+export async function getElixirWebFrameworkChoice(
+ elixirWebFramework?: ElixirWebFramework,
+) {
+ const resolution = resolveElixirWebFrameworkPrompt(elixirWebFramework);
+ if (!resolution.shouldPrompt) {
+ return resolution.autoValue ?? "none";
+ }
+
+ const response = await navigableSelect({
+ message: "Select Elixir web framework",
+ options: resolution.options,
+ initialValue: resolution.initialValue as ElixirWebFramework,
+ });
+
+ if (isCancel(response)) return exitCancelled("Operation cancelled");
+ return response;
+}
+
+export function resolveElixirDatabasePrompt(elixirDatabase?: ElixirDatabase) {
+ return createStaticSinglePromptResolution(
+ ELIXIR_DATABASE_PROMPT_OPTIONS,
+ "none",
+ elixirDatabase,
+ );
+}
+
+export async function getElixirDatabaseChoice(elixirDatabase?: ElixirDatabase) {
+ const resolution = resolveElixirDatabasePrompt(elixirDatabase);
+ if (!resolution.shouldPrompt) {
+ return resolution.autoValue ?? "none";
+ }
+
+ const response = await navigableSelect({
+ message: "Select Elixir database layer",
+ options: resolution.options,
+ initialValue: resolution.initialValue as ElixirDatabase,
+ });
+
+ if (isCancel(response)) return exitCancelled("Operation cancelled");
+ return response;
+}
+
+export function resolveElixirLibrariesPrompt(elixirLibraries?: ElixirLibraries[]) {
+ return createStaticMultiPromptResolution(
+ ELIXIR_LIBRARY_PROMPT_OPTIONS,
+ ["jason"],
+ elixirLibraries,
+ );
+}
+
+export async function getElixirLibrariesChoice(elixirLibraries?: ElixirLibraries[]) {
+ const resolution = resolveElixirLibrariesPrompt(elixirLibraries);
+ if (!resolution.shouldPrompt) {
+ return (resolution.autoValue as ElixirLibraries[]) ?? [];
+ }
+
+ const response = await navigableMultiselect({
+ message: "Select Elixir libraries",
+ options: resolution.options,
+ required: false,
+ initialValues: resolution.initialValue as ElixirLibraries[],
+ });
+
+ if (isCancel(response)) return exitCancelled("Operation cancelled");
+ if (response.includes("none")) return [];
+ return response as ElixirLibraries[];
+}
+
+export function resolveElixirTestingPrompt(elixirTesting?: ElixirTesting[]) {
+ return createStaticMultiPromptResolution(
+ ELIXIR_TESTING_PROMPT_OPTIONS,
+ ["exunit"],
+ elixirTesting,
+ );
+}
+
+export async function getElixirTestingChoice(elixirTesting?: ElixirTesting[]) {
+ const resolution = resolveElixirTestingPrompt(elixirTesting);
+ if (!resolution.shouldPrompt) {
+ return (resolution.autoValue as ElixirTesting[]) ?? [];
+ }
+
+ const response = await navigableMultiselect({
+ message: "Select Elixir testing libraries",
+ options: resolution.options,
+ required: false,
+ initialValues: resolution.initialValue as ElixirTesting[],
+ });
+
+ if (isCancel(response)) return exitCancelled("Operation cancelled");
+ if (response.includes("none")) return [];
+ return response as ElixirTesting[];
+}
diff --git a/apps/cli/src/prompts/frontend.ts b/apps/cli/src/prompts/frontend.ts
index 62c81e13c..be88226d1 100644
--- a/apps/cli/src/prompts/frontend.ts
+++ b/apps/cli/src/prompts/frontend.ts
@@ -259,3 +259,20 @@ export async function getFrontendChoice(
return result;
}
}
+
+export async function getNativeFrontendChoice(frontendOptions?: Frontend[]): Promise {
+ if (frontendOptions !== undefined) {
+ return frontendOptions.filter((frontend) => frontend.startsWith("native-"));
+ }
+
+ const nativeFramework = await navigableSelect({
+ message: "Choose React Native app type",
+ options: NATIVE_FRONTEND_PROMPT_OPTIONS,
+ initialValue: "native-bare",
+ });
+
+ if (isGoBack(nativeFramework)) return GO_BACK_SYMBOL;
+ if (isCancel(nativeFramework)) return exitCancelled("Operation cancelled");
+
+ return [nativeFramework as Frontend];
+}
diff --git a/apps/cli/src/prompts/install.ts b/apps/cli/src/prompts/install.ts
index 0433b7b20..5b39454fd 100644
--- a/apps/cli/src/prompts/install.ts
+++ b/apps/cli/src/prompts/install.ts
@@ -95,6 +95,24 @@ export async function getinstallChoice(
return response;
}
+ // For Elixir: check mix and show appropriate message
+ if (ecosystem === "elixir") {
+ const mixInstalled = await commandExists("mix");
+ if (!mixInstalled) {
+ log.warn("Mix is not installed. Please install Elixir from https://elixir-lang.org/install.html");
+ return false;
+ }
+
+ const response = await navigableConfirm({
+ message: "Run mix deps.get?",
+ initialValue: DEFAULT_CONFIG.install,
+ });
+
+ if (isCancel(response)) return exitCancelled("Operation cancelled");
+
+ return response;
+ }
+
// For TypeScript: existing behavior
const response = await navigableConfirm({
message: "Install dependencies?",
diff --git a/apps/cli/src/prompts/mobile.ts b/apps/cli/src/prompts/mobile.ts
new file mode 100644
index 000000000..f797e903a
--- /dev/null
+++ b/apps/cli/src/prompts/mobile.ts
@@ -0,0 +1,158 @@
+import type {
+ MobileDeepLinking,
+ MobileNavigation,
+ MobileOTA,
+ MobilePush,
+ MobileStorage,
+ MobileTesting,
+ MobileUI,
+} from "../types";
+
+import {
+ createStaticSinglePromptResolution,
+ type PromptOption,
+} from "./prompt-contract";
+import { exitCancelled } from "../utils/errors";
+import { isCancel, navigableSelect } from "./navigable";
+
+const MOBILE_NAVIGATION_OPTIONS: PromptOption[] = [
+ { value: "expo-router", label: "Expo Router", hint: "File-based routing for Expo apps" },
+ { value: "react-navigation", label: "React Navigation", hint: "Code-defined native stacks and tabs" },
+ { value: "none", label: "None", hint: "Skip navigation setup" },
+];
+
+const MOBILE_UI_OPTIONS: PromptOption[] = [
+ { value: "none", label: "None", hint: "Use React Native primitives" },
+ { value: "tamagui", label: "Tamagui", hint: "Universal themed UI primitives" },
+ { value: "gluestack-ui", label: "Gluestack UI", hint: "Accessible cross-platform components" },
+ { value: "uniwind", label: "Uniwind", hint: "Tailwind-style React Native styling" },
+ { value: "unistyles", label: "Unistyles", hint: "Type-safe React Native stylesheets" },
+];
+
+const MOBILE_STORAGE_OPTIONS: PromptOption[] = [
+ { value: "none", label: "None", hint: "Skip device storage helpers" },
+ { value: "mmkv", label: "MMKV", hint: "Fast encrypted key-value storage" },
+];
+
+const MOBILE_TESTING_OPTIONS: PromptOption[] = [
+ { value: "none", label: "None", hint: "Skip mobile testing setup" },
+ { value: "maestro", label: "Maestro", hint: "Mobile E2E flow files" },
+ {
+ value: "react-native-testing-library",
+ label: "React Native Testing Library",
+ hint: "Unit tests for native components",
+ },
+ {
+ value: "maestro-react-native-testing-library",
+ label: "Maestro + RN Testing Library",
+ hint: "Mobile E2E flows and unit tests",
+ },
+];
+
+const MOBILE_PUSH_OPTIONS: PromptOption[] = [
+ { value: "none", label: "None", hint: "Skip push notification setup" },
+ { value: "expo-notifications", label: "Expo Notifications", hint: "Expo push token helper" },
+];
+
+const MOBILE_OTA_OPTIONS: PromptOption[] = [
+ { value: "none", label: "None", hint: "Skip OTA update setup" },
+ { value: "expo-updates", label: "Expo Updates", hint: "Runtime version and update helper" },
+];
+
+const MOBILE_DEEP_LINKING_OPTIONS: PromptOption[] = [
+ { value: "expo-linking", label: "Expo Linking", hint: "Scheme config and redirect URI helpers" },
+ { value: "none", label: "None", hint: "Skip deep link helpers" },
+];
+
+async function promptMobileOption(
+ options: PromptOption[],
+ defaultValue: T,
+ selected: T | undefined,
+ message: string,
+) {
+ const resolution = createStaticSinglePromptResolution(options, defaultValue, selected);
+ if (!resolution.shouldPrompt) return resolution.autoValue ?? defaultValue;
+
+ const response = await navigableSelect({
+ message,
+ options: resolution.options,
+ initialValue: resolution.initialValue as T,
+ });
+
+ if (isCancel(response)) return exitCancelled("Operation cancelled");
+ return response;
+}
+
+export function resolveMobileNavigationPrompt(mobileNavigation?: MobileNavigation) {
+ return createStaticSinglePromptResolution(
+ MOBILE_NAVIGATION_OPTIONS,
+ "expo-router",
+ mobileNavigation,
+ );
+}
+
+export function resolveMobileUIPrompt(mobileUI?: MobileUI) {
+ return createStaticSinglePromptResolution(MOBILE_UI_OPTIONS, "none", mobileUI);
+}
+
+export function resolveMobileStoragePrompt(mobileStorage?: MobileStorage) {
+ return createStaticSinglePromptResolution(MOBILE_STORAGE_OPTIONS, "none", mobileStorage);
+}
+
+export function resolveMobileTestingPrompt(mobileTesting?: MobileTesting) {
+ return createStaticSinglePromptResolution(MOBILE_TESTING_OPTIONS, "none", mobileTesting);
+}
+
+export function resolveMobilePushPrompt(mobilePush?: MobilePush) {
+ return createStaticSinglePromptResolution(MOBILE_PUSH_OPTIONS, "none", mobilePush);
+}
+
+export function resolveMobileOTAPrompt(mobileOTA?: MobileOTA) {
+ return createStaticSinglePromptResolution(MOBILE_OTA_OPTIONS, "none", mobileOTA);
+}
+
+export function resolveMobileDeepLinkingPrompt(mobileDeepLinking?: MobileDeepLinking) {
+ return createStaticSinglePromptResolution(
+ MOBILE_DEEP_LINKING_OPTIONS,
+ "expo-linking",
+ mobileDeepLinking,
+ );
+}
+
+export function getMobileNavigationChoice(mobileNavigation?: MobileNavigation) {
+ return promptMobileOption(
+ MOBILE_NAVIGATION_OPTIONS,
+ "expo-router",
+ mobileNavigation,
+ "Select mobile navigation",
+ );
+}
+
+export function getMobileUIChoice(mobileUI?: MobileUI) {
+ return promptMobileOption(MOBILE_UI_OPTIONS, "none", mobileUI, "Select mobile UI");
+}
+
+export function getMobileStorageChoice(mobileStorage?: MobileStorage) {
+ return promptMobileOption(MOBILE_STORAGE_OPTIONS, "none", mobileStorage, "Select mobile storage");
+}
+
+export function getMobileTestingChoice(mobileTesting?: MobileTesting) {
+ return promptMobileOption(MOBILE_TESTING_OPTIONS, "none", mobileTesting, "Select mobile testing");
+}
+
+export function getMobilePushChoice(mobilePush?: MobilePush) {
+ return promptMobileOption(MOBILE_PUSH_OPTIONS, "none", mobilePush, "Select mobile push");
+}
+
+export function getMobileOTAChoice(mobileOTA?: MobileOTA) {
+ return promptMobileOption(MOBILE_OTA_OPTIONS, "none", mobileOTA, "Select mobile OTA updates");
+}
+
+export function getMobileDeepLinkingChoice(mobileDeepLinking?: MobileDeepLinking) {
+ return promptMobileOption(
+ MOBILE_DEEP_LINKING_OPTIONS,
+ "expo-linking",
+ mobileDeepLinking,
+ "Select mobile deep linking",
+ );
+}
diff --git a/apps/cli/src/prompts/prompt-resolver-registry.ts b/apps/cli/src/prompts/prompt-resolver-registry.ts
index 1e6a97b6c..ac66d614d 100644
--- a/apps/cli/src/prompts/prompt-resolver-registry.ts
+++ b/apps/cli/src/prompts/prompt-resolver-registry.ts
@@ -11,9 +11,20 @@ import {
DATABASE_SETUP_VALUES,
DATABASE_VALUES,
EMAIL_VALUES,
+ ELIXIR_DATABASE_VALUES,
+ ELIXIR_LIBRARIES_VALUES,
+ ELIXIR_TESTING_VALUES,
+ ELIXIR_WEB_FRAMEWORK_VALUES,
FILE_UPLOAD_VALUES,
FORMS_VALUES,
FRONTEND_VALUES,
+ MOBILE_DEEP_LINKING_VALUES,
+ MOBILE_NAVIGATION_VALUES,
+ MOBILE_OTA_VALUES,
+ MOBILE_PUSH_VALUES,
+ MOBILE_STORAGE_VALUES,
+ MOBILE_TESTING_VALUES,
+ MOBILE_UI_VALUES,
GO_API_VALUES,
GO_AUTH_VALUES,
GO_CLI_VALUES,
@@ -68,6 +79,12 @@ import { resolveCMSPrompt } from "./cms";
import { resolveCSSFrameworkPrompt } from "./css-framework";
import { resolveDatabasePrompt } from "./database";
import { resolveDBSetupPrompt } from "./database-setup";
+import {
+ resolveElixirDatabasePrompt,
+ resolveElixirLibrariesPrompt,
+ resolveElixirTestingPrompt,
+ resolveElixirWebFrameworkPrompt,
+} from "./elixir-ecosystem";
import { resolveEmailPrompt } from "./email";
import { resolveFileUploadPrompt } from "./file-upload";
import { resolveFrontendPrompt } from "./frontend";
@@ -90,6 +107,15 @@ import {
} from "./java-ecosystem";
import { resolveJobQueuePrompt } from "./job-queue";
import { resolveLoggingPrompt } from "./logging";
+import {
+ resolveMobileDeepLinkingPrompt,
+ resolveMobileNavigationPrompt,
+ resolveMobileOTAPrompt,
+ resolveMobilePushPrompt,
+ resolveMobileStoragePrompt,
+ resolveMobileTestingPrompt,
+ resolveMobileUIPrompt,
+} from "./mobile";
import { resolveObservabilityPrompt } from "./observability";
import { resolveORMPrompt } from "./orm";
import { resolvePaymentsPrompt } from "./payments";
@@ -266,6 +292,43 @@ export const PROMPT_RESOLVER_REGISTRY: ResolverRegistry = {
resolve: ({ value }: { value?: string } = {}) => resolveTestingPrompt(value as any),
coverageContexts: [{}],
},
+ mobileNavigation: {
+ schemaValues: MOBILE_NAVIGATION_VALUES,
+ resolve: ({ value }: { value?: string } = {}) =>
+ resolveMobileNavigationPrompt(value as any),
+ coverageContexts: [{}],
+ },
+ mobileUI: {
+ schemaValues: MOBILE_UI_VALUES,
+ resolve: ({ value }: { value?: string } = {}) => resolveMobileUIPrompt(value as any),
+ coverageContexts: [{}],
+ },
+ mobileStorage: {
+ schemaValues: MOBILE_STORAGE_VALUES,
+ resolve: ({ value }: { value?: string } = {}) => resolveMobileStoragePrompt(value as any),
+ coverageContexts: [{}],
+ },
+ mobileTesting: {
+ schemaValues: MOBILE_TESTING_VALUES,
+ resolve: ({ value }: { value?: string } = {}) => resolveMobileTestingPrompt(value as any),
+ coverageContexts: [{}],
+ },
+ mobilePush: {
+ schemaValues: MOBILE_PUSH_VALUES,
+ resolve: ({ value }: { value?: string } = {}) => resolveMobilePushPrompt(value as any),
+ coverageContexts: [{}],
+ },
+ mobileOTA: {
+ schemaValues: MOBILE_OTA_VALUES,
+ resolve: ({ value }: { value?: string } = {}) => resolveMobileOTAPrompt(value as any),
+ coverageContexts: [{}],
+ },
+ mobileDeepLinking: {
+ schemaValues: MOBILE_DEEP_LINKING_VALUES,
+ resolve: ({ value }: { value?: string } = {}) =>
+ resolveMobileDeepLinkingPrompt(value as any),
+ coverageContexts: [{}],
+ },
uiLibrary: {
schemaValues: UI_LIBRARY_VALUES,
resolve: resolveUILibraryPrompt,
@@ -442,4 +505,25 @@ export const PROMPT_RESOLVER_REGISTRY: ResolverRegistry = {
resolveJavaTestingLibrariesPrompt(value as any),
coverageContexts: [{}, { value: ["none"] }],
},
+ elixirWebFramework: {
+ schemaValues: ELIXIR_WEB_FRAMEWORK_VALUES,
+ resolve: ({ value }: { value?: string } = {}) =>
+ resolveElixirWebFrameworkPrompt(value as any),
+ coverageContexts: [{}],
+ },
+ elixirDatabase: {
+ schemaValues: ELIXIR_DATABASE_VALUES,
+ resolve: ({ value }: { value?: string } = {}) => resolveElixirDatabasePrompt(value as any),
+ coverageContexts: [{}],
+ },
+ elixirLibraries: {
+ schemaValues: ELIXIR_LIBRARIES_VALUES,
+ resolve: ({ value }: { value?: string[] } = {}) => resolveElixirLibrariesPrompt(value as any),
+ coverageContexts: [{}, { value: ["none"] }],
+ },
+ elixirTesting: {
+ schemaValues: ELIXIR_TESTING_VALUES,
+ resolve: ({ value }: { value?: string[] } = {}) => resolveElixirTestingPrompt(value as any),
+ coverageContexts: [{}, { value: ["none"] }],
+ },
};
diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts
index d736ab7b4..d8560e7a1 100644
--- a/apps/cli/src/utils/bts-config.ts
+++ b/apps/cli/src/utils/bts-config.ts
@@ -45,6 +45,13 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
observability: projectConfig.observability,
featureFlags: projectConfig.featureFlags,
analytics: projectConfig.analytics,
+ mobileNavigation: projectConfig.mobileNavigation,
+ mobileUI: projectConfig.mobileUI,
+ mobileStorage: projectConfig.mobileStorage,
+ mobileTesting: projectConfig.mobileTesting,
+ mobilePush: projectConfig.mobilePush,
+ mobileOTA: projectConfig.mobileOTA,
+ mobileDeepLinking: projectConfig.mobileDeepLinking,
cms: projectConfig.cms,
caching: projectConfig.caching,
i18n: projectConfig.i18n,
@@ -81,6 +88,10 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
javaAuth: projectConfig.javaAuth,
javaLibraries: projectConfig.javaLibraries,
javaTestingLibraries: projectConfig.javaTestingLibraries,
+ elixirWebFramework: projectConfig.elixirWebFramework,
+ elixirDatabase: projectConfig.elixirDatabase,
+ elixirLibraries: projectConfig.elixirLibraries,
+ elixirTesting: projectConfig.elixirTesting,
aiDocs: projectConfig.aiDocs,
};
@@ -121,6 +132,13 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
observability: btsConfig.observability,
featureFlags: btsConfig.featureFlags,
analytics: btsConfig.analytics,
+ mobileNavigation: btsConfig.mobileNavigation,
+ mobileUI: btsConfig.mobileUI,
+ mobileStorage: btsConfig.mobileStorage,
+ mobileTesting: btsConfig.mobileTesting,
+ mobilePush: btsConfig.mobilePush,
+ mobileOTA: btsConfig.mobileOTA,
+ mobileDeepLinking: btsConfig.mobileDeepLinking,
cms: btsConfig.cms,
caching: btsConfig.caching,
i18n: btsConfig.i18n,
@@ -157,6 +175,10 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
javaAuth: btsConfig.javaAuth,
javaLibraries: btsConfig.javaLibraries,
javaTestingLibraries: btsConfig.javaTestingLibraries,
+ elixirWebFramework: btsConfig.elixirWebFramework,
+ elixirDatabase: btsConfig.elixirDatabase,
+ elixirLibraries: btsConfig.elixirLibraries,
+ elixirTesting: btsConfig.elixirTesting,
aiDocs: btsConfig.aiDocs,
};
diff --git a/apps/cli/src/utils/config-processing.ts b/apps/cli/src/utils/config-processing.ts
index 10008f39a..b9769e034 100644
--- a/apps/cli/src/utils/config-processing.ts
+++ b/apps/cli/src/utils/config-processing.ts
@@ -52,4 +52,6 @@ export function validateArrayOptions(options: CLIInput) {
validateNoneExclusivity(options.pythonAi, "python ai libraries");
validateNoneExclusivity(options.javaLibraries, "java libraries");
validateNoneExclusivity(options.javaTestingLibraries, "java testing libraries");
+ validateNoneExclusivity(options.elixirLibraries, "elixir libraries");
+ validateNoneExclusivity(options.elixirTesting, "elixir testing libraries");
}
diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts
index 751b7e9ac..d4658f8d9 100644
--- a/apps/cli/src/utils/config-validation.ts
+++ b/apps/cli/src/utils/config-validation.ts
@@ -559,6 +559,21 @@ export function validateFrontendConstraints(
const { frontend } = config;
if (frontend && frontend.length > 0) {
+ if (
+ config.ecosystem === "react-native" &&
+ frontend.some((item) => !item.startsWith("native-") && item !== "none")
+ ) {
+ incompatibilityError({
+ message: "React Native ecosystem only supports native Expo frontends.",
+ provided: { ecosystem: "react-native", frontend: frontend.join(" ") },
+ suggestions: [
+ "Use --frontend native-bare",
+ "Use --frontend native-uniwind",
+ "Use --frontend native-unistyles",
+ ],
+ });
+ }
+
ensureSingleWebAndNative(frontend);
if (providedFlags.has("api") && providedFlags.has("frontend") && config.api) {
@@ -638,6 +653,25 @@ export function validateJavaConstraints(
}
}
+export function validateElixirConstraints(config: Partial) {
+ if (config.ecosystem !== "elixir") return;
+
+ const hasOban = (config.elixirLibraries ?? []).includes("oban");
+ if (hasOban && config.elixirDatabase !== "ecto") {
+ incompatibilityError({
+ message: "Oban requires Ecto in the Elixir scaffold.",
+ provided: {
+ "elixir-database": config.elixirDatabase ?? "none",
+ "elixir-libraries": (config.elixirLibraries ?? []).join(" ") || "none",
+ },
+ suggestions: [
+ "Use --elixir-database ecto with --elixir-libraries oban",
+ "Remove oban from --elixir-libraries for a database-free Mix project",
+ ],
+ });
+ }
+}
+
export function validateEmailConstraints(config: Partial) {
if (!config.email || config.email === "none") return;
if (config.ecosystem !== "typescript" && config.email !== "resend") {
@@ -811,6 +845,7 @@ export function validateFullConfig(
validateCachingConstraints(config);
validateSearchConstraints(config);
validateJavaConstraints(config, providedFlags);
+ validateElixirConstraints(config);
validateServerDeployRequiresBackend(config.serverDeploy, config.backend);
@@ -905,6 +940,7 @@ export function validateConfigForProgrammaticUse(config: Partial)
validateCachingConstraints(config);
validateSearchConstraints(config);
validateJavaConstraints(config);
+ validateElixirConstraints(config);
validatePaymentsCompatibility(config.payments, config.auth, config.backend, config.frontend);
diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts
index 98a3d081f..32cc0cf99 100644
--- a/apps/cli/src/utils/display-config.ts
+++ b/apps/cli/src/utils/display-config.ts
@@ -104,10 +104,50 @@ export function displayConfig(config: Partial) {
configDisplay.push(`${pc.blue("Observability:")} ${String(config.observability)}`);
}
+ if (config.featureFlags !== undefined) {
+ configDisplay.push(`${pc.blue("Feature Flags:")} ${String(config.featureFlags)}`);
+ }
+
+ if (config.analytics !== undefined) {
+ configDisplay.push(`${pc.blue("Analytics:")} ${String(config.analytics)}`);
+ }
+
+ if (config.mobileNavigation !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile Navigation:")} ${String(config.mobileNavigation)}`);
+ }
+
+ if (config.mobileUI !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile UI:")} ${String(config.mobileUI)}`);
+ }
+
+ if (config.mobileStorage !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile Storage:")} ${String(config.mobileStorage)}`);
+ }
+
+ if (config.mobileTesting !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile Testing:")} ${String(config.mobileTesting)}`);
+ }
+
+ if (config.mobilePush !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile Push:")} ${String(config.mobilePush)}`);
+ }
+
+ if (config.mobileOTA !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile OTA:")} ${String(config.mobileOTA)}`);
+ }
+
+ if (config.mobileDeepLinking !== undefined) {
+ configDisplay.push(`${pc.blue("Mobile Deep Linking:")} ${String(config.mobileDeepLinking)}`);
+ }
+
if (config.caching !== undefined) {
configDisplay.push(`${pc.blue("Caching:")} ${String(config.caching)}`);
}
+ if (config.i18n !== undefined) {
+ configDisplay.push(`${pc.blue("i18n:")} ${String(config.i18n)}`);
+ }
+
if (config.cms !== undefined) {
configDisplay.push(`${pc.blue("CMS:")} ${String(config.cms)}`);
}
@@ -133,6 +173,12 @@ export function displayConfig(config: Partial) {
configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`);
}
+ if (config.aiDocs !== undefined) {
+ const aiDocs = Array.isArray(config.aiDocs) ? config.aiDocs : [config.aiDocs];
+ const aiDocsText = aiDocs.length > 0 && aiDocs[0] !== undefined ? aiDocs.join(", ") : "none";
+ configDisplay.push(`${pc.blue("AI Docs:")} ${aiDocsText}`);
+ }
+
if (config.git !== undefined) {
const gitText =
typeof config.git === "boolean" ? (config.git ? "Yes" : "No") : String(config.git);
diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts
index 41ec6b122..28e72fbc6 100644
--- a/apps/cli/src/utils/generate-reproducible-command.ts
+++ b/apps/cli/src/utils/generate-reproducible-command.ts
@@ -97,6 +97,13 @@ function getTypeScriptFlags(config: ProjectConfig) {
flags.push(`--cms ${config.cms}`);
flags.push(`--search ${config.search}`);
flags.push(`--file-storage ${config.fileStorage}`);
+ flags.push(`--mobile-navigation ${config.mobileNavigation}`);
+ flags.push(`--mobile-ui ${config.mobileUI}`);
+ flags.push(`--mobile-storage ${config.mobileStorage}`);
+ flags.push(`--mobile-testing ${config.mobileTesting}`);
+ flags.push(`--mobile-push ${config.mobilePush}`);
+ flags.push(`--mobile-ota ${config.mobileOTA}`);
+ flags.push(`--mobile-deep-linking ${config.mobileDeepLinking}`);
if (config.addons && config.addons.length > 0) {
flags.push(`--addons ${config.addons.join(" ")}`);
@@ -119,6 +126,29 @@ function getTypeScriptFlags(config: ProjectConfig) {
return flags;
}
+function getReactNativeFlags(config: ProjectConfig) {
+ const flags = ["--ecosystem react-native"];
+
+ if (config.frontend && config.frontend.length > 0) {
+ flags.push(`--frontend ${config.frontend.join(" ")}`);
+ } else {
+ flags.push("--frontend native-bare");
+ }
+
+ flags.push(`--auth ${config.auth}`);
+ flags.push(`--mobile-navigation ${config.mobileNavigation}`);
+ flags.push(`--mobile-ui ${config.mobileUI}`);
+ flags.push(`--mobile-storage ${config.mobileStorage}`);
+ flags.push(`--mobile-testing ${config.mobileTesting}`);
+ flags.push(`--mobile-push ${config.mobilePush}`);
+ flags.push(`--mobile-ota ${config.mobileOTA}`);
+ flags.push(`--mobile-deep-linking ${config.mobileDeepLinking}`);
+
+ appendCommonFlags(flags, config);
+
+ return flags;
+}
+
function getRustFlags(config: ProjectConfig) {
const flags = ["--ecosystem rust"];
@@ -191,10 +221,26 @@ function getJavaFlags(config: ProjectConfig) {
return flags;
}
+function getElixirFlags(config: ProjectConfig) {
+ const flags = ["--ecosystem elixir"];
+
+ flags.push(`--elixir-web-framework ${config.elixirWebFramework}`);
+ flags.push(`--elixir-database ${config.elixirDatabase}`);
+ flags.push(formatArrayFlag("elixir-libraries", config.elixirLibraries));
+ flags.push(formatArrayFlag("elixir-testing", config.elixirTesting));
+
+ appendCommonFlags(flags, config);
+
+ return flags;
+}
+
export function generateReproducibleCommand(config: ProjectConfig) {
let flags: string[];
switch (config.ecosystem) {
+ case "react-native":
+ flags = getReactNativeFlags(config);
+ break;
case "rust":
flags = getRustFlags(config);
break;
@@ -207,6 +253,9 @@ export function generateReproducibleCommand(config: ProjectConfig) {
case "java":
flags = getJavaFlags(config);
break;
+ case "elixir":
+ flags = getElixirFlags(config);
+ break;
case "typescript":
default:
flags = getTypeScriptFlags(config);
diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap
index 7d896281f..b94256e03 100644
--- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap
+++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap
@@ -786,6 +786,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac
"CLAUDE.md",
"README.md",
"apps/native/.env",
+ "apps/native/.env.example",
"apps/native/app.json",
"apps/native/app/(drawer)/(tabs)/_layout.tsx",
"apps/native/app/(drawer)/(tabs)/index.tsx",
@@ -797,9 +798,11 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac
"apps/native/app/modal.tsx",
"apps/native/components/container.tsx",
"apps/native/components/header-button.tsx",
+ "apps/native/components/mobile-ui-provider.tsx",
"apps/native/components/tabbar-icon.tsx",
"apps/native/lib/android-navigation-bar.tsx",
"apps/native/lib/constants.ts",
+ "apps/native/lib/deep-linking.ts",
"apps/native/lib/use-color-scheme.ts",
"apps/native/metro.config.js",
"apps/native/package.json",
@@ -833,6 +836,56 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac
]
`;
+exports[`Template Snapshots File Structure Snapshots file structure: native-mobile-integrations 1`] = `
+[
+ "CLAUDE.md",
+ "README.md",
+ "apps/native/.env",
+ "apps/native/.env.example",
+ "apps/native/.maestro/home.yaml",
+ "apps/native/App.tsx",
+ "apps/native/__tests__/mobile-ui-provider.test.tsx",
+ "apps/native/app.json",
+ "apps/native/components/container.tsx",
+ "apps/native/components/header-button.tsx",
+ "apps/native/components/mobile-ui-provider.tsx",
+ "apps/native/components/tabbar-icon.tsx",
+ "apps/native/index.js",
+ "apps/native/jest.config.js",
+ "apps/native/lib/android-navigation-bar.tsx",
+ "apps/native/lib/constants.ts",
+ "apps/native/lib/deep-linking.ts",
+ "apps/native/lib/mobile-storage.ts",
+ "apps/native/lib/notifications.ts",
+ "apps/native/lib/updates.ts",
+ "apps/native/lib/use-color-scheme.ts",
+ "apps/native/metro.config.js",
+ "apps/native/navigation/native-navigation.tsx",
+ "apps/native/package.json",
+ "apps/native/tsconfig.json",
+ "apps/native/utils/orpc.ts",
+ "apps/server/.env",
+ "apps/server/package.json",
+ "apps/server/src/index.ts",
+ "apps/server/tsconfig.json",
+ "apps/server/tsdown.config.ts",
+ "package.json",
+ "packages/api/package.json",
+ "packages/api/src/context.ts",
+ "packages/api/src/index.ts",
+ "packages/api/src/routers/index.ts",
+ "packages/api/tsconfig.json",
+ "packages/config/package.json",
+ "packages/config/tsconfig.base.json",
+ "packages/env/package.json",
+ "packages/env/src/native.ts",
+ "packages/env/src/server.ts",
+ "packages/env/src/web.ts",
+ "packages/env/tsconfig.json",
+ "tsconfig.json",
+]
+`;
+
exports[`Template Snapshots File Structure Snapshots file structure: java-spring-boot-jpa-security 1`] = `
[
".env.example",
@@ -11234,12 +11287,16 @@ export default defineConfig({
exports[`Template Snapshots Key File Content Snapshots key files: native-react-native 1`] = `
{
- "fileCount": 62,
+ "fileCount": 65,
"files": [
{
"content": "[exists]",
"path": "apps/native/.env",
},
+ {
+ "content": "EXPO_PUBLIC_SERVER_URL=http://localhost:3000",
+ "path": "apps/native/.env.example",
+ },
{
"content": "[exists]",
"path": "apps/native/app.json",
@@ -11300,7 +11357,6 @@ const styles = StyleSheet.create({
fontSize: 16,
},
});
-
"
,
"path": "apps/native/app/(drawer)/(tabs)/index.tsx",
@@ -11446,6 +11502,10 @@ fontWeight: "bold",
"content": "[exists]",
"path": "apps/native/components/header-button.tsx",
},
+ {
+ "content": "[exists]",
+ "path": "apps/native/components/mobile-ui-provider.tsx",
+ },
{
"content": "[exists]",
"path": "apps/native/components/tabbar-icon.tsx",
@@ -11458,6 +11518,10 @@ fontWeight: "bold",
"content": "[exists]",
"path": "apps/native/lib/constants.ts",
},
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/deep-linking.ts",
+ },
{
"content": "[exists]",
"path": "apps/native/lib/use-color-scheme.ts",
@@ -11484,6 +11548,7 @@ fontWeight: "bold",
"@react-navigation/bottom-tabs": "^7.16.1",
"@react-navigation/drawer": "^7.10.2",
"@react-navigation/native": "^7.2.4",
+ "@react-navigation/native-stack": "^7.8.1",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"expo": "^55.0.24",
@@ -12021,6 +12086,647 @@ export const db = drizzle({ client, schema });
}
`;
+exports[`Template Snapshots Key File Content Snapshots key files: native-mobile-integrations 1`] = `
+{
+ "fileCount": 59,
+ "files": [
+ {
+ "content": "[exists]",
+ "path": "apps/native/__tests__/mobile-ui-provider.test.tsx",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/.env",
+ },
+ {
+ "content":
+"EXPO_PUBLIC_SERVER_URL=http://localhost:3000
+# EAS project ID for Expo push notification tokens
+EXPO_PUBLIC_EAS_PROJECT_ID=your-eas-project-id"
+,
+ "path": "apps/native/.env.example",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/.maestro/home.yaml",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/app.json",
+ },
+ {
+ "content":
+"
+import { QueryClientProvider } from "@tanstack/react-query";
+import { NavigationContainer } from "@react-navigation/native";
+import { StatusBar } from "expo-status-bar";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { StyleSheet } from "react-native";
+import { MobileUIProvider } from "@/components/mobile-ui-provider";
+import { linking } from "@/lib/deep-linking";
+import { queryClient } from "@/utils/orpc";
+import { RootNavigator } from "@/navigation/native-navigation";
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
+
+
+function AppShell() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
+"
+,
+ "path": "apps/native/App.tsx",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/components/container.tsx",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/components/header-button.tsx",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/components/mobile-ui-provider.tsx",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/components/tabbar-icon.tsx",
+ },
+ {
+ "content":
+"import { registerRootComponent } from "expo";
+
+import App from "./App";
+
+registerRootComponent(App);
+"
+,
+ "path": "apps/native/index.js",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/jest.config.js",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/android-navigation-bar.tsx",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/constants.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/deep-linking.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/mobile-storage.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/notifications.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/updates.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/lib/use-color-scheme.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/metro.config.js",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/native/navigation/native-navigation.tsx",
+ },
+ {
+ "content":
+"{
+ "name": "native",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "dev": "expo start --clear",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "prebuild": "expo prebuild",
+ "web": "expo start --web",
+ "test": "jest"
+ },
+ "dependencies": {
+ "@expo/vector-icons": "^15.1.1",
+ "@react-navigation/bottom-tabs": "^7.16.1",
+ "@react-navigation/drawer": "^7.10.2",
+ "@react-navigation/native": "^7.2.4",
+ "@react-navigation/native-stack": "^7.8.1",
+ "@tanstack/react-form": "^1.32.0",
+ "@gluestack-ui/themed": "^1.1.73",
+ "@tanstack/react-query": "^5.100.10",
+ "expo": "^55.0.24",
+ "expo-constants": "^55.0.16",
+ "expo-device": "^8.0.9",
+ "expo-crypto": "^55.0.15",
+ "expo-linking": "^55.0.15",
+ "expo-navigation-bar": "^55.0.13",
+ "expo-network": "^55.0.14",
+ "expo-notifications": "^56.0.12",
+ "expo-secure-store": "^55.0.14",
+ "expo-splash-screen": "^55.0.21",
+ "expo-status-bar": "^55.0.6",
+ "expo-system-ui": "^55.0.18",
+ "expo-updates": "^56.0.15",
+ "expo-web-browser": "^55.0.16",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6",
+ "react-native": "^0.85.3",
+ "react-native-mmkv": "^4.1.0",
+ "react-native-gesture-handler": "^2.31.2",
+ "react-native-reanimated": "^4.3.1",
+ "react-native-safe-area-context": "^5.7.0",
+ "react-native-screens": "^4.25.0",
+ "react-native-web": "^0.21.2",
+ "react-native-worklets": "^0.8.3",
+ "dotenv": "catalog:",
+ "zod": "catalog:",
+ "@snapshot-native-mobile-integrations/env": "workspace:*",
+ "@snapshot-native-mobile-integrations/api": "workspace:*",
+ "@orpc/tanstack-query": "^1.14.3",
+ "@orpc/client": "catalog:"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.0",
+ "@types/react": "^19.2.14",
+ "@types/jest": "^29.5.14",
+ "@testing-library/react-native": "^13.3.3",
+ "@react-native/jest-preset": "^0.85.3",
+ "jest": "^29.7.0",
+ "jest-expo": "^55.0.18",
+ "react-test-renderer": "^19.2.6",
+ "typescript": "catalog:",
+ "@snapshot-native-mobile-integrations/config": "workspace:*"
+ },
+ "private": true
+}
+"
+,
+ "path": "apps/native/package.json",
+ },
+ {
+ "content":
+"{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+}
+
+"
+,
+ "path": "apps/native/tsconfig.json",
+ },
+ {
+ "content":
+"import { createORPCClient } from "@orpc/client";
+import { RPCLink } from "@orpc/client/fetch";
+import { createTanstackQueryUtils } from "@orpc/tanstack-query";
+import { QueryCache, QueryClient } from "@tanstack/react-query";
+import type { AppRouterClient } from "@snapshot-native-mobile-integrations/api/routers/index";
+import { env } from "@snapshot-native-mobile-integrations/env/native";
+
+export const queryClient = new QueryClient({
+ queryCache: new QueryCache({
+ onError: (error) => {
+ console.log(error)
+ },
+ }),
+});
+
+export const link = new RPCLink({
+ url: \`\${env.EXPO_PUBLIC_SERVER_URL}/rpc\`,
+});
+
+export const client: AppRouterClient = createORPCClient(link);
+
+export const orpc = createTanstackQueryUtils(client);
+"
+,
+ "path": "apps/native/utils/orpc.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/server/.env",
+ },
+ {
+ "content":
+"{
+ "name": "server",
+ "main": "src/index.ts",
+ "type": "module",
+ "scripts": {
+ "build": "tsdown",
+ "check-types": "tsc -b",
+ "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
+ "dev": "bun run --hot src/index.ts",
+ "start": "bun run dist/index.js"
+ },
+ "dependencies": {
+ "dotenv": "catalog:",
+ "zod": "catalog:",
+ "@snapshot-native-mobile-integrations/env": "workspace:*",
+ "@snapshot-native-mobile-integrations/api": "workspace:*",
+ "hono": "catalog:",
+ "@orpc/server": "catalog:",
+ "@orpc/openapi": "catalog:",
+ "@orpc/zod": "catalog:"
+ },
+ "devDependencies": {
+ "typescript": "catalog:",
+ "tsdown": "^0.22.0",
+ "@snapshot-native-mobile-integrations/config": "workspace:*",
+ "@types/bun": "catalog:"
+ }
+}
+"
+,
+ "path": "apps/server/package.json",
+ },
+ {
+ "content":
+"import { env } from "@snapshot-native-mobile-integrations/env/server";
+import { OpenAPIHandler } from "@orpc/openapi/fetch";
+import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
+import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
+import { RPCHandler } from "@orpc/server/fetch";
+import { onError } from "@orpc/server";
+import { createContext } from "@snapshot-native-mobile-integrations/api/context";
+import { appRouter } from "@snapshot-native-mobile-integrations/api/routers/index";
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { logger } from "hono/logger";
+
+const app = new Hono();
+
+app.use(logger());
+app.use(
+ "/*",
+ cors({
+ origin: env.CORS_ORIGIN,
+ allowMethods: ["GET", "POST", "OPTIONS"],
+ })
+);
+
+
+export const apiHandler = new OpenAPIHandler(appRouter, {
+ plugins: [
+ new OpenAPIReferencePlugin({
+ schemaConverters: [new ZodToJsonSchemaConverter()],
+ }),
+ ],
+ interceptors: [
+ onError((error) => {
+ console.error(error);
+ }),
+ ],
+});
+
+export const rpcHandler = new RPCHandler(appRouter, {
+ interceptors: [
+ onError((error) => {
+ console.error(error);
+ }),
+ ],
+});
+
+app.use("/*", async (c, next) => {
+ const context = await createContext({ context: c });
+
+ const rpcResult = await rpcHandler.handle(c.req.raw, {
+ prefix: "/rpc",
+ context: context,
+ });
+
+ if (rpcResult.matched) {
+ return c.newResponse(rpcResult.response.body, rpcResult.response);
+ }
+
+ const apiResult = await apiHandler.handle(c.req.raw, {
+ prefix: "/api-reference",
+ context: context,
+ });
+
+ if (apiResult.matched) {
+ return c.newResponse(apiResult.response.body, apiResult.response);
+ }
+
+ await next();
+});
+
+
+
+
+app.get("/", (c) => {
+ return c.text("OK");
+});
+
+export default app;
+"
+,
+ "path": "apps/server/src/index.ts",
+ },
+ {
+ "content":
+"{
+ "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "dist",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ }
+}
+"
+,
+ "path": "apps/server/tsconfig.json",
+ },
+ {
+ "content": "[exists]",
+ "path": "apps/server/tsdown.config.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "CLAUDE.md",
+ },
+ {
+ "content":
+"{
+ "name": "snapshot-native-mobile-integrations",
+ "private": true,
+ "type": "module",
+ "workspaces": {
+ "packages": [
+ "apps/*",
+ "packages/*"
+ ],
+ "catalog": {
+ "dotenv": "^17.4.2",
+ "zod": "^4.4.3",
+ "typescript": "^6.0.3",
+ "@types/bun": "^1.3.14",
+ "hono": "^4.12.19",
+ "@orpc/server": "^1.14.3",
+ "@orpc/openapi": "^1.14.3",
+ "@orpc/zod": "^1.14.3",
+ "@orpc/client": "^1.14.3"
+ }
+ },
+ "scripts": {
+ "dev": "bun run --filter '*' dev",
+ "build": "bun run --filter '*' build",
+ "check-types": "bun run --if-present --filter '*' check-types",
+ "dev:native": "bun run --filter native dev",
+ "dev:web": "bun run --filter web dev",
+ "dev:server": "bun run --filter server dev"
+ },
+ "packageManager": "bun@1.3.5",
+ "dependencies": {
+ "dotenv": "catalog:",
+ "zod": "catalog:",
+ "@snapshot-native-mobile-integrations/env": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "catalog:",
+ "@types/bun": "catalog:",
+ "@snapshot-native-mobile-integrations/config": "workspace:*"
+ }
+}
+"
+,
+ "path": "package.json",
+ },
+ {
+ "content":
+"{
+ "name": "@snapshot-native-mobile-integrations/api",
+ "exports": {
+ ".": {
+ "default": "./src/index.ts"
+ },
+ "./*": {
+ "default": "./src/*.ts"
+ }
+ },
+ "type": "module",
+ "scripts": {},
+ "devDependencies": {
+ "typescript": "catalog:",
+ "@snapshot-native-mobile-integrations/config": "workspace:*"
+ },
+ "dependencies": {
+ "dotenv": "catalog:",
+ "zod": "catalog:",
+ "@snapshot-native-mobile-integrations/env": "workspace:*",
+ "@orpc/server": "catalog:",
+ "@orpc/client": "catalog:",
+ "@orpc/openapi": "catalog:",
+ "@orpc/zod": "catalog:",
+ "hono": "catalog:"
+ }
+}
+"
+,
+ "path": "packages/api/package.json",
+ },
+ {
+ "content": "[exists]",
+ "path": "packages/api/src/context.ts",
+ },
+ {
+ "content":
+"import { os } from "@orpc/server";
+import type { Context } from "./context";
+
+export const o = os.$context();
+
+export const publicProcedure = o;
+
+"
+,
+ "path": "packages/api/src/index.ts",
+ },
+ {
+ "content":
+"import { publicProcedure } from "../index";
+import type { RouterClient } from "@orpc/server";
+
+export const appRouter = {
+ healthCheck: publicProcedure.handler(() => {
+ return "OK";
+ }),
+};
+export type AppRouter = typeof appRouter;
+export type AppRouterClient = RouterClient;
+"
+,
+ "path": "packages/api/src/routers/index.ts",
+ },
+ {
+ "content":
+"{
+ "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "composite": true
+ }
+}
+"
+,
+ "path": "packages/api/tsconfig.json",
+ },
+ {
+ "content":
+"{
+ "name": "@snapshot-native-mobile-integrations/config",
+ "version": "0.0.0",
+ "private": true
+}
+"
+,
+ "path": "packages/config/package.json",
+ },
+ {
+ "content":
+"{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ESNext"],
+ "verbatimModuleSyntax": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "types": [
+ "bun"
+
+ ]
+ }
+}
+"
+,
+ "path": "packages/config/tsconfig.base.json",
+ },
+ {
+ "content":
+"{
+ "name": "@snapshot-native-mobile-integrations/env",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./server": "./src/server.ts",
+ "./native": "./src/native.ts"
+ },
+ "dependencies": {
+ "dotenv": "catalog:",
+ "zod": "catalog:",
+ "@t3-oss/env-core": "^0.13.11"
+ },
+ "devDependencies": {
+ "typescript": "catalog:",
+ "@types/bun": "catalog:",
+ "@snapshot-native-mobile-integrations/config": "workspace:*"
+ }
+}
+"
+,
+ "path": "packages/env/package.json",
+ },
+ {
+ "content": "[exists]",
+ "path": "packages/env/src/native.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "packages/env/src/server.ts",
+ },
+ {
+ "content": "[exists]",
+ "path": "packages/env/src/web.ts",
+ },
+ {
+ "content":
+"{
+ "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json",
+}
+"
+,
+ "path": "packages/env/tsconfig.json",
+ },
+ {
+ "content": "[exists]",
+ "path": "README.md",
+ },
+ {
+ "content":
+"{
+ "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json",
+}
+"
+,
+ "path": "tsconfig.json",
+ },
+ ],
+}
+`;
+
exports[`Template Snapshots Key File Content Snapshots key files: java-spring-boot-jpa-security 1`] = `
{
"fileCount": 22,
diff --git a/apps/cli/test/cli-builder-sync.test.ts b/apps/cli/test/cli-builder-sync.test.ts
index d3fb7274b..8b306c4d3 100644
--- a/apps/cli/test/cli-builder-sync.test.ts
+++ b/apps/cli/test/cli-builder-sync.test.ts
@@ -8,6 +8,10 @@ import { describe, expect, it } from "bun:test";
import { CreateCommandOptionsSchema } from "../src/create-command-input";
import { PROMPT_RESOLVER_REGISTRY } from "../src/prompts/prompt-resolver-registry";
+import {
+ resolveElixirLibrariesPrompt,
+ resolveElixirTestingPrompt,
+} from "../src/prompts/elixir-ecosystem";
import {
resolveJavaLibrariesPrompt,
resolveJavaTestingLibrariesPrompt,
@@ -276,6 +280,20 @@ describe("CLI prompts vs schemas parity", () => {
expect(resolution.mode).toBe("multiple");
expect(resolution.initialValue).toEqual(DEFAULT_CONFIG.javaLibraries);
});
+
+ it("keeps the Elixir libraries prompt default aligned with CLI defaults", () => {
+ const resolution = resolveElixirLibrariesPrompt();
+
+ expect(resolution.mode).toBe("multiple");
+ expect(resolution.initialValue).toEqual(DEFAULT_CONFIG.elixirLibraries);
+ });
+
+ it("keeps the Elixir testing prompt default aligned with CLI defaults", () => {
+ const resolution = resolveElixirTestingPrompt();
+
+ expect(resolution.mode).toBe("multiple");
+ expect(resolution.initialValue).toEqual(DEFAULT_CONFIG.elixirTesting);
+ });
});
describe("CLI array exclusivity", () => {
@@ -318,4 +336,20 @@ describe("CLI array exclusivity", () => {
}),
).toThrow("Cannot combine 'none' with other java libraries.");
});
+
+ it("rejects elixirLibraries mixed with none", () => {
+ expect(() =>
+ validateArrayOptions({
+ elixirLibraries: ["none", "jason"],
+ }),
+ ).toThrow("Cannot combine 'none' with other elixir libraries.");
+ });
+
+ it("rejects elixirTesting mixed with none", () => {
+ expect(() =>
+ validateArrayOptions({
+ elixirTesting: ["none", "exunit"],
+ }),
+ ).toThrow("Cannot combine 'none' with other elixir testing libraries.");
+ });
});
diff --git a/apps/cli/test/elixir-ecosystem.test.ts b/apps/cli/test/elixir-ecosystem.test.ts
new file mode 100644
index 000000000..0015e159f
--- /dev/null
+++ b/apps/cli/test/elixir-ecosystem.test.ts
@@ -0,0 +1,109 @@
+import type { VirtualFile, VirtualNode } from "@better-fullstack/template-generator";
+
+import { describe, expect, it } from "bun:test";
+
+import { createVirtual } from "../src/index";
+import {
+ EcosystemSchema,
+ ElixirDatabaseSchema,
+ ElixirLibrariesSchema,
+ ElixirTestingSchema,
+ ElixirWebFrameworkSchema,
+} from "../src/types";
+
+function extractEnumValues(schema: { options: readonly T[] }): readonly T[] {
+ return schema.options;
+}
+
+function findFile(node: VirtualNode, path: string): VirtualFile | undefined {
+ if (node.type === "file") {
+ const normalizedNodePath = node.path.replace(/^\/+/, "");
+ const normalizedPath = path.replace(/^\/+/, "");
+ return normalizedNodePath === normalizedPath ? node : undefined;
+ }
+
+ for (const child of node.children) {
+ const found = findFile(child, path);
+ if (found) return found;
+ }
+ return undefined;
+}
+
+function hasFile(node: VirtualNode, path: string): boolean {
+ return findFile(node, path) !== undefined;
+}
+
+function getFileContent(node: VirtualNode, path: string): string | undefined {
+ return findFile(node, path)?.content;
+}
+
+describe("Elixir Ecosystem", () => {
+ it("exposes Elixir schemas", () => {
+ expect(extractEnumValues(EcosystemSchema)).toContain("elixir");
+ expect(extractEnumValues(ElixirWebFrameworkSchema)).toEqual(["phoenix", "none"]);
+ expect(extractEnumValues(ElixirDatabaseSchema)).toEqual(["ecto", "none"]);
+ expect(extractEnumValues(ElixirLibrariesSchema)).toEqual([
+ "jason",
+ "req",
+ "oban",
+ "broadway",
+ "telemetry",
+ "nx",
+ "none",
+ ]);
+ expect(extractEnumValues(ElixirTestingSchema)).toEqual([
+ "exunit",
+ "mox",
+ "stream-data",
+ "none",
+ ]);
+ });
+
+ it("creates a plain Mix / OTP project without Phoenix files", async () => {
+ const result = await createVirtual({
+ projectName: "plain-elixir",
+ ecosystem: "elixir",
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: ["jason", "req"],
+ elixirTesting: ["exunit"],
+ });
+
+ expect(result.success).toBe(true);
+ const root = result.tree!.root;
+
+ expect(hasFile(root, "mix.exs")).toBe(true);
+ expect(hasFile(root, "lib/plain_elixir.ex")).toBe(true);
+ expect(hasFile(root, "lib/plain_elixir/application.ex")).toBe(true);
+ expect(hasFile(root, "lib/plain_elixir/worker.ex")).toBe(true);
+ expect(hasFile(root, "lib/plain_elixir/web/router.ex")).toBe(false);
+ expect(hasFile(root, "lib/plain_elixir/repo.ex")).toBe(false);
+ expect(hasFile(root, "test/plain_elixir_test.exs")).toBe(true);
+
+ expect(getFileContent(root, "mix.exs")).toContain('app: :plain_elixir');
+ expect(getFileContent(root, "mix.exs")).toContain('{:jason, "~> 1.4"}');
+ expect(getFileContent(root, "mix.exs")).toContain('{:req, "~> 0.5"}');
+ });
+
+ it("creates Phoenix and Ecto files when selected", async () => {
+ const result = await createVirtual({
+ projectName: "phoenix-api",
+ ecosystem: "elixir",
+ elixirWebFramework: "phoenix",
+ elixirDatabase: "ecto",
+ elixirLibraries: ["jason", "oban", "telemetry"],
+ elixirTesting: ["exunit", "mox"],
+ });
+
+ expect(result.success).toBe(true);
+ const root = result.tree!.root;
+
+ expect(hasFile(root, "lib/phoenix_api/repo.ex")).toBe(true);
+ expect(hasFile(root, "lib/phoenix_api/web/endpoint.ex")).toBe(true);
+ expect(hasFile(root, "lib/phoenix_api/web/router.ex")).toBe(true);
+ expect(hasFile(root, "test/support/mox.ex")).toBe(true);
+ expect(getFileContent(root, "mix.exs")).toContain('{:phoenix, "~> 1.7"}');
+ expect(getFileContent(root, "mix.exs")).toContain('{:ecto_sql, "~> 3.12"}');
+ expect(getFileContent(root, "mix.exs")).toContain('{:oban, "~> 2.19"}');
+ });
+});
diff --git a/apps/cli/test/frontend.test.ts b/apps/cli/test/frontend.test.ts
index 49834b67d..426006ce7 100644
--- a/apps/cli/test/frontend.test.ts
+++ b/apps/cli/test/frontend.test.ts
@@ -466,6 +466,7 @@ describe("Frontend Configurations", () => {
expectSuccess(result);
if (result.projectDir) {
+ const rootDenoJson = await Bun.file(`${result.projectDir}/deno.json`).json();
const denoJson = await Bun.file(`${result.projectDir}/apps/web/deno.json`).text();
const webPkg = await Bun.file(`${result.projectDir}/apps/web/package.json`).json();
const readme = await Bun.file(`${result.projectDir}/README.md`).text();
@@ -475,8 +476,18 @@ describe("Frontend Configurations", () => {
const modernApp = Bun.file(`${result.projectDir}/apps/web/routes/_app.tsx`);
const legacyLayout = Bun.file(`${result.projectDir}/apps/web/src/routes/_layout.tsx`);
+ expect(rootDenoJson).toMatchObject({
+ lock: false,
+ nodeModulesDir: "auto",
+ workspace: ["./apps/web"],
+ });
expect(denoJson).toContain('"fresh": "jsr:@fresh/core@^2.2.0"');
+ expect(denoJson).toContain('"preact/": "npm:preact@^10.27.2/"');
+ expect(denoJson).toContain('"preact/jsx-runtime": "npm:preact@^10.27.2/jsx-runtime"');
+ expect(denoJson).toContain('"preact/jsx-dev-runtime": "npm:preact@^10.27.2/jsx-dev-runtime"');
+ expect(denoJson).toContain('"@preact/signals/": "npm:@preact/signals@^2.5.0/"');
expect(denoJson).toContain('"build": "vite build"');
+ expect(denoJson).not.toContain('"nodeModulesDir"');
expect(webPkg.scripts["check-types"]).toBe("deno check");
expect(readme).toContain("http://localhost:5173");
expect(await viteConfig.exists()).toBe(true);
@@ -487,32 +498,36 @@ describe("Frontend Configurations", () => {
}
});
- it.skipIf(!hasDeno)("should pass Deno check and build for Fresh", async () => {
- const result = await runTRPCTest({
- projectName: "fresh-runtime-smoke",
- frontend: ["fresh"],
- backend: "none",
- runtime: "none",
- database: "none",
- orm: "none",
- auth: "none",
- api: "none",
- addons: ["none"],
- examples: ["none"],
- dbSetup: "none",
- webDeploy: "none",
- serverDeploy: "none",
- install: true,
- });
+ it.skipIf(!hasDeno)(
+ "should pass Deno check and build for Fresh",
+ async () => {
+ const result = await runTRPCTest({
+ projectName: "fresh-runtime-smoke",
+ frontend: ["fresh"],
+ backend: "none",
+ runtime: "none",
+ database: "none",
+ orm: "none",
+ auth: "none",
+ api: "none",
+ addons: ["none"],
+ examples: ["none"],
+ dbSetup: "none",
+ webDeploy: "none",
+ serverDeploy: "none",
+ install: true,
+ });
- expectSuccess(result);
- expect(result.projectDir).toBeDefined();
+ expectSuccess(result);
+ expect(result.projectDir).toBeDefined();
- const projectDir = result.projectDir!;
+ const projectDir = result.projectDir!;
- await execa("bun", ["run", "--filter", "web", "check-types"], { cwd: projectDir });
- await execa("bun", ["run", "--filter", "web", "build"], { cwd: projectDir });
- }, 120000);
+ await execa("bun", ["run", "--filter", "web", "check-types"], { cwd: projectDir });
+ await execa("bun", ["run", "--filter", "web", "build"], { cwd: projectDir });
+ },
+ 120000,
+ );
it("should fail Fresh with tRPC API", async () => {
const result = await runTRPCTest({
diff --git a/apps/cli/test/generate-reproducible-command.test.ts b/apps/cli/test/generate-reproducible-command.test.ts
index 3a64007b0..c8f408416 100644
--- a/apps/cli/test/generate-reproducible-command.test.ts
+++ b/apps/cli/test/generate-reproducible-command.test.ts
@@ -87,6 +87,10 @@ function makeConfig(overrides: Partial = {}): ProjectConfig {
javaAuth: "none",
javaLibraries: [],
javaTestingLibraries: ["junit5"],
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: [],
+ elixirTesting: [],
aiDocs: ["claude-md"],
...overrides,
};
@@ -433,4 +437,37 @@ describe("generateReproducibleCommand", () => {
);
expect(command).not.toContain("--auth ");
});
+
+ it("generates an Elixir command with Mix and Phoenix options", () => {
+ const config = makeConfig({
+ ecosystem: "elixir",
+ frontend: [],
+ backend: "none",
+ runtime: "none",
+ api: "none",
+ cssFramework: "none",
+ uiLibrary: "none",
+ forms: "none",
+ testing: "none",
+ validation: "none",
+ elixirWebFramework: "phoenix",
+ elixirDatabase: "ecto",
+ elixirLibraries: ["jason", "oban"],
+ elixirTesting: ["exunit", "mox"],
+ aiDocs: ["none"],
+ });
+
+ expect(generateReproducibleCommand(config)).toBe(
+ "bun create better-fullstack@latest my-app " +
+ "--ecosystem elixir " +
+ "--elixir-web-framework phoenix " +
+ "--elixir-database ecto " +
+ "--elixir-libraries jason oban " +
+ "--elixir-testing exunit mox " +
+ "--ai-docs none " +
+ "--git " +
+ "--package-manager bun " +
+ "--install",
+ );
+ });
});
diff --git a/apps/cli/test/go-language.test.ts b/apps/cli/test/go-language.test.ts
index e99ad7417..3112b4887 100644
--- a/apps/cli/test/go-language.test.ts
+++ b/apps/cli/test/go-language.test.ts
@@ -66,13 +66,14 @@ const GO_LOGGINGS = extractEnumValues(GoLoggingSchema);
describe("Go Language Support", () => {
describe("Schema Definitions", () => {
- it("should have ecosystem schema with typescript, rust, python, go, and java", () => {
+ it("should have ecosystem schema with typescript, rust, python, go, java, and elixir", () => {
expect(ECOSYSTEMS).toContain("typescript");
expect(ECOSYSTEMS).toContain("rust");
expect(ECOSYSTEMS).toContain("python");
expect(ECOSYSTEMS).toContain("go");
expect(ECOSYSTEMS).toContain("java");
- expect(ECOSYSTEMS.length).toBe(5);
+ expect(ECOSYSTEMS).toContain("elixir");
+ expect(ECOSYSTEMS.length).toBe(7);
});
it("should include GoBetterAuth in auth options", () => {
@@ -576,6 +577,13 @@ describe("Go Language Support", () => {
api: "none",
webDeploy: "none",
serverDeploy: "none",
+ mobileNavigation: "none",
+ mobileUI: "none",
+ mobileStorage: "none",
+ mobileTesting: "none",
+ mobilePush: "none",
+ mobileOTA: "none",
+ mobileDeepLinking: "none",
yolo: "false",
rustWebFramework: "none",
rustFrontend: "none",
diff --git a/apps/cli/test/java-ecosystem.test.ts b/apps/cli/test/java-ecosystem.test.ts
index c768691d0..0b7fb4cd3 100644
--- a/apps/cli/test/java-ecosystem.test.ts
+++ b/apps/cli/test/java-ecosystem.test.ts
@@ -150,7 +150,8 @@ describe("Java Ecosystem", () => {
describe("Schema Definitions", () => {
it("should expose java as a valid ecosystem", () => {
expect(ECOSYSTEMS).toContain("java");
- expect(ECOSYSTEMS).toHaveLength(5);
+ expect(ECOSYSTEMS).toContain("elixir");
+ expect(ECOSYSTEMS).toHaveLength(7);
});
it("should expose scaffolded Java web framework values", () => {
diff --git a/apps/cli/test/mobile.test.ts b/apps/cli/test/mobile.test.ts
new file mode 100644
index 000000000..923c5401e
--- /dev/null
+++ b/apps/cli/test/mobile.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, test } from "bun:test";
+
+import { createVirtual } from "../src/index";
+import type { VirtualDirectory, VirtualFile, VirtualNode } from "../src/index";
+
+function findFile(node: VirtualNode, path: string): VirtualFile | undefined {
+ if (node.type === "file") {
+ return node.path.replace(/^\/+/, "") === path ? node : undefined;
+ }
+
+ for (const child of (node as VirtualDirectory).children) {
+ const found = findFile(child, path);
+ if (found) return found;
+ }
+}
+
+function getFile(root: VirtualNode, path: string) {
+ const file = findFile(root, path);
+ expect(file, `${path} should be generated`).toBeDefined();
+ return file!.content;
+}
+
+describe("mobile native scaffolding", () => {
+ test("generates React Navigation with production mobile integrations", async () => {
+ const result = await createVirtual({
+ projectName: "mobile-rn",
+ frontend: ["native-bare"],
+ backend: "hono",
+ runtime: "bun",
+ database: "none",
+ orm: "none",
+ api: "orpc",
+ auth: "none",
+ cssFramework: "none",
+ uiLibrary: "none",
+ mobileNavigation: "react-navigation",
+ mobileUI: "gluestack-ui",
+ mobileStorage: "mmkv",
+ mobileTesting: "maestro-react-native-testing-library",
+ mobilePush: "expo-notifications",
+ mobileOTA: "expo-updates",
+ mobileDeepLinking: "expo-linking",
+ });
+
+ expect(result.success).toBe(true);
+ const root = result.tree!.root;
+ const pkg = JSON.parse(getFile(root, "apps/native/package.json"));
+ const appConfig = JSON.parse(getFile(root, "apps/native/app.json"));
+
+ expect(pkg.main).toBe("index.js");
+ expect(pkg.dependencies).toMatchObject({
+ "@react-navigation/native-stack": "^7.8.1",
+ "@gluestack-ui/themed": "^1.1.73",
+ "react-native-mmkv": "^4.1.0",
+ "expo-notifications": "^56.0.12",
+ "expo-updates": "^56.0.15",
+ });
+ expect(pkg.dependencies["expo-router"]).toBeUndefined();
+ expect(pkg.scripts.test).toBe("jest");
+
+ expect(appConfig.expo.plugins).not.toContain("expo-router");
+ expect(appConfig.expo.updates.url).toBe("https://u.expo.dev/your-eas-project-id");
+ expect(appConfig.expo.extra.eas.projectId).toBe("your-eas-project-id");
+ expect(getFile(root, "apps/native/App.tsx")).toContain("NavigationContainer");
+ expect(getFile(root, "apps/native/navigation/native-navigation.tsx")).toContain("mobileStorage");
+ expect(getFile(root, "apps/native/lib/notifications.ts")).toContain("getExpoPushTokenAsync");
+ expect(getFile(root, "apps/native/lib/updates.ts")).toContain("checkForUpdateAsync");
+ expect(getFile(root, "apps/native/.maestro/home.yaml")).toContain("launchApp");
+ expect(getFile(root, "apps/native/__tests__/mobile-ui-provider.test.tsx")).toContain(
+ "@testing-library/react-native",
+ );
+ });
+
+ test("keeps Expo Router as the default native navigation", async () => {
+ const result = await createVirtual({
+ projectName: "mobile-router",
+ frontend: ["native-bare"],
+ backend: "hono",
+ api: "trpc",
+ database: "sqlite",
+ orm: "drizzle",
+ auth: "none",
+ });
+
+ expect(result.success).toBe(true);
+ const root = result.tree!.root;
+ const pkg = JSON.parse(getFile(root, "apps/native/package.json"));
+ const appConfig = JSON.parse(getFile(root, "apps/native/app.json"));
+
+ expect(pkg.main).toBe("expo-router/entry");
+ expect(pkg.dependencies["expo-router"]).toBe("^55.0.14");
+ expect(appConfig.expo.plugins).toContain("expo-router");
+ });
+
+ test("omits deep-linking wiring when React Navigation selects no deep linking", async () => {
+ const result = await createVirtual({
+ projectName: "mobile-no-linking",
+ frontend: ["native-bare"],
+ backend: "hono",
+ api: "none",
+ database: "none",
+ orm: "none",
+ auth: "none",
+ mobileNavigation: "react-navigation",
+ mobileDeepLinking: "none",
+ });
+
+ expect(result.success).toBe(true);
+ const root = result.tree!.root;
+ const pkg = JSON.parse(getFile(root, "apps/native/package.json"));
+ const app = getFile(root, "apps/native/App.tsx");
+ const navigation = getFile(root, "apps/native/navigation/native-navigation.tsx");
+
+ expect(pkg.dependencies["expo-linking"]).toBeUndefined();
+ expect(app).toContain("");
+ expect(app).not.toContain("linking={linking}");
+ expect(app).not.toContain("@/lib/deep-linking");
+ expect(navigation).not.toContain("@/lib/deep-linking");
+ expect(findFile(root, "apps/native/lib/deep-linking.ts")).toBeUndefined();
+ });
+});
diff --git a/apps/cli/test/python-language.test.ts b/apps/cli/test/python-language.test.ts
index b23db4754..b0528942a 100644
--- a/apps/cli/test/python-language.test.ts
+++ b/apps/cli/test/python-language.test.ts
@@ -76,13 +76,14 @@ const PYTHON_QUALITIES = extractEnumValues(PythonQualitySchema);
describe("Python Language Support", () => {
describe("Schema Definitions", () => {
- it("should have ecosystem schema with typescript, rust, python, go, and java", () => {
+ it("should have ecosystem schema with typescript, rust, python, go, java, and elixir", () => {
expect(ECOSYSTEMS).toContain("typescript");
expect(ECOSYSTEMS).toContain("rust");
expect(ECOSYSTEMS).toContain("python");
expect(ECOSYSTEMS).toContain("go");
expect(ECOSYSTEMS).toContain("java");
- expect(ECOSYSTEMS.length).toBe(5);
+ expect(ECOSYSTEMS).toContain("elixir");
+ expect(ECOSYSTEMS.length).toBe(7);
});
it("should have python web framework options", () => {
diff --git a/apps/cli/test/rust-ecosystem.test.ts b/apps/cli/test/rust-ecosystem.test.ts
index 9cd9288f0..3c25d26c7 100644
--- a/apps/cli/test/rust-ecosystem.test.ts
+++ b/apps/cli/test/rust-ecosystem.test.ts
@@ -69,13 +69,14 @@ const RUST_LOGGINGS = extractEnumValues(RustLoggingSchema);
describe("Rust Ecosystem", () => {
describe("Schema Definitions", () => {
- it("should have ecosystem schema with typescript, rust, python, go, and java", () => {
+ it("should have ecosystem schema with typescript, rust, python, go, java, and elixir", () => {
expect(ECOSYSTEMS).toContain("typescript");
expect(ECOSYSTEMS).toContain("rust");
expect(ECOSYSTEMS).toContain("python");
expect(ECOSYSTEMS).toContain("go");
expect(ECOSYSTEMS).toContain("java");
- expect(ECOSYSTEMS.length).toBe(5);
+ expect(ECOSYSTEMS).toContain("elixir");
+ expect(ECOSYSTEMS.length).toBe(7);
});
it("should have rust web framework options", () => {
diff --git a/apps/cli/test/template-snapshots.test.ts b/apps/cli/test/template-snapshots.test.ts
index ddcb87c5e..616177da7 100644
--- a/apps/cli/test/template-snapshots.test.ts
+++ b/apps/cli/test/template-snapshots.test.ts
@@ -208,6 +208,24 @@ const SNAPSHOT_CONFIGS: Array<{
auth: "none",
},
},
+ {
+ name: "native-mobile-integrations",
+ config: {
+ frontend: ["native-bare"],
+ backend: "hono",
+ api: "orpc",
+ database: "none",
+ orm: "none",
+ auth: "none",
+ mobileNavigation: "react-navigation",
+ mobileUI: "gluestack-ui",
+ mobileStorage: "mmkv",
+ mobileTesting: "maestro-react-native-testing-library",
+ mobilePush: "expo-notifications",
+ mobileOTA: "expo-updates",
+ mobileDeepLinking: "expo-linking",
+ },
+ },
{
name: "java-spring-boot-jpa-security",
config: {
@@ -273,6 +291,13 @@ const DEFAULT_CONFIG: Partial = {
cms: "none",
ai: "none",
jobQueue: "none",
+ mobileNavigation: "expo-router",
+ mobileUI: "none",
+ mobileStorage: "none",
+ mobileTesting: "none",
+ mobilePush: "none",
+ mobileOTA: "none",
+ mobileDeepLinking: "expo-linking",
};
describe("Template Snapshots", () => {
diff --git a/apps/cli/test/test-utils.ts b/apps/cli/test/test-utils.ts
index b4d7e17cd..a00221c92 100644
--- a/apps/cli/test/test-utils.ts
+++ b/apps/cli/test/test-utils.ts
@@ -43,6 +43,13 @@ import type {
Analytics,
FeatureFlags,
AiDocs,
+ MobileNavigation,
+ MobileUI,
+ MobileStorage,
+ MobileTesting,
+ MobilePush,
+ MobileOTA,
+ MobileDeepLinking,
} from "../src/types";
import { create } from "../src/index";
@@ -165,6 +172,13 @@ export async function runTRPCTest(config: TestConfig): Promise {
"jobQueue",
"analytics",
"featureFlags",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
"aiDocs",
];
const hasSpecificCoreConfig = coreStackFlags.some((flag) => config[flag] !== undefined);
@@ -215,6 +229,13 @@ export async function runTRPCTest(config: TestConfig): Promise {
jobQueue: "none" as JobQueue,
analytics: "none" as Analytics,
featureFlags: "none" as FeatureFlags,
+ mobileNavigation: "none" as MobileNavigation,
+ mobileUI: "none" as MobileUI,
+ mobileStorage: "none" as MobileStorage,
+ mobileTesting: "none" as MobileTesting,
+ mobilePush: "none" as MobilePush,
+ mobileOTA: "none" as MobileOTA,
+ mobileDeepLinking: "none" as MobileDeepLinking,
aiDocs: [] as AiDocs[],
};
diff --git a/apps/web/content/docs/ai/mcp-tools.mdx b/apps/web/content/docs/ai/mcp-tools.mdx
index f7e856171..5adab9630 100644
--- a/apps/web/content/docs/ai/mcp-tools.mdx
+++ b/apps/web/content/docs/ai/mcp-tools.mdx
@@ -166,7 +166,7 @@ The MCP server exposes structured tools for agents. Use them instead of asking a
## Agent Rules
-- Treat `frontend`, `addons`, `examples`, `rustLibraries`, `pythonAi`, `javaLibraries`, and `javaTestingLibraries` as arrays.
+- Treat `frontend`, `addons`, `examples`, `rustLibraries`, `pythonAi`, `javaLibraries`, `javaTestingLibraries`, `elixirLibraries`, and `elixirTesting` as arrays.
- Set `ecosystem` before selecting ecosystem-specific fields.
- Prefer `none` when the user explicitly wants to disable a category.
- Do not call write tools until the user has approved the dry-run or addition plan.
diff --git a/apps/web/content/docs/ai/mcp.mdx b/apps/web/content/docs/ai/mcp.mdx
index 2bf6e6df2..da97ca364 100644
--- a/apps/web/content/docs/ai/mcp.mdx
+++ b/apps/web/content/docs/ai/mcp.mdx
@@ -98,7 +98,7 @@ For an existing project:
- `frontend` is an array and can contain multiple frontend targets.
- `none` disables a category.
- Set `ecosystem` first so the agent uses the correct field family.
-- Array fields include `frontend`, `addons`, `examples`, `rustLibraries`, `pythonAi`, `javaLibraries`, and `javaTestingLibraries`.
+- Array fields include `frontend`, `addons`, `examples`, `rustLibraries`, `pythonAi`, `javaLibraries`, `javaTestingLibraries`, `elixirLibraries`, and `elixirTesting`.
- Current MCP project schemas do not expose `aiDocs` as a create field; project creation uses the server's built-in AI-doc behavior.
- Always call `bfs_check_compatibility` before creating a project.
diff --git a/apps/web/content/docs/ai/overview.mdx b/apps/web/content/docs/ai/overview.mdx
index ed3a5892a..ae03d180f 100644
--- a/apps/web/content/docs/ai/overview.mdx
+++ b/apps/web/content/docs/ai/overview.mdx
@@ -10,7 +10,7 @@ Better Fullstack supports two separate AI workflows:
## Preferred workflow
-1. Choose the target ecosystem: `typescript`, `rust`, `python`, `go`, or `java`.
+1. Choose the target ecosystem: `typescript`, `react-native`, `rust`, `python`, `go`, `java`, or `elixir`.
2. Fetch schema options from MCP or the CLI docs.
3. Validate compatibility before scaffolding.
4. Dry-run to inspect the file tree.
diff --git a/apps/web/content/docs/cli/create.mdx b/apps/web/content/docs/cli/create.mdx
index d660cd871..6ecbaf727 100644
--- a/apps/web/content/docs/cli/create.mdx
+++ b/apps/web/content/docs/cli/create.mdx
@@ -30,7 +30,7 @@ With `npm create`, pass Better Fullstack flags after `--`. Generated reproducibl
| Flag | Values |
| --- | --- |
-| `--frontend` | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `native-bare` `native-uniwind` `native-unistyles` `redwood` `fresh` `none` |
+| `--frontend` | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` |
| `--backend` | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` |
| `--runtime` | `node` `bun` `workers` `none` |
| `--database` | `sqlite` `postgres` `mysql` `mongodb` `edgedb` `redis` `none` |
@@ -137,11 +137,37 @@ Go also supports the global `--auth go-better-auth` option for GoBetterAuth. Tha
Spring Boot requires Maven or Gradle. Java ORM, auth, and libraries require Spring Boot plus a build tool. Flyway requires Spring Data JPA.
+### Elixir
+
+| Flag | Values |
+| --- | --- |
+| `--elixir-web-framework` | `phoenix` `none` |
+| `--elixir-database` | `ecto` `none` |
+| `--elixir-libraries` | `jason` `req` `oban` `broadway` `telemetry` `nx` `none` |
+| `--elixir-testing` | `exunit` `mox` `stream-data` `none` |
+
+Phoenix is optional. Use `--elixir-web-framework none` for plain Mix / OTP projects.
+
+## React Native flags
+
+Use `--ecosystem react-native` for Expo/React Native mobile projects.
+
+| Flag | Values |
+| --- | --- |
+| `--frontend` | `native-bare` `native-uniwind` `native-unistyles` |
+| `--mobile-navigation` | `expo-router` `react-navigation` `none` |
+| `--mobile-ui` | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` |
+| `--mobile-storage` | `mmkv` `none` |
+| `--mobile-testing` | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` |
+| `--mobile-push` | `expo-notifications` `none` |
+| `--mobile-ota` | `expo-updates` `none` |
+| `--mobile-deep-linking` | `expo-linking` `none` |
+
## Global flags
| Flag | Description |
| --- | --- |
-| `--ecosystem` | `typescript` `rust` `python` `go` `java`. Defaults to `typescript`. |
+| `--ecosystem` | `typescript` `react-native` `rust` `python` `go` `java` `elixir`. Defaults to `typescript`. |
| `--template` | `t3` `mern` `pern` `uniwind` `none`. |
| `--addons` | `mcp` `skills` `turborepo` `starlight` `fumadocs` `biome` `oxlint` `ultracite` `lefthook` `husky` `pwa` `tauri` `wxt` `msw` `storybook` `tanstack-query` `tanstack-table` `tanstack-virtual` `tanstack-db` `tanstack-pacer` `opentui` `ruler` `none`. |
| `--examples` | `ai` `chat-sdk` `tanstack-showcase` `none`. |
diff --git a/apps/web/content/docs/ecosystems/elixir.mdx b/apps/web/content/docs/ecosystems/elixir.mdx
new file mode 100644
index 000000000..fdff61e91
--- /dev/null
+++ b/apps/web/content/docs/ecosystems/elixir.mdx
@@ -0,0 +1,49 @@
+---
+title: Elixir
+description: Plain Mix / OTP and Phoenix-capable scaffolds with Ecto, BEAM libraries, and ExUnit support.
+---
+
+Elixir projects use Mix and language-native templates. Phoenix is optional: choose `--elixir-web-framework none` for a plain OTP application, or `phoenix` when you want web routing and endpoint files.
+
+## Prerequisites
+
+- Elixir and Erlang/OTP.
+- Git if you want repository initialization.
+
+## Scripted example
+
+```bash
+npm create better-fullstack@latest my-elixir-service -- \
+ --ecosystem elixir \
+ --elixir-web-framework none \
+ --elixir-database none \
+ --elixir-libraries jason req telemetry \
+ --elixir-testing exunit stream-data \
+ --ai-docs none \
+ --version-channel stable \
+ --no-install \
+ --no-git
+```
+
+## Elixir categories
+
+| Category | Values |
+| --- | --- |
+| Web framework | `phoenix` `none` |
+| Database | `ecto` `none` |
+| Libraries | `jason` `req` `oban` `broadway` `telemetry` `nx` `none` |
+| Testing | `exunit` `mox` `stream-data` `none` |
+
+## Compatibility notes
+
+- `none` for web framework creates a plain Mix / OTP app without Phoenix modules.
+- `phoenix` adds Phoenix endpoint, router, controllers, and web config.
+- `ecto` adds a Repo and Ecto SQL dependencies.
+- `oban` is intended for Ecto-backed projects.
+
+## Generated behavior
+
+- Install runs `mix deps.get` when dependency installation is enabled.
+- Plain Mix projects include an application supervisor and worker.
+- Phoenix projects include Phoenix web files without forcing every Elixir project through Phoenix.
+- AI instruction files are still available through `--ai-docs`, including `--ai-docs none`.
diff --git a/apps/web/content/docs/ecosystems/index.mdx b/apps/web/content/docs/ecosystems/index.mdx
index 4f3607f3e..9e48d544b 100644
--- a/apps/web/content/docs/ecosystems/index.mdx
+++ b/apps/web/content/docs/ecosystems/index.mdx
@@ -3,15 +3,17 @@ title: Ecosystems
description: Supported language ecosystems and what Better Fullstack scaffolds for each one.
---
-Better Fullstack uses one CLI for multiple language ecosystems, but each ecosystem has its own scaffold surface. TypeScript has the broadest integration set; Rust, Python, Go, and Java focus on language-native project templates.
+Better Fullstack uses one CLI for multiple ecosystems, but each ecosystem has its own scaffold surface. TypeScript focuses on fullstack web, React Native focuses on Expo mobile apps, and Rust, Python, Go, Java, and Elixir focus on language-native project templates.
| Ecosystem | Best for | Notes |
| --- | --- | --- |
-| [TypeScript](/docs/ecosystems/typescript/) | Fullstack web, APIs, workers, mobile, desktop, and the largest integration matrix. | Most shared options are TypeScript-first. |
+| [TypeScript](/docs/ecosystems/typescript/) | Fullstack web, APIs, workers, desktop, and the largest web integration matrix. | Web options are TypeScript-first. |
+| [React Native](/docs/ecosystems/react-native/) | Expo mobile apps with navigation, UI, storage, testing, push, OTA, and deep linking. | Uses the React Native ecosystem surface instead of TypeScript web frontend categories. |
| [Rust](/docs/ecosystems/rust/) | Backend services, CLI apps, GraphQL/gRPC, Rust web frontends, and strongly typed libraries. | Uses Cargo; templates are conditional by selected Rust options. |
| [Python](/docs/ecosystems/python/) | API and AI-oriented services with framework, ORM, validation, queue, GraphQL, and quality choices. | Uses `uv`; Python AI is a separate multi-select category. |
| [Go](/docs/ecosystems/go/) | API services, gRPC, CLIs, logging, ORM, and Go auth helpers. | Uses Go modules; GoBetterAuth is selected with global `--auth go-better-auth`. |
| [Java](/docs/ecosystems/java/) | Spring Boot or plain Java projects with Maven/Gradle, Spring Data JPA, Spring Security, libraries, and tests. | Java is supported by flags/schema/templates; verify interactive prompt support before relying on wizard-only flows. |
+| [Elixir](/docs/ecosystems/elixir/) | Plain Mix / OTP services, Phoenix apps, Ecto, BEAM libraries, and ExUnit tests. | Phoenix is optional; `none` creates a plain OTP application. |
## Default CLI baseline
diff --git a/apps/web/content/docs/ecosystems/meta.json b/apps/web/content/docs/ecosystems/meta.json
index fa4c2a151..6b26f5f8d 100644
--- a/apps/web/content/docs/ecosystems/meta.json
+++ b/apps/web/content/docs/ecosystems/meta.json
@@ -1,5 +1,5 @@
{
"title": "Ecosystems",
"defaultOpen": true,
- "pages": ["index", "typescript", "rust", "python", "go", "java"]
+ "pages": ["index", "typescript", "react-native", "rust", "python", "go", "java", "elixir"]
}
diff --git a/apps/web/content/docs/ecosystems/react-native.mdx b/apps/web/content/docs/ecosystems/react-native.mdx
new file mode 100644
index 000000000..2f5a439ac
--- /dev/null
+++ b/apps/web/content/docs/ecosystems/react-native.mdx
@@ -0,0 +1,61 @@
+---
+title: React Native
+description: Expo and React Native mobile scaffolds with production-minded integrations.
+---
+
+React Native is the dedicated mobile ecosystem for Better Fullstack. It scaffolds runnable Expo projects and keeps mobile choices separate from TypeScript web frontend categories.
+
+## Prerequisites
+
+- Node.js 20+.
+- npm, pnpm, Yarn, or Bun.
+- Expo-compatible iOS/Android tooling when you want simulator or device validation.
+
+## Scripted example
+
+```bash
+npm create better-fullstack@latest mobile-app -- \
+ --ecosystem react-native \
+ --frontend native-bare \
+ --mobile-navigation expo-router \
+ --mobile-ui tamagui \
+ --mobile-storage mmkv \
+ --mobile-testing maestro-react-native-testing-library \
+ --mobile-push expo-notifications \
+ --mobile-ota expo-updates \
+ --mobile-deep-linking expo-linking \
+ --package-manager npm \
+ --ai-docs none \
+ --no-install
+```
+
+## Mobile scaffold categories
+
+| Category | Values |
+| --- | --- |
+| Native frontend | `native-bare` `native-uniwind` `native-unistyles` |
+| Navigation | `expo-router` `react-navigation` `none` |
+| UI | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` |
+| Storage | `mmkv` `none` |
+| Testing | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` |
+| Push | `expo-notifications` `none` |
+| OTA | `expo-updates` `none` |
+| Deep linking | `expo-linking` `none` |
+
+## Generated integrations
+
+- Expo config with scheme/runtime settings when deep linking or OTA is selected.
+- Expo Router route files or a React Navigation entrypoint, depending on navigation.
+- Mobile UI provider/theme files for Tamagui, Gluestack UI, Uniwind, or Unistyles.
+- MMKV client helpers when storage is selected.
+- Jest Expo and React Native Testing Library examples, Maestro flows, or both.
+- Push notification registration helpers for Expo Notifications.
+- OTA update helpers for Expo Updates.
+- `.env.example` with `EXPO_PUBLIC_` conventions for mobile client configuration.
+
+## Compatibility notes
+
+- React Native does not expose TypeScript web frontend, backend, database, web UI, web deploy, or server deploy categories.
+- Uniwind and Unistyles UI modes are tied to their matching Expo frontend variants.
+- Tamagui and Gluestack UI use the bare Expo variant to avoid conflicting styling setup.
+- Deep linking is enabled automatically when a mobile auth flow requires redirect URI examples.
diff --git a/apps/web/content/docs/ecosystems/typescript.mdx b/apps/web/content/docs/ecosystems/typescript.mdx
index 2648fef89..e8f2872ad 100644
--- a/apps/web/content/docs/ecosystems/typescript.mdx
+++ b/apps/web/content/docs/ecosystems/typescript.mdx
@@ -1,9 +1,9 @@
---
title: TypeScript
-description: Fullstack web, mobile, desktop, API, worker, and integration scaffolds.
+description: Fullstack web, desktop, API, worker, and integration scaffolds.
---
-TypeScript is the primary Better Fullstack ecosystem and has the broadest support for shared categories: frontend, backend, database, auth, API, UI, services, addons, deploy targets, and examples.
+TypeScript is the primary Better Fullstack web ecosystem and has the broadest support for shared categories: frontend, backend, database, auth, API, UI, services, addons, deploy targets, and examples. React Native/Expo mobile scaffolds now live in the dedicated [React Native](/docs/ecosystems/react-native/) ecosystem.
## Prerequisites
@@ -37,7 +37,6 @@ npm create better-fullstack@latest my-app -- \
| Category | Values |
| --- | --- |
| Web frontend | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` |
-| Native frontend | `native-bare` `native-uniwind` `native-unistyles` `none` |
| Backend | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` |
| Runtime | `node` `bun` `workers` `none` |
| Database | `sqlite` `postgres` `mysql` `mongodb` `edgedb` `redis` `none` |
diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx
index dd93b712b..6d4fd8273 100644
--- a/apps/web/content/docs/index.mdx
+++ b/apps/web/content/docs/index.mdx
@@ -13,7 +13,7 @@ Better Fullstack requires Node.js 20 or newer.
node --version
```
-The CLI itself runs on Node.js even when the generated project targets Rust, Python, Go, or Java. Install those ecosystem toolchains only when you scaffold that ecosystem.
+The CLI itself runs on Node.js even when the generated project targets Rust, Python, Go, Java, or Elixir. Install those ecosystem toolchains only when you scaffold that ecosystem.
## Create a project
@@ -45,4 +45,4 @@ Not ready to scaffold yet? Use the [Stack Builder](https://better-fullstack.dev/
- [Installation](/docs/getting-started/installation/) — prerequisites and launchers.
- [First Project](/docs/getting-started/first-project/) — dry-runs, scripted examples, and project structure.
- [CLI Create](/docs/cli/create/) — flags and examples.
-- [Ecosystems](/docs/ecosystems/) — TypeScript, Rust, Python, Go, and Java support.
+- [Ecosystems](/docs/ecosystems/) — TypeScript, React Native, Rust, Python, Go, Java, and Elixir support.
diff --git a/apps/web/content/docs/reference/options/elixir.mdx b/apps/web/content/docs/reference/options/elixir.mdx
new file mode 100644
index 000000000..158fb77a0
--- /dev/null
+++ b/apps/web/content/docs/reference/options/elixir.mdx
@@ -0,0 +1,61 @@
+---
+title: "Elixir Options"
+description: "Elixir-ecosystem categories: web framework, database, libraries, and testing."
+---
+
+Categories applied when scaffolding an Elixir project.
+
+This page covers **15 options** across **4 categories**.
+
+Use CLI values in commands and MCP payloads.
+
+
+Phoenix is optional. Use `elixirWebFramework=none` for plain Mix / OTP projects. `oban` is intended for Ecto-backed projects.
+
+
+## Elixir Web Framework
+
+Selection: **single**
+
+| Option | Label | CLI value |
+| --- | --- | --- |
+| `phoenix` | Phoenix | `phoenix` |
+| `none` | No Web Framework | `none` |
+
+## Elixir Database
+
+Selection: **single**
+
+| Option | Label | CLI value |
+| --- | --- | --- |
+| `ecto` | Ecto | `ecto` |
+| `none` | None | `none` |
+
+## Elixir Libraries
+
+BEAM-native application libraries emitted into `mix.exs`.
+
+Selection: **multiple**
+
+| Option | Label | CLI value |
+| --- | --- | --- |
+| `jason` | Jason | `jason` |
+| `req` | Req | `req` |
+| `oban` | Oban | `oban` |
+| `broadway` | Broadway | `broadway` |
+| `telemetry` | Telemetry | `telemetry` |
+| `nx` | Nx | `nx` |
+| `none` | None | `none` |
+
+## Elixir Testing
+
+Elixir test libraries and support files.
+
+Selection: **multiple**
+
+| Option | Label | CLI value |
+| --- | --- | --- |
+| `exunit` | ExUnit | `exunit` |
+| `mox` | Mox | `mox` |
+| `stream-data` | StreamData | `stream-data` |
+| `none` | None | `none` |
diff --git a/apps/web/content/docs/reference/options/index.mdx b/apps/web/content/docs/reference/options/index.mdx
index 7428cd47a..dbaf0506d 100644
--- a/apps/web/content/docs/reference/options/index.mdx
+++ b/apps/web/content/docs/reference/options/index.mdx
@@ -9,11 +9,13 @@ Use CLI values in commands and MCP payloads. Some builder labels normalize to th
| Section | Scope | Categories | Options |
| --- | --- | --- | --- |
-| [TypeScript Options](/docs/reference/options/typescript/) | TypeScript categories: frontend, backend, runtime, data, API, auth, UI, services, tooling, examples, addons, and deploy. | 52 | 325 |
+| [TypeScript Options](/docs/reference/options/typescript/) | TypeScript web categories: frontend, backend, runtime, data, API, auth, UI, services, tooling, examples, addons, and deploy. | 45 | 308 |
+| [React Native Options](/docs/reference/options/react-native/) | React Native categories: Expo frontend variant, navigation, UI, storage, testing, push, OTA, and deep linking. | 8 | 20 |
| [Rust Options](/docs/reference/options/rust/) | Rust-ecosystem categories: web framework, ORM, API, CLI, libraries, auth. | 10 | 35 |
| [Python Options](/docs/reference/options/python/) | Python-ecosystem categories: web framework, ORM, validation, AI, quality. | 8 | 27 |
| [Go Options](/docs/reference/options/go/) | Go-ecosystem categories: web framework, ORM, API, CLI, logging, auth. | 6 | 21 |
| [Java Options](/docs/reference/options/java/) | Java-ecosystem categories: web framework, build tool, ORM, auth, libraries. | 6 | 17 |
+| [Elixir Options](/docs/reference/options/elixir/) | Elixir-ecosystem categories: web framework, database, libraries, and testing. | 4 | 15 |
The tables list valid option values. The compatibility checker still decides whether a full stack combination is valid and may reject or adjust combinations before generation.
diff --git a/apps/web/content/docs/reference/options/meta.json b/apps/web/content/docs/reference/options/meta.json
index a792aecee..8126920a1 100644
--- a/apps/web/content/docs/reference/options/meta.json
+++ b/apps/web/content/docs/reference/options/meta.json
@@ -1,5 +1,5 @@
{
"title": "Options",
"defaultOpen": true,
- "pages": ["index", "typescript", "rust", "python", "go", "java"]
+ "pages": ["index", "typescript", "react-native", "rust", "python", "go", "java", "elixir"]
}
diff --git a/apps/web/content/docs/reference/options/react-native.mdx b/apps/web/content/docs/reference/options/react-native.mdx
new file mode 100644
index 000000000..87b3c1327
--- /dev/null
+++ b/apps/web/content/docs/reference/options/react-native.mdx
@@ -0,0 +1,24 @@
+---
+title: React Native Options
+description: Reference for Expo and React Native stack options.
+---
+
+React Native options are selected with `--ecosystem react-native`. They generate a runnable Expo app and do not expose TypeScript web frontend, backend, database, or web deployment categories.
+
+| Category | CLI flag | Values |
+| --- | --- | --- |
+| Native frontend | `--frontend` | `native-bare` `native-uniwind` `native-unistyles` |
+| Mobile navigation | `--mobile-navigation` | `expo-router` `react-navigation` `none` |
+| Mobile UI | `--mobile-ui` | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` |
+| Mobile storage | `--mobile-storage` | `mmkv` `none` |
+| Mobile testing | `--mobile-testing` | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` |
+| Mobile push | `--mobile-push` | `expo-notifications` `none` |
+| Mobile OTA | `--mobile-ota` | `expo-updates` `none` |
+| Mobile deep linking | `--mobile-deep-linking` | `expo-linking` `none` |
+
+## Compatibility
+
+- `native-uniwind` selects `mobile-ui uniwind`.
+- `native-unistyles` selects `mobile-ui unistyles`.
+- `tamagui` and `gluestack-ui` are intended for `native-bare` to avoid conflicting style systems.
+- `expo-notifications`, `expo-updates`, and `expo-linking` require Expo-compatible native projects.
diff --git a/apps/web/content/docs/reference/options/typescript.mdx b/apps/web/content/docs/reference/options/typescript.mdx
index 36c8fd648..82662c536 100644
--- a/apps/web/content/docs/reference/options/typescript.mdx
+++ b/apps/web/content/docs/reference/options/typescript.mdx
@@ -16,7 +16,6 @@ The tables list valid option values, not every valid combination. tRPC is React-
| Category | Selection | Values |
| --- | --- | --- |
| Web frontend | multiple | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` |
-| Native frontend | multiple | `native-bare` `native-uniwind` `native-unistyles` `none` |
| Astro integration | single | `react` `vue` `svelte` `solid` `none` |
| Backend | single | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` |
| Runtime | single | `bun` `node` `workers` `none` |
@@ -28,6 +27,8 @@ The tables list valid option values, not every valid combination. tRPC is React-
`dbSetup: upstash` is a Redis setup path. GoBetterAuth is documented on the [Go Options](/docs/reference/options/go/) page because it targets Go projects.
+React Native and Expo options live on the [React Native Options](/docs/reference/options/react-native/) page.
+
## Services And Integrations
| Category | Selection | Values |
diff --git a/apps/web/content/docs/roadmap.mdx b/apps/web/content/docs/roadmap.mdx
index de6e49349..dca6b4bcd 100644
--- a/apps/web/content/docs/roadmap.mdx
+++ b/apps/web/content/docs/roadmap.mdx
@@ -10,6 +10,7 @@ This roadmap is a curated view of the active Better Fullstack backlog. It is not
| Focus | Why it matters |
| --- | --- |
| REST / OpenAPI API option | REST remains the most common API style, and OpenAPI output makes generated projects easier to document and integrate. |
+| Mobile-first Expo scaffolds | Native apps now need production-minded navigation, UI, storage, testing, push, OTA, and deep-link setup rather than thin starter shells. |
| Deployment trust follow-ups | The public docs now cover deployment targets; provider env examples and database provisioning guides are the next trust layer. |
## Next
@@ -28,8 +29,7 @@ This roadmap is a curated view of the active Better Fullstack backlog. It is not
| --- | --- |
| Cross-ecosystem stacks research | Rust, Go, Python, or Java backends with TypeScript frontends would be powerful, but this needs architecture work first. |
| Non-monorepo / single-app mode | Simpler generated layouts are valuable for small projects, but require careful template path design. |
-| React Native and mobile expansion | Navigation, UI, push notifications, and OTA updates would make native scaffolds more complete. |
-| Elixir ecosystem | Phoenix, LiveView, and BEAM strengths would be a unique differentiator. |
+| Elixir ecosystem | Plain Mix / OTP plus Phoenix, LiveView, and BEAM strengths would be a unique differentiator. |
| .NET ecosystem | ASP.NET Core, EF Core, Dapper, and SignalR would broaden enterprise coverage. |
## How We Prioritize
diff --git a/apps/web/content/guides/index.mdx b/apps/web/content/guides/index.mdx
index f96dbc672..5839c9866 100644
--- a/apps/web/content/guides/index.mdx
+++ b/apps/web/content/guides/index.mdx
@@ -1,6 +1,6 @@
---
title: Fullstack App Starter Guides
-description: Practical Better Fullstack guides for creating TypeScript, Rust, Python, Go, and Java projects with supported frameworks, databases, auth, APIs, email, AI, and deployment options.
+description: Practical Better Fullstack guides for creating TypeScript, React Native, Rust, Python, Go, Java, and Elixir projects with supported frameworks, databases, auth, APIs, email, AI, and deployment options.
updated: 2026-05-12
category: Guides
tags:
diff --git a/apps/web/package.json b/apps/web/package.json
index da2d97beb..6d99b1171 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,6 +13,7 @@
"test": "bun test ./test/*.test.ts",
"typecheck": "tsc --noEmit",
"validate:tech-links": "bun run scripts/validate-tech-resource-links.ts",
+ "validate:tech-icons": "bun run scripts/validate-tech-icons.ts",
"perf:check": "node scripts/check-performance-budget.mjs",
"perf:baseline": "node scripts/check-performance-budget.mjs --update-baseline",
"test:e2e": "playwright test",
diff --git a/apps/web/scripts/validate-tech-icons.ts b/apps/web/scripts/validate-tech-icons.ts
new file mode 100644
index 000000000..bb61b2d3e
--- /dev/null
+++ b/apps/web/scripts/validate-tech-icons.ts
@@ -0,0 +1,162 @@
+import { ECOSYSTEMS, PRESET_CATEGORIES, TECH_OPTIONS } from "../src/lib/constant";
+import { computeSiUrl, ICON_REGISTRY, type IconConfig } from "../src/lib/tech-icons";
+
+type IconTarget = {
+ owner: string;
+ src: string;
+};
+
+const REQUEST_TIMEOUT_MS = 15_000;
+
+function addTarget(targets: Map>, owner: string, src: string) {
+ const value = src.trim();
+ if (!value) return;
+
+ const owners = targets.get(value) ?? new Set();
+ owners.add(owner);
+ targets.set(value, owners);
+}
+
+function addConfigTarget(targets: Map>, owner: string, config?: IconConfig) {
+ if (!config) return;
+
+ if (config.type === "si") {
+ addTarget(targets, owner, computeSiUrl(config.slug, config.hex, false));
+ return;
+ }
+
+ addTarget(targets, owner, config.src);
+}
+
+function addRenderedTechIconTarget(
+ targets: Map>,
+ owner: string,
+ techId: string,
+ fallbackIcon: string,
+) {
+ const config = ICON_REGISTRY[techId];
+ if (config) {
+ addConfigTarget(targets, `${owner} via ICON_REGISTRY:${techId}`, config);
+ return;
+ }
+
+ addTarget(targets, owner, fallbackIcon);
+}
+
+function getFetchErrorMessage(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
+
+async function fetchStatus(url: string): Promise {
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
+ const head = await fetch(url, {
+ method: "HEAD",
+ redirect: "follow",
+ signal,
+ });
+
+ if (head.ok) return head.status;
+ if (head.status === 403 || head.status === 405) {
+ const get = await fetch(url, {
+ method: "GET",
+ redirect: "follow",
+ signal,
+ });
+ return get.status;
+ }
+
+ return head.status;
+}
+
+function isLocalIconPath(src: string): boolean {
+ return src.startsWith("/icon/");
+}
+
+function isRemoteIconUrl(src: string): boolean {
+ try {
+ const url = new URL(src);
+ return url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+function collectIconTargets(): IconTarget[] {
+ const targets = new Map>();
+
+ for (const [category, options] of Object.entries(TECH_OPTIONS)) {
+ for (const option of options) {
+ const owner = `${category}:${option.id}`;
+ addRenderedTechIconTarget(targets, owner, option.id, option.icon);
+ }
+ }
+
+ for (const ecosystem of ECOSYSTEMS) {
+ addRenderedTechIconTarget(targets, `ECOSYSTEMS:${ecosystem.id}`, ecosystem.id, ecosystem.icon);
+ }
+
+ for (const category of PRESET_CATEGORIES) {
+ addRenderedTechIconTarget(targets, `PRESET_CATEGORIES:${category.id}`, category.icon, "");
+ }
+
+ for (const [id, config] of Object.entries(ICON_REGISTRY)) {
+ addConfigTarget(targets, `ICON_REGISTRY:${id}`, config);
+ }
+
+ return [...targets.entries()].map(([src, owners]) => ({
+ src,
+ owner: [...owners].sort().join(", "),
+ }));
+}
+
+async function run() {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+ const targets = collectIconTargets();
+
+ for (const target of targets) {
+ if (isLocalIconPath(target.src)) {
+ const path = `public${target.src}`;
+ if (!(await Bun.file(path).exists())) {
+ errors.push(`${target.owner} uses missing local icon ${target.src}`);
+ }
+ continue;
+ }
+
+ if (!isRemoteIconUrl(target.src)) {
+ warnings.push(`${target.owner} uses a non-loadable icon token "${target.src}"`);
+ continue;
+ }
+
+ try {
+ const status = await fetchStatus(target.src);
+ if (status >= 400) {
+ errors.push(`${target.owner} icon returned ${status}: ${target.src}`);
+ }
+ } catch (error) {
+ errors.push(
+ `${target.owner} icon request failed for ${target.src}: ${getFetchErrorMessage(error)}`,
+ );
+ }
+ }
+
+ console.log(`[tech-icons] validated ${targets.length} configured icon sources`);
+
+ if (warnings.length > 0) {
+ console.log(`[tech-icons] warnings (${warnings.length})`);
+ for (const warning of warnings) {
+ console.log(` - ${warning}`);
+ }
+ }
+
+ if (errors.length > 0) {
+ console.error(`[tech-icons] errors (${errors.length})`);
+ for (const error of errors) {
+ console.error(` - ${error}`);
+ }
+ process.exit(1);
+ }
+}
+
+await run();
diff --git a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx
index 7094f62bc..9f37df7ea 100644
--- a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx
+++ b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx
@@ -23,6 +23,7 @@ type BaselineControl = {
const ECOSYSTEMS: Array<{ id: Ecosystem; label: string }> = [
{ id: "typescript", label: "TypeScript" },
+ { id: "react-native", label: "React Native" },
{ id: "rust", label: "Rust" },
{ id: "python", label: "Python" },
{ id: "go", label: "Go" },
@@ -69,6 +70,18 @@ const TYPESCRIPT_CATEGORIES: SelectCategory[] = [
const ECOSYSTEM_CATEGORIES: Record = {
typescript: TYPESCRIPT_CATEGORIES,
+ "react-native": [
+ "nativeFrontend",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
+ "auth",
+ "packageManager",
+ ],
rust: [
"rustWebFramework",
"rustFrontend",
@@ -133,6 +146,14 @@ const ECOSYSTEM_CATEGORIES: Record = {
"packageManager",
"versionChannel",
],
+ elixir: [
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
+ "packageManager",
+ "versionChannel",
+ ],
};
const BASELINE_CONTROLS: Record = {
@@ -147,6 +168,13 @@ const BASELINE_CONTROLS: Record = {
{ category: "cssFramework", label: "CSS" },
{ category: "uiLibrary", label: "UI library" },
],
+ "react-native": [
+ { category: "nativeFrontend", label: "Expo app" },
+ { category: "mobileNavigation", label: "Navigation" },
+ { category: "mobileUI", label: "Mobile UI" },
+ { category: "mobileStorage", label: "Storage" },
+ { category: "mobileTesting", label: "Testing" },
+ ],
rust: [
{ category: "rustWebFramework", label: "Framework" },
{ category: "rustOrm", label: "ORM" },
@@ -176,6 +204,12 @@ const BASELINE_CONTROLS: Record = {
{ category: "javaOrm", label: "ORM" },
{ category: "javaAuth", label: "Auth" },
],
+ elixir: [
+ { category: "elixirWebFramework", label: "Framework" },
+ { category: "elixirDatabase", label: "Database" },
+ { category: "elixirLibraries", label: "Libraries" },
+ { category: "elixirTesting", label: "Testing" },
+ ],
};
const MULTI_STACK_KEYS = new Set([
@@ -190,6 +224,8 @@ const MULTI_STACK_KEYS = new Set([
"pythonAi",
"javaLibraries",
"javaTestingLibraries",
+ "elixirLibraries",
+ "elixirTesting",
]);
const categoryToStackKey = (category: SelectCategory): keyof StackState => {
diff --git a/apps/web/src/components/home/combinations-section.tsx b/apps/web/src/components/home/combinations-section.tsx
index 29074eecc..990e8ce78 100644
--- a/apps/web/src/components/home/combinations-section.tsx
+++ b/apps/web/src/components/home/combinations-section.tsx
@@ -9,7 +9,7 @@ const { totalScientific, yearsAtOneMillisecondScientific, universeLifetimesScien
const funFacts = [
`${universeLifetimesScientific.mantissa} × 10^${universeLifetimesScientific.exponent} universe lifetimes to test all combinations`,
`${combinationsMetrics.universeSandRatioScientific.mantissa} × 10^${combinationsMetrics.universeSandRatioScientific.exponent}× more combinations than grains of sand in the observable universe`,
- "Across TypeScript, Rust, Python, Go, and Java",
+ "Across TypeScript, React Native, Rust, Python, Go, Java, and Elixir",
"Each combination scaffolds a unique, production-ready app",
"YOLO mode doubles every single one of them",
];
diff --git a/apps/web/src/components/home/features-section.tsx b/apps/web/src/components/home/features-section.tsx
index 22c07c007..8c96e3a8e 100644
--- a/apps/web/src/components/home/features-section.tsx
+++ b/apps/web/src/components/home/features-section.tsx
@@ -34,12 +34,13 @@ const LAYERS: ReadonlyArray = [
"pythonWebFramework",
"goWebFramework",
"javaWebFramework",
+ "elixirWebFramework",
],
word: "BACKEND FRAMEWORKS",
},
{
type: "categories",
- categories: ["orm", "rustOrm", "pythonOrm", "goOrm", "javaOrm"],
+ categories: ["orm", "rustOrm", "pythonOrm", "goOrm", "javaOrm", "elixirDatabase"],
word: "DATABASE ORMs",
},
{
@@ -77,7 +78,7 @@ export default function FeaturesSection() {
- ✦ five ecosystems
+ ✦ six ecosystems
Everything.
- TypeScript, Rust, Python, Go, Java — one CLI scaffolds production-ready
- apps across all five. Pick your ecosystem, pick your stack.
+ TypeScript, React Native, Rust, Python, Go, Java, Elixir — one CLI scaffolds
+ production-ready apps across all seven. Pick your ecosystem, pick your stack.
@@ -291,7 +292,7 @@ function TotalBlock() {
- options across 5 ecosystems · ts · rust · go · python · java
+ options across 7 ecosystems · ts · rn · rust · go · python · java · elixir
diff --git a/apps/web/src/components/stack-builder/presets-panel.tsx b/apps/web/src/components/stack-builder/presets-panel.tsx
index a78e6ac3a..335528d32 100644
--- a/apps/web/src/components/stack-builder/presets-panel.tsx
+++ b/apps/web/src/components/stack-builder/presets-panel.tsx
@@ -38,6 +38,9 @@ const HIGHLIGHT_SCALAR_KEYS = [
"goApi",
"goCli",
"goLogging",
+ // Elixir
+ "elixirWebFramework",
+ "elixirDatabase",
] as const satisfies readonly (keyof StackState)[];
function getPresetHighlights(presetStack: Partial): string[] {
diff --git a/apps/web/src/components/stack-builder/saved-stacks-panel.tsx b/apps/web/src/components/stack-builder/saved-stacks-panel.tsx
index ab7052504..be0ae38db 100644
--- a/apps/web/src/components/stack-builder/saved-stacks-panel.tsx
+++ b/apps/web/src/components/stack-builder/saved-stacks-panel.tsx
@@ -71,6 +71,11 @@ const RELEVANT_KEYS_BY_ECOSYSTEM: Record = {
"email", "observability", "caching", "search",
"aiDocs", "git", "install", "yolo",
],
+ elixir: [
+ "ecosystem", "projectName",
+ "elixirWebFramework", "elixirDatabase", "elixirLibraries", "elixirTesting",
+ "aiDocs", "git", "install", "yolo",
+ ],
};
/** Subset of keys used for the card highlight badges. */
@@ -80,6 +85,7 @@ const HIGHLIGHT_KEYS_BY_ECOSYSTEM: Record
python: ["pythonWebFramework", "pythonOrm", "pythonAi", "pythonApi", "pythonTaskQueue"],
go: ["goWebFramework", "goOrm", "goApi", "goCli"],
java: ["javaWebFramework", "javaBuildTool", "javaOrm", "javaAuth", "javaLibraries", "javaTestingLibraries"],
+ elixir: ["elixirWebFramework", "elixirDatabase", "elixirLibraries", "elixirTesting"],
};
interface SavedStacksPanelProps {
diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx
index fa12ed9ea..73d9a2008 100644
--- a/apps/web/src/components/stack-builder/stack-builder.tsx
+++ b/apps/web/src/components/stack-builder/stack-builder.tsx
@@ -56,11 +56,7 @@ import {
CATEGORY_ORDER,
generateStackCommand,
generateStackSharingUrl,
- GO_CATEGORY_ORDER,
- JAVA_CATEGORY_ORDER,
- PYTHON_CATEGORY_ORDER,
- RUST_CATEGORY_ORDER,
- TYPESCRIPT_CATEGORY_ORDER,
+ getCategoryOrderForEcosystem,
} from "@/lib/stack-utils";
import { ICON_REGISTRY } from "@/lib/tech-icons";
import { getTechResourceLinks } from "@/lib/tech-resource-links";
@@ -540,18 +536,7 @@ const StackBuilder = () => {
// ─── Derived state ──────────────────────────────────────────────────────
const categoryOrder = useMemo(() => {
- switch (stack.ecosystem) {
- case "rust":
- return RUST_CATEGORY_ORDER;
- case "python":
- return PYTHON_CATEGORY_ORDER;
- case "go":
- return GO_CATEGORY_ORDER;
- case "java":
- return JAVA_CATEGORY_ORDER;
- default:
- return TYPESCRIPT_CATEGORY_ORDER;
- }
+ return getCategoryOrderForEcosystem(stack.ecosystem);
}, [stack.ecosystem]);
const sidebarCategories = useMemo(() => {
diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts
index 815231bbc..83d7aa091 100644
--- a/apps/web/src/lib/constant.ts
+++ b/apps/web/src/lib/constant.ts
@@ -243,6 +243,171 @@ export const TECH_OPTIONS: Record<
default: false,
},
],
+ mobileNavigation: [
+ {
+ id: "expo-router",
+ name: "Expo Router",
+ description: "File-based routing with typed routes for Expo apps",
+ icon: "https://cdn.simpleicons.org/expo",
+ color: "from-slate-500 to-zinc-700",
+ default: true,
+ className: "invert-0 dark:invert",
+ },
+ {
+ id: "react-navigation",
+ name: "React Navigation",
+ description: "Production native stacks, tabs, and linking configuration",
+ icon: "https://cdn.simpleicons.org/react/61DAFB",
+ color: "from-cyan-500 to-blue-700",
+ },
+ {
+ id: "none",
+ name: "No Mobile Navigation",
+ description: "Skip mobile navigation setup",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ },
+ ],
+ mobileUI: [
+ {
+ id: "tamagui",
+ name: "Tamagui",
+ description: "Universal themed UI primitives for React Native",
+ icon: "",
+ color: "from-emerald-400 to-teal-700",
+ },
+ {
+ id: "gluestack-ui",
+ name: "Gluestack UI",
+ description: "Accessible cross-platform component primitives",
+ icon: "",
+ color: "from-orange-400 to-red-600",
+ },
+ {
+ id: "uniwind",
+ name: "Uniwind",
+ description: "Tailwind-aligned styling for React Native",
+ icon: "https://cdn.simpleicons.org/tailwindcss/06B6D4",
+ color: "from-cyan-400 to-sky-700",
+ },
+ {
+ id: "unistyles",
+ name: "Unistyles",
+ description: "Type-safe stylesheet system for React Native",
+ icon: "",
+ color: "from-pink-400 to-rose-700",
+ },
+ {
+ id: "none",
+ name: "No Mobile UI",
+ description: "Use React Native primitives",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ ],
+ mobileStorage: [
+ {
+ id: "mmkv",
+ name: "MMKV",
+ description: "Fast local key-value storage for mobile data",
+ icon: "",
+ color: "from-yellow-400 to-amber-700",
+ },
+ {
+ id: "none",
+ name: "No Mobile Storage",
+ description: "Skip device storage helpers",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ ],
+ mobileTesting: [
+ {
+ id: "maestro",
+ name: "Maestro",
+ description: "Mobile E2E flow files for iOS and Android",
+ icon: "",
+ color: "from-violet-400 to-fuchsia-700",
+ },
+ {
+ id: "react-native-testing-library",
+ name: "RN Testing Library",
+ description: "Native component tests with Jest Expo",
+ icon: "https://cdn.simpleicons.org/testinglibrary/E33332",
+ color: "from-red-400 to-rose-700",
+ },
+ {
+ id: "maestro-react-native-testing-library",
+ name: "Maestro + RN Testing Library",
+ description: "Mobile E2E flows plus native component tests",
+ icon: "https://cdn.simpleicons.org/testinglibrary/E33332",
+ color: "from-violet-500 to-red-600",
+ },
+ {
+ id: "none",
+ name: "No Mobile Testing",
+ description: "Skip mobile testing setup",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ ],
+ mobilePush: [
+ {
+ id: "expo-notifications",
+ name: "Expo Notifications",
+ description: "Push token registration and notification handler",
+ icon: "https://cdn.simpleicons.org/expo",
+ color: "from-blue-400 to-indigo-700",
+ className: "invert-0 dark:invert",
+ },
+ {
+ id: "none",
+ name: "No Mobile Push",
+ description: "Skip push notification setup",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ ],
+ mobileOTA: [
+ {
+ id: "expo-updates",
+ name: "Expo Updates",
+ description: "Runtime version and update check helper",
+ icon: "https://cdn.simpleicons.org/expo",
+ color: "from-green-400 to-emerald-700",
+ className: "invert-0 dark:invert",
+ },
+ {
+ id: "none",
+ name: "No Mobile OTA",
+ description: "Skip OTA update setup",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ ],
+ mobileDeepLinking: [
+ {
+ id: "expo-linking",
+ name: "Expo Linking",
+ description: "Scheme config and auth redirect URI examples",
+ icon: "https://cdn.simpleicons.org/expo",
+ color: "from-sky-400 to-blue-700",
+ className: "invert-0 dark:invert",
+ default: true,
+ },
+ {
+ id: "none",
+ name: "No Deep Linking",
+ description: "Skip mobile deep link helpers",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ },
+ ],
astroIntegration: [
{
id: "react",
@@ -398,7 +563,7 @@ export const TECH_OPTIONS: Record<
id: "self-vinext",
name: "Fullstack Vinext",
description: "Use Vinext server routes",
- icon: "https://cdn.simpleicons.org/vue",
+ icon: "https://cdn.simpleicons.org/vuedotjs/4FC08D",
color: "from-green-400 to-green-600",
},
{
@@ -977,7 +1142,7 @@ export const TECH_OPTIONS: Record<
id: "launchdarkly",
name: "LaunchDarkly",
description: "Enterprise feature management with client and server SDKs",
- icon: "https://cdn.simpleicons.org/launchdarkly/405BFF",
+ icon: "",
color: "from-blue-500 to-indigo-600",
default: false,
},
@@ -985,7 +1150,7 @@ export const TECH_OPTIONS: Record<
id: "flagsmith",
name: "Flagsmith",
description: "Open-source feature flags and remote config platform",
- icon: "https://cdn.simpleicons.org/flagsmith/1A1A1A",
+ icon: "",
color: "from-emerald-500 to-cyan-600",
default: false,
},
@@ -993,7 +1158,7 @@ export const TECH_OPTIONS: Record<
id: "unleash",
name: "Unleash",
description: "Open-source feature management with Edge and proxy SDKs",
- icon: "https://cdn.simpleicons.org/unleash/1D4ED8",
+ icon: "",
color: "from-sky-500 to-violet-600",
default: false,
},
@@ -3374,7 +3539,7 @@ export const TECH_OPTIONS: Record<
id: "pyright",
name: "Pyright",
description: "Fast Python type checker from Microsoft",
- icon: "https://cdn.simpleicons.org/microsoft/5E5E5E",
+ icon: "",
color: "from-violet-500 to-blue-600",
default: false,
isNew: true,
@@ -3836,7 +4001,7 @@ export const TECH_OPTIONS: Record<
id: "mockito",
name: "Mockito",
description: "Mocking framework for isolated unit tests",
- icon: "https://cdn.simpleicons.org/mockito/78A641",
+ icon: "",
color: "from-lime-500 to-green-600",
default: false,
},
@@ -3844,7 +4009,7 @@ export const TECH_OPTIONS: Record<
id: "testcontainers",
name: "Testcontainers",
description: "Disposable Docker-based integration testing",
- icon: "https://cdn.simpleicons.org/testcontainers/2496ED",
+ icon: "",
color: "from-sky-500 to-blue-700",
default: false,
},
@@ -3905,6 +4070,135 @@ export const TECH_OPTIONS: Record<
default: false,
},
],
+ // Elixir ecosystem options
+ elixirWebFramework: [
+ {
+ id: "none",
+ name: "No Web Framework",
+ description: "Plain Mix / OTP application without Phoenix",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ {
+ id: "phoenix",
+ name: "Phoenix",
+ description: "Elixir web framework with LiveView and Channels",
+ icon: "https://cdn.simpleicons.org/phoenixframework/FD4F00",
+ color: "from-orange-500 to-purple-600",
+ default: false,
+ },
+ ],
+ elixirDatabase: [
+ {
+ id: "none",
+ name: "No Database Layer",
+ description: "Skip Elixir database setup",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: true,
+ },
+ {
+ id: "ecto",
+ name: "Ecto",
+ description: "Database wrapper and query generator for Elixir",
+ icon: "",
+ color: "from-violet-500 to-indigo-600",
+ default: false,
+ },
+ ],
+ elixirLibraries: [
+ {
+ id: "jason",
+ name: "Jason",
+ description: "Fast JSON encoder and decoder",
+ icon: "",
+ color: "from-purple-500 to-indigo-600",
+ default: true,
+ },
+ {
+ id: "req",
+ name: "Req",
+ description: "Modern HTTP client for Elixir",
+ icon: "",
+ color: "from-blue-500 to-cyan-600",
+ default: false,
+ },
+ {
+ id: "oban",
+ name: "Oban",
+ description: "PostgreSQL-backed background jobs",
+ icon: "",
+ color: "from-emerald-500 to-teal-600",
+ default: false,
+ },
+ {
+ id: "broadway",
+ name: "Broadway",
+ description: "Concurrent data ingestion and processing pipelines",
+ icon: "",
+ color: "from-rose-500 to-pink-600",
+ default: false,
+ },
+ {
+ id: "telemetry",
+ name: "Telemetry",
+ description: "Instrumentation events and metrics foundation",
+ icon: "",
+ color: "from-amber-500 to-orange-600",
+ default: false,
+ },
+ {
+ id: "nx",
+ name: "Nx",
+ description: "Numerical Elixir tensors and machine learning",
+ icon: "",
+ color: "from-sky-500 to-blue-600",
+ default: false,
+ },
+ {
+ id: "none",
+ name: "No Extra Libraries",
+ description: "Skip extra Elixir libraries",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: false,
+ },
+ ],
+ elixirTesting: [
+ {
+ id: "exunit",
+ name: "ExUnit",
+ description: "Built-in Elixir test framework",
+ icon: "",
+ color: "from-purple-500 to-indigo-600",
+ default: true,
+ },
+ {
+ id: "mox",
+ name: "Mox",
+ description: "Explicit contract-based mocks",
+ icon: "",
+ color: "from-blue-500 to-indigo-600",
+ default: false,
+ },
+ {
+ id: "stream-data",
+ name: "StreamData",
+ description: "Property-based testing for ExUnit",
+ icon: "",
+ color: "from-green-500 to-emerald-600",
+ default: false,
+ },
+ {
+ id: "none",
+ name: "No Testing Libraries",
+ description: "Skip Elixir testing setup",
+ icon: "",
+ color: "from-gray-400 to-gray-600",
+ default: false,
+ },
+ ],
};
// Ecosystem configuration
@@ -3918,10 +4212,17 @@ export const ECOSYSTEMS: {
{
id: "typescript",
name: "TypeScript",
- description: "Full-stack TypeScript ecosystem",
+ description: "Full-stack TypeScript web ecosystem",
icon: "https://cdn.simpleicons.org/typescript/3178C6",
color: "from-blue-500 to-blue-700",
},
+ {
+ id: "react-native",
+ name: "React Native",
+ description: "Expo and React Native mobile ecosystem",
+ icon: "https://cdn.simpleicons.org/react/61DAFB",
+ color: "from-cyan-500 to-blue-700",
+ },
{
id: "rust",
name: "Rust",
@@ -3950,13 +4251,19 @@ export const ECOSYSTEMS: {
icon: "/icon/java.svg",
color: "from-red-500 to-orange-600",
},
+ {
+ id: "elixir",
+ name: "Elixir",
+ description: "Mix, OTP, Phoenix, and BEAM-native apps",
+ icon: "https://cdn.simpleicons.org/elixir/4B275F",
+ color: "from-purple-500 to-fuchsia-700",
+ },
];
// Categories available for each ecosystem
export const ECOSYSTEM_CATEGORIES: Record = {
typescript: [
"webFrontend",
- "nativeFrontend",
"astroIntegration",
"cssFramework",
"uiLibrary",
@@ -3990,6 +4297,21 @@ export const ECOSYSTEM_CATEGORIES: Record = {
"git",
"install",
],
+ "react-native": [
+ "nativeFrontend",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
+ "auth",
+ "packageManager",
+ "aiDocs",
+ "git",
+ "install",
+ ],
rust: [
"rustWebFramework",
"rustFrontend",
@@ -4058,6 +4380,15 @@ export const ECOSYSTEM_CATEGORIES: Record = {
"git",
"install",
],
+ elixir: [
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
+ "aiDocs",
+ "git",
+ "install",
+ ],
};
export const PRESET_CATEGORIES = [
@@ -4070,12 +4401,13 @@ export const PRESET_CATEGORIES = [
{ id: "solid", name: "Solid", icon: "solid", ecosystem: "typescript" },
{ id: "angular", name: "Angular", icon: "angular", ecosystem: "typescript" },
{ id: "qwik", name: "Qwik", icon: "qwik", ecosystem: "typescript" },
- { id: "mobile", name: "Mobile", icon: "native-uniwind", ecosystem: "typescript" },
+ { id: "mobile", name: "Mobile", icon: "native-uniwind", ecosystem: "react-native" },
{ id: "ai-tools", name: "AI Tools", icon: "ai-cli", ecosystem: "typescript" },
{ id: "rust", name: "Rust", icon: "", ecosystem: "rust" },
{ id: "python", name: "Python", icon: "fastapi", ecosystem: "python" },
{ id: "go", name: "Go", icon: "gin", ecosystem: "go" },
{ id: "java", name: "Java", icon: "java", ecosystem: "java" },
+ { id: "elixir", name: "Elixir", icon: "elixir", ecosystem: "elixir" },
] as const;
export type PresetCategory = (typeof PRESET_CATEGORIES)[number]["id"];
@@ -4767,6 +5099,7 @@ export const PRESET_TEMPLATES: {
description: "Expo + Uniwind native app with no backend",
category: "mobile",
stack: {
+ ecosystem: "react-native",
projectName: "my-app",
webFrontend: ["none"],
nativeFrontend: ["native-uniwind"],
@@ -4802,6 +5135,7 @@ export const PRESET_TEMPLATES: {
description: "Expo with bare workflow — no backend",
category: "mobile",
stack: {
+ ecosystem: "react-native",
projectName: "my-app",
webFrontend: ["none"],
nativeFrontend: ["native-bare"],
diff --git a/apps/web/src/lib/llms.ts b/apps/web/src/lib/llms.ts
index cb3266052..626b16906 100644
--- a/apps/web/src/lib/llms.ts
+++ b/apps/web/src/lib/llms.ts
@@ -34,6 +34,7 @@ export function generateLlmsTxt({
"/docs/ai/mcp",
"/docs/ai/mcp-tools",
"/docs/ecosystems/typescript",
+ "/docs/ecosystems/react-native",
"/docs/ecosystems/rust",
"/docs/ecosystems/python",
"/docs/ecosystems/go",
diff --git a/apps/web/src/lib/project-stats.generated.ts b/apps/web/src/lib/project-stats.generated.ts
index bedcb8b06..910d0fd9f 100644
--- a/apps/web/src/lib/project-stats.generated.ts
+++ b/apps/web/src/lib/project-stats.generated.ts
@@ -6,5 +6,12 @@ export const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce
0,
);
export const CATEGORY_COUNT = Object.keys(OPTION_CATEGORY_METADATA).length;
-export const ECOSYSTEM_COUNT = 5;
-export const ECOSYSTEM_NAMES = ["TypeScript", "Rust", "Python", "Go", "Java"] as const;
+export const ECOSYSTEM_COUNT = 6;
+export const ECOSYSTEM_NAMES = [
+ "TypeScript",
+ "React Native",
+ "Rust",
+ "Python",
+ "Go",
+ "Java",
+] as const;
diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts
index 0b26d7e67..dbf5a4bec 100644
--- a/apps/web/src/lib/stack-utils.ts
+++ b/apps/web/src/lib/stack-utils.ts
@@ -10,7 +10,6 @@ import { createStackSearchParams } from "@/lib/stack-url-state.shared";
// TypeScript ecosystem category order
const TYPESCRIPT_CATEGORY_ORDER: Array = [
"webFrontend",
- "nativeFrontend",
"astroIntegration",
"cssFramework",
"uiLibrary",
@@ -62,6 +61,23 @@ const TYPESCRIPT_CATEGORY_ORDER: Array = [
"install",
];
+// React Native ecosystem category order
+const REACT_NATIVE_CATEGORY_ORDER: Array = [
+ "nativeFrontend",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
+ "auth",
+ "packageManager",
+ "aiDocs",
+ "git",
+ "install",
+];
+
// Rust ecosystem category order
const RUST_CATEGORY_ORDER: Array = [
"rustWebFramework",
@@ -138,17 +154,51 @@ const JAVA_CATEGORY_ORDER: Array = [
"install",
];
+// Elixir ecosystem category order
+const ELIXIR_CATEGORY_ORDER: Array = [
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
+ "aiDocs",
+ "git",
+ "install",
+];
+
// Combined category order for backwards compatibility
const CATEGORY_ORDER = [
...new Set([
...TYPESCRIPT_CATEGORY_ORDER,
+ ...REACT_NATIVE_CATEGORY_ORDER,
...RUST_CATEGORY_ORDER,
...PYTHON_CATEGORY_ORDER,
...GO_CATEGORY_ORDER,
...JAVA_CATEGORY_ORDER,
+ ...ELIXIR_CATEGORY_ORDER,
]),
] as Array;
+export function getCategoryOrderForEcosystem(
+ ecosystem: StackState["ecosystem"],
+): Array {
+ switch (ecosystem) {
+ case "react-native":
+ return REACT_NATIVE_CATEGORY_ORDER;
+ case "rust":
+ return RUST_CATEGORY_ORDER;
+ case "python":
+ return PYTHON_CATEGORY_ORDER;
+ case "go":
+ return GO_CATEGORY_ORDER;
+ case "java":
+ return JAVA_CATEGORY_ORDER;
+ case "elixir":
+ return ELIXIR_CATEGORY_ORDER;
+ case "typescript":
+ return TYPESCRIPT_CATEGORY_ORDER;
+ }
+}
+
export function generateStackSummary(stack: StackState) {
const selectedTechs = CATEGORY_ORDER.flatMap((category) => {
const options = TECH_OPTIONS[category];
@@ -199,8 +249,10 @@ export function generateStackSharingUrl(stack: StackState, baseUrl?: string) {
export {
CATEGORY_ORDER,
TYPESCRIPT_CATEGORY_ORDER,
+ REACT_NATIVE_CATEGORY_ORDER,
RUST_CATEGORY_ORDER,
PYTHON_CATEGORY_ORDER,
GO_CATEGORY_ORDER,
JAVA_CATEGORY_ORDER,
+ ELIXIR_CATEGORY_ORDER,
};
diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts
index cd00e2f68..7d0a2eb1f 100644
--- a/apps/web/src/lib/tech-icons.ts
+++ b/apps/web/src/lib/tech-icons.ts
@@ -57,6 +57,18 @@ export const ICON_REGISTRY: Record = {
python: { type: "si", slug: "python", hex: "3776AB" },
go: { type: "si", slug: "go", hex: "00ADD8" },
java: { type: "local", src: "/icon/java.svg" },
+ elixir: { type: "si", slug: "elixir", hex: "4B275F" },
+ phoenix: { type: "si", slug: "phoenixframework", hex: "FD4F00" },
+ ecto: { type: "si", slug: "elixir", hex: "4B275F" },
+ jason: { type: "si", slug: "elixir", hex: "4B275F" },
+ req: { type: "si", slug: "elixir", hex: "4B275F" },
+ oban: { type: "si", slug: "elixir", hex: "4B275F" },
+ broadway: { type: "si", slug: "elixir", hex: "4B275F" },
+ telemetry: { type: "si", slug: "elixir", hex: "4B275F" },
+ nx: { type: "si", slug: "elixir", hex: "4B275F" },
+ exunit: { type: "si", slug: "elixir", hex: "4B275F" },
+ mox: { type: "si", slug: "elixir", hex: "4B275F" },
+ "stream-data": { type: "si", slug: "elixir", hex: "4B275F" },
// ─── API ───────────────────────────────────────────────────────────────────
trpc: { type: "si", slug: "trpc", hex: "398CCB" },
@@ -127,7 +139,6 @@ export const ICON_REGISTRY: Record = {
kysely: { type: "local", src: "https://kysely.dev/img/logo.svg" },
mikroorm: { type: "local", src: "https://mikro-orm.io/img/logo.svg" },
sequelize: { type: "si", slug: "sequelize", hex: "52B0E7" },
- "tortoise-orm": { type: "local", src: "/icon/python.svg" },
// ─── DB Setup ──────────────────────────────────────────────────────────────
turso: { type: "si", slug: "turso", hex: "4FF8D2" },
@@ -184,11 +195,7 @@ export const ICON_REGISTRY: Record = {
opentelemetry: { type: "si", slug: "opentelemetry", hex: "000000" },
// ─── Feature Flags ─────────────────────────────────────────────────────────
- growthbook: { type: "si", slug: "growthbook", hex: "4E00DF" },
posthog: { type: "si", slug: "posthog", hex: "F54E00" },
- launchdarkly: { type: "si", slug: "launchdarkly", hex: "405BFF" },
- flagsmith: { type: "si", slug: "flagsmith", hex: "1A1A1A" },
- unleash: { type: "si", slug: "unleash", hex: "1D4ED8" },
// ─── State Management ──────────────────────────────────────────────────────
"redux-toolkit": { type: "si", slug: "redux", hex: "764ABC" },
@@ -353,7 +360,6 @@ export const ICON_REGISTRY: Record = {
ariadne: { type: "si", slug: "graphql", hex: "E10098" },
ruff: { type: "si", slug: "ruff", hex: "D7FF64" },
mypy: { type: "si", slug: "python", hex: "3776AB" },
- pyright: { type: "si", slug: "microsoft", hex: "5E5E5E" },
// ─── Go ────────────────────────────────────────────────────────────────────
gin: { type: "si", slug: "gin", hex: "00ADD8" },
@@ -391,8 +397,6 @@ export const ICON_REGISTRY: Record = {
"micrometer-prometheus": { type: "si", slug: "prometheus", hex: "E6522C" },
thymeleaf: { type: "si", slug: "thymeleaf", hex: "005F0F" },
junit5: { type: "si", slug: "junit5", hex: "25A162" },
- mockito: { type: "si", slug: "mockito", hex: "78A641" },
- testcontainers: { type: "si", slug: "testcontainers", hex: "2496ED" },
assertj: { type: "local", src: "https://assertj.github.io/doc/images/favicon.png" },
"rest-assured": { type: "local", src: "https://rest-assured.io/img/logo-transparent.png" },
wiremock: { type: "local", src: "https://wiremock.org/images/favicon.svg" },
diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts
index 702b14b60..d38c6e53c 100644
--- a/apps/web/src/lib/tech-resource-links.ts
+++ b/apps/web/src/lib/tech-resource-links.ts
@@ -120,6 +120,58 @@ const BASE_LINKS: LinkMap = {
docsUrl: "https://www.unistyl.es/v3/start/introduction",
githubUrl: "https://github.com/jpudysz/react-native-unistyles",
},
+ "expo-router": {
+ docsUrl: "https://docs.expo.dev/router/introduction/",
+ githubUrl: "https://github.com/expo/expo",
+ },
+ "react-navigation": {
+ docsUrl: "https://reactnavigation.org/docs/getting-started",
+ githubUrl: "https://github.com/react-navigation/react-navigation",
+ },
+ tamagui: {
+ docsUrl: "https://tamagui.dev/docs/intro/introduction",
+ githubUrl: "https://github.com/tamagui/tamagui",
+ },
+ "gluestack-ui": {
+ docsUrl: "https://gluestack.io/ui/docs/home/overview/introduction",
+ githubUrl: "https://github.com/gluestack/gluestack-ui",
+ },
+ uniwind: {
+ docsUrl: "https://www.uniwind.dev/",
+ githubUrl: "https://github.com/uniwind/uniwind",
+ },
+ unistyles: {
+ docsUrl: "https://www.unistyl.es/v3/start/introduction",
+ githubUrl: "https://github.com/jpudysz/react-native-unistyles",
+ },
+ mmkv: {
+ docsUrl: "https://github.com/mrousavy/react-native-mmkv",
+ githubUrl: "https://github.com/mrousavy/react-native-mmkv",
+ },
+ maestro: {
+ docsUrl: "https://docs.maestro.dev/",
+ githubUrl: "https://github.com/mobile-dev-inc/Maestro",
+ },
+ "react-native-testing-library": {
+ docsUrl: "https://callstack.github.io/react-native-testing-library/",
+ githubUrl: "https://github.com/callstack/react-native-testing-library",
+ },
+ "maestro-react-native-testing-library": {
+ docsUrl: "https://callstack.github.io/react-native-testing-library/",
+ githubUrl: "https://github.com/callstack/react-native-testing-library",
+ },
+ "expo-notifications": {
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/notifications/",
+ githubUrl: "https://github.com/expo/expo",
+ },
+ "expo-updates": {
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/updates/",
+ githubUrl: "https://github.com/expo/expo",
+ },
+ "expo-linking": {
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/linking/",
+ githubUrl: "https://github.com/expo/expo",
+ },
sqlite: {
docsUrl: "https://www.sqlite.org/docs.html",
githubUrl: "https://github.com/sqlite/sqlite",
@@ -1152,6 +1204,52 @@ const CATEGORY_LINKS: LinkMap = {
"shadcnFont:outfit": { docsUrl: "https://fonts.google.com/specimen/Outfit" },
"shadcnFont:jetbrains-mono": { docsUrl: "https://www.jetbrains.com/lp/mono/" },
"shadcnFont:geist-mono": { docsUrl: "https://vercel.com/font" },
+
+ // ─── Elixir ────────────────────────────────────────────────────────────────
+ "elixirWebFramework:phoenix": {
+ docsUrl: "https://hexdocs.pm/phoenix/overview.html",
+ githubUrl: "https://github.com/phoenixframework/phoenix",
+ },
+ "elixirDatabase:ecto": {
+ docsUrl: "https://hexdocs.pm/ecto/Ecto.html",
+ githubUrl: "https://github.com/elixir-ecto/ecto",
+ },
+ "elixirLibraries:jason": {
+ docsUrl: "https://hexdocs.pm/jason/Jason.html",
+ githubUrl: "https://github.com/michalmuskala/jason",
+ },
+ "elixirLibraries:req": {
+ docsUrl: "https://hexdocs.pm/req/Req.html",
+ githubUrl: "https://github.com/wojtekmach/req",
+ },
+ "elixirLibraries:oban": {
+ docsUrl: "https://hexdocs.pm/oban/Oban.html",
+ githubUrl: "https://github.com/sorentwo/oban",
+ },
+ "elixirLibraries:broadway": {
+ docsUrl: "https://hexdocs.pm/broadway/Broadway.html",
+ githubUrl: "https://github.com/dashbitco/broadway",
+ },
+ "elixirLibraries:telemetry": {
+ docsUrl: "https://hexdocs.pm/telemetry/",
+ githubUrl: "https://github.com/beam-telemetry/telemetry",
+ },
+ "elixirLibraries:nx": {
+ docsUrl: "https://hexdocs.pm/nx/Nx.html",
+ githubUrl: "https://github.com/elixir-nx/nx",
+ },
+ "elixirTesting:exunit": {
+ docsUrl: "https://hexdocs.pm/ex_unit/ExUnit.html",
+ githubUrl: "https://github.com/elixir-lang/elixir",
+ },
+ "elixirTesting:mox": {
+ docsUrl: "https://hexdocs.pm/mox/Mox.html",
+ githubUrl: "https://github.com/dashbitco/mox",
+ },
+ "elixirTesting:stream-data": {
+ docsUrl: "https://hexdocs.pm/stream_data/StreamData.html",
+ githubUrl: "https://github.com/whatyouhide/stream_data",
+ },
};
export function getTechResourceLinks(category: string, techId: string): TechResourceLinks {
diff --git a/apps/web/test/elixir-ecosystem.test.ts b/apps/web/test/elixir-ecosystem.test.ts
new file mode 100644
index 000000000..246fb207d
--- /dev/null
+++ b/apps/web/test/elixir-ecosystem.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "bun:test";
+
+import type { Ecosystem, TechCategory } from "../src/lib/types";
+
+import { getCategoryDisplayName, getVisibleOptions } from "../src/components/stack-builder/utils";
+import { DEFAULT_STACK, ECOSYSTEMS, ECOSYSTEM_CATEGORIES, TECH_OPTIONS } from "../src/lib/constant";
+import { ELIXIR_CATEGORY_ORDER, generateStackCommand } from "../src/lib/stack-utils";
+
+describe("Elixir Ecosystem Tab", () => {
+ it("has elixir as a valid ecosystem value", () => {
+ const ecosystems: Ecosystem[] = [
+ "typescript",
+ "react-native",
+ "rust",
+ "python",
+ "go",
+ "java",
+ "elixir",
+ ];
+
+ expect(ecosystems).toContain("elixir");
+ });
+
+ it("registers Elixir in ecosystem constants", () => {
+ const elixirEcosystem = ECOSYSTEMS.find((ecosystem) => ecosystem.id === "elixir");
+
+ expect(ECOSYSTEMS).toHaveLength(7);
+ expect(elixirEcosystem).toBeDefined();
+ expect(elixirEcosystem?.name).toBe("Elixir");
+ expect(elixirEcosystem?.description).toContain("Mix");
+ });
+
+ it("exposes Elixir-specific categories", () => {
+ const categories = ECOSYSTEM_CATEGORIES.elixir;
+
+ expect(categories).toContain("elixirWebFramework");
+ expect(categories).toContain("elixirDatabase");
+ expect(categories).toContain("elixirLibraries");
+ expect(categories).toContain("elixirTesting");
+ expect(categories).toContain("git");
+ expect(categories).toContain("install");
+ expect(ELIXIR_CATEGORY_ORDER).toEqual(categories);
+ });
+
+ it("has options for every Elixir category", () => {
+ const categories: TechCategory[] = [
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
+ ];
+
+ for (const category of categories) {
+ const options = TECH_OPTIONS[category];
+ expect(options.length).toBeGreaterThan(0);
+ expect(options.some((option) => option.id === "none")).toBe(true);
+ expect(typeof getCategoryDisplayName(category)).toBe("string");
+ }
+ });
+
+ it("defaults to plain Mix / OTP with Jason and ExUnit", () => {
+ expect(DEFAULT_STACK.elixirWebFramework).toBe("none");
+ expect(DEFAULT_STACK.elixirDatabase).toBe("none");
+ expect(DEFAULT_STACK.elixirLibraries).toEqual(["jason"]);
+ expect(DEFAULT_STACK.elixirTesting).toEqual(["exunit"]);
+ });
+
+ it("generates Elixir command flags", () => {
+ const command = generateStackCommand({
+ ...DEFAULT_STACK,
+ ecosystem: "elixir",
+ projectName: "beam-service",
+ elixirWebFramework: "phoenix",
+ elixirDatabase: "ecto",
+ elixirLibraries: ["jason", "oban"],
+ elixirTesting: ["exunit", "mox"],
+ });
+
+ expect(command).toContain("--ecosystem elixir");
+ expect(command).toContain("--elixir-web-framework phoenix");
+ expect(command).toContain("--elixir-database ecto");
+ expect(command).toContain("--elixir-libraries jason oban");
+ expect(command).toContain("--elixir-testing exunit mox");
+ });
+
+ it("keeps Elixir options visible for the Elixir ecosystem", () => {
+ const visibleOptions = getVisibleOptions(
+ { ...DEFAULT_STACK, ecosystem: "elixir" },
+ "elixirWebFramework",
+ TECH_OPTIONS.elixirWebFramework,
+ );
+
+ expect(visibleOptions.map((option) => option.id)).toEqual(["none", "phoenix"]);
+ });
+});
diff --git a/apps/web/test/go-ecosystem.test.ts b/apps/web/test/go-ecosystem.test.ts
index d3fc9efc5..e0f69a80d 100644
--- a/apps/web/test/go-ecosystem.test.ts
+++ b/apps/web/test/go-ecosystem.test.ts
@@ -21,7 +21,15 @@ import {
describe("Go Ecosystem Tab", () => {
describe("Ecosystem Type", () => {
it("should have go as a valid ecosystem value", () => {
- const ecosystems: Ecosystem[] = ["typescript", "rust", "python", "go", "java"];
+ const ecosystems: Ecosystem[] = [
+ "typescript",
+ "react-native",
+ "rust",
+ "python",
+ "go",
+ "java",
+ "elixir",
+ ];
expect(ecosystems).toContain("go");
});
});
@@ -35,8 +43,8 @@ describe("Go Ecosystem Tab", () => {
expect(goEcosystem?.description).toBe("High-performance Go ecosystem");
});
- it("should have exactly 5 ecosystems", () => {
- expect(ECOSYSTEMS).toHaveLength(5);
+ it("should have exactly 7 ecosystems", () => {
+ expect(ECOSYSTEMS).toHaveLength(7);
});
});
diff --git a/apps/web/test/java-ecosystem.test.ts b/apps/web/test/java-ecosystem.test.ts
index b00528641..0cf27f80f 100644
--- a/apps/web/test/java-ecosystem.test.ts
+++ b/apps/web/test/java-ecosystem.test.ts
@@ -24,7 +24,15 @@ import { CATEGORY_ORDER, JAVA_CATEGORY_ORDER, generateStackCommand } from "../sr
describe("Java Ecosystem Tab", () => {
describe("Ecosystem Type", () => {
it("should have java as a valid ecosystem value", () => {
- const ecosystems: Ecosystem[] = ["typescript", "rust", "python", "go", "java"];
+ const ecosystems: Ecosystem[] = [
+ "typescript",
+ "react-native",
+ "rust",
+ "python",
+ "go",
+ "java",
+ "elixir",
+ ];
expect(ecosystems).toContain("java");
});
});
@@ -37,8 +45,8 @@ describe("Java Ecosystem Tab", () => {
expect(javaEcosystem?.description).toBe("Modern Java ecosystem");
});
- it("should have exactly 5 ecosystems", () => {
- expect(ECOSYSTEMS).toHaveLength(5);
+ it("should have exactly 7 ecosystems", () => {
+ expect(ECOSYSTEMS).toHaveLength(7);
});
it("should use the Java language icon for Java presets", () => {
diff --git a/apps/web/test/python-ecosystem.test.ts b/apps/web/test/python-ecosystem.test.ts
index 543ac3a0f..9e3e22521 100644
--- a/apps/web/test/python-ecosystem.test.ts
+++ b/apps/web/test/python-ecosystem.test.ts
@@ -49,8 +49,8 @@ describe("Python Ecosystem Tab", () => {
expect(pythonEcosystem?.description).toBe("Python full-stack ecosystem");
});
- it("should have exactly 5 ecosystems", () => {
- expect(ECOSYSTEMS).toHaveLength(5);
+ it("should have exactly 7 ecosystems", () => {
+ expect(ECOSYSTEMS).toHaveLength(7);
});
});
diff --git a/apps/web/test/rust-ecosystem.test.ts b/apps/web/test/rust-ecosystem.test.ts
index 872e185b5..f6229b35f 100644
--- a/apps/web/test/rust-ecosystem.test.ts
+++ b/apps/web/test/rust-ecosystem.test.ts
@@ -43,8 +43,8 @@ describe("Rust Ecosystem Tab", () => {
expect(rustEcosystem?.description).toBe("High-performance Rust ecosystem");
});
- it("should have exactly 5 ecosystems", () => {
- expect(ECOSYSTEMS).toHaveLength(5);
+ it("should have exactly 7 ecosystems", () => {
+ expect(ECOSYSTEMS).toHaveLength(7);
});
});
diff --git a/apps/web/test/stack-command-parity.test.ts b/apps/web/test/stack-command-parity.test.ts
index ebf350d9c..ef42a3e9d 100644
--- a/apps/web/test/stack-command-parity.test.ts
+++ b/apps/web/test/stack-command-parity.test.ts
@@ -26,6 +26,22 @@ describe("generateStackCommand parity", () => {
expect(command).toContain("--feature-flags launchdarkly");
});
+ it("does not treat core TypeScript stack selections as React Native-only defaults", () => {
+ const backendCommand = generateStackCommand({
+ ...DEFAULT_STACK,
+ backend: "fastify",
+ });
+ const frontendCommand = generateStackCommand({
+ ...DEFAULT_STACK,
+ webFrontend: ["next"],
+ });
+
+ expect(backendCommand).not.toContain("--yes");
+ expect(backendCommand).toContain("--backend fastify");
+ expect(frontendCommand).not.toContain("--yes");
+ expect(frontendCommand).toContain("--frontend next");
+ });
+
it("maps builder-only aliases to CLI flags", () => {
const command = generateStackCommand({
...DEFAULT_STACK,
@@ -39,14 +55,19 @@ describe("generateStackCommand parity", () => {
expect(command).toContain("--ai langgraph");
});
- it("serializes merged frontend selections into a single --frontend flag", () => {
+ it("serializes React Native frontend selections through the mobile ecosystem", () => {
const command = generateStackCommand({
...DEFAULT_STACK,
- webFrontend: ["next"],
+ ecosystem: "react-native",
+ webFrontend: ["none"],
nativeFrontend: ["native-bare"],
+ mobileNavigation: "expo-router",
});
- expect(command).toContain("--frontend next native-bare");
+ expect(command).toContain("--ecosystem react-native");
+ expect(command).toContain("--frontend native-bare");
+ expect(command).toContain("--mobile-navigation expo-router");
+ expect(command).not.toContain("--backend");
});
it("serializes addons from codeQuality, documentation, and appPlatforms", () => {
@@ -127,7 +148,7 @@ describe("generateStackCommand parity", () => {
expect(betaCommand).toContain("--version-channel beta");
});
- it("serializes python and rust multi-select arrays for their ecosystem commands", () => {
+ it("serializes python, rust, and elixir multi-select arrays for their ecosystem commands", () => {
const pythonCommand = generateStackCommand({
...DEFAULT_STACK,
ecosystem: "python",
@@ -138,13 +159,21 @@ describe("generateStackCommand parity", () => {
ecosystem: "rust",
rustLibraries: ["validator", "mockall"],
});
+ const elixirCommand = generateStackCommand({
+ ...DEFAULT_STACK,
+ ecosystem: "elixir",
+ elixirLibraries: ["jason", "oban"],
+ elixirTesting: ["exunit", "mox"],
+ });
expect(pythonCommand).toContain("--python-ai langchain openai-sdk");
expect(rustCommand).toContain("--rust-libraries validator mockall");
expect(rustCommand).toContain("--rust-auth none");
+ expect(elixirCommand).toContain("--elixir-libraries jason oban");
+ expect(elixirCommand).toContain("--elixir-testing exunit mox");
});
- it("serializes empty python and rust multi-select arrays as none", () => {
+ it("serializes empty python, rust, and elixir multi-select arrays as none", () => {
const pythonCommand = generateStackCommand({
...DEFAULT_STACK,
ecosystem: "python",
@@ -155,9 +184,17 @@ describe("generateStackCommand parity", () => {
ecosystem: "rust",
rustLibraries: [],
});
+ const elixirCommand = generateStackCommand({
+ ...DEFAULT_STACK,
+ ecosystem: "elixir",
+ elixirLibraries: [],
+ elixirTesting: [],
+ });
expect(pythonCommand).toContain("--python-ai none");
expect(rustCommand).toContain("--rust-libraries none");
+ expect(elixirCommand).toContain("--elixir-libraries none");
+ expect(elixirCommand).toContain("--elixir-testing none");
});
it("serializes empty Go aiDocs arrays as none", () => {
diff --git a/apps/web/test/stack-state-contract.test.ts b/apps/web/test/stack-state-contract.test.ts
index 8885e96f6..6d31e405e 100644
--- a/apps/web/test/stack-state-contract.test.ts
+++ b/apps/web/test/stack-state-contract.test.ts
@@ -3,7 +3,13 @@ import { describe, expect, it } from "bun:test";
import { DEFAULT_STACK } from "../src/lib/stack-defaults";
import { NON_OPTION_STACK_KEYS, STACK_STATE_OPTION_CATEGORY_BY_KEY } from "../src/lib/stack-contract";
+import { ECOSYSTEM_CATEGORIES } from "../src/lib/constant";
import { normalizeStackStateSelections } from "../src/lib/stack-option-normalization";
+import {
+ getCategoryOrderForEcosystem,
+ REACT_NATIVE_CATEGORY_ORDER,
+ TYPESCRIPT_CATEGORY_ORDER,
+} from "../src/lib/stack-utils";
import { stackUrlKeys } from "../src/lib/stack-url-keys";
import {
createStackSearchParams,
@@ -115,4 +121,15 @@ describe("StackState contract", () => {
expect(normalized.pythonAi).toEqual([]);
expect(normalized.aiDocs).toEqual([]);
});
+
+ it("uses React Native categories when the React Native ecosystem is selected", () => {
+ expect(getCategoryOrderForEcosystem("react-native")).toBe(REACT_NATIVE_CATEGORY_ORDER);
+ expect(getCategoryOrderForEcosystem("react-native")).not.toBe(TYPESCRIPT_CATEGORY_ORDER);
+ expect(getCategoryOrderForEcosystem("react-native")).toContain("nativeFrontend");
+ expect(getCategoryOrderForEcosystem("react-native")).toContain("mobileNavigation");
+ expect(getCategoryOrderForEcosystem("react-native")).not.toContain("webFrontend");
+ expect(getCategoryOrderForEcosystem("react-native")).toEqual(
+ expect.arrayContaining(ECOSYSTEM_CATEGORIES["react-native"]),
+ );
+ });
});
diff --git a/docs/plans/planned/elixir-ecosystem.md b/docs/plans/planned/elixir-ecosystem.md
index 2c9828def..02918cb27 100644
--- a/docs/plans/planned/elixir-ecosystem.md
+++ b/docs/plans/planned/elixir-ecosystem.md
@@ -1,22 +1,29 @@
-# Elixir / Phoenix Ecosystem Expansion
+# Elixir Ecosystem Expansion
-Strong differentiator — no competing scaffolding tool covers Elixir well. Phoenix LiveView is unique: server-rendered reactive UI without JavaScript. The BEAM VM provides unmatched fault tolerance and hot code reloading.
+Strong differentiator — no competing scaffolding tool covers Elixir well. Phoenix LiveView is unique, but the ecosystem should not be Phoenix-only. Plain Mix projects, OTP applications, libraries, workers, CLI tools, and BEAM-native services are all valuable scaffold targets.
---
## Web Framework
-- [ ] Add `phoenix` — dominant Elixir web framework. No real competitors in the ecosystem.
+- [ ] Add `none` — plain Mix / OTP application without Phoenix.
+ - `mix new`-style application structure
+ - Supervision tree and application callback when useful
+ - Optional CLI entrypoint via `escript`
+ - Suitable for libraries, workers, services, Broadway pipelines, Nerves-adjacent foundations, and non-HTTP BEAM apps
+- [ ] Add `phoenix` — dominant Elixir web framework.
- LiveView for reactive server-rendered UI
- Channels for WebSocket-based realtime
- Built-in generators (`mix phx.gen.auth`, `mix phx.gen.live`, etc.)
- HEEx templates (HTML + Elixir expressions)
+Phoenix should be the rich web default, not the only valid Elixir target. The builder must allow "No Web Framework" for Elixir once the ecosystem ships.
+
---
## Database / ORM
-- [ ] Add `ecto` — built-in ORM, tightly integrated with Phoenix. Changesets for validation, migrations, multi-repo support.
+- [ ] Add `ecto` — database layer for Phoenix and non-Phoenix Mix apps. Changesets for validation, migrations, multi-repo support.
- Supports PostgreSQL, MySQL, SQLite, MSSQL via adapters
- Query composition via `Ecto.Query`
- Schema-less queries for flexibility
@@ -25,10 +32,11 @@ Strong differentiator — no competing scaffolding tool covers Elixir well. Phoe
## Realtime
-Built-in — Phoenix Channels and LiveView handle this natively:
+Phoenix has built-in realtime primitives, while non-Phoenix apps can use BEAM/OTP primitives directly:
- **Phoenix Channels** — WebSocket-based pub/sub, presence tracking
- **LiveView** — server-rendered reactive components, no JS framework needed
- **LiveView Streams** — efficient list rendering for large datasets
+- **GenServer / Registry / PubSub** — process-based coordination for non-web applications
---
@@ -38,6 +46,8 @@ Built-in — Phoenix Channels and LiveView handle this natively:
- [ ] Add `ueberauth` — OAuth/social login strategies. Pluggable architecture (GitHub, Google, Twitter, etc.).
- [ ] Add `guardian` — JWT-based auth. Token generation, refresh, revocation.
+`phx_gen_auth` must be Phoenix-gated. `guardian` and some `ueberauth` flows can apply to non-Phoenix HTTP stacks later, but should start disabled for `elixirWebFramework: "none"` until templates exist.
+
---
## Task Queues
@@ -51,6 +61,7 @@ Built-in — Phoenix Channels and LiveView handle this natively:
- [ ] Add `absinthe` — GraphQL toolkit for Elixir. Schema-first, subscriptions, dataloader for N+1 prevention.
- [ ] Add `grpc` (via `grpc-elixir`) — Protocol Buffers-based RPC.
- [ ] REST is default via Phoenix controllers/routers.
+- [ ] Add `bandit` / `plug` consideration — lightweight HTTP without Phoenix if the ecosystem later needs a middle ground between `none` and Phoenix.
---
@@ -90,12 +101,15 @@ Built-in — Phoenix Channels and LiveView handle this natively:
## Implementation Notes
- New schema value in `EcosystemSchema`: `"elixir"`
+- New schema value set for `ElixirWebFrameworkSchema`: `["phoenix", "none"]`
- Template directory: `packages/template-generator/templates/elixir-base/`
-- Project structure: `lib/`, `config/`, `priv/`, `test/`
-- Mix project with umbrella app support (monorepo equivalent)
+- Base project structure: `mix.exs`, `lib/`, `config/`, `test/`
+- Phoenix project structure: add `assets/`, `priv/`, web modules, endpoint/router/live directories, and Phoenix-specific config only when `elixirWebFramework === "phoenix"`
+- Mix project with optional umbrella app support (monorepo equivalent)
- Build system: Mix (built-in, no choice needed)
- Package manager: Hex
- Elixir 1.17+ / OTP 27+ as default
+- Compatibility must not disable `elixirWebFramework: "none"` just because Phoenix templates exist first.
### Challenges
- Phoenix has its own project structure conventions (different from all other ecosystems)
@@ -103,15 +117,17 @@ Built-in — Phoenix Channels and LiveView handle this natively:
- LiveView is unique — no equivalent concept in other ecosystems
- BEAM deployment (releases) has specific requirements (runtime config, clustering)
- Umbrella apps are Elixir's monorepo pattern — different from Turborepo/Nx
+- Non-Phoenix scaffolds must still be useful, not empty shells. Include a small supervised worker, tests, README commands, and release-friendly config.
---
## Priority Order
-1. **Phoenix** + Ecto + PostgreSQL — core stack
-2. **phx_gen_auth** — auth is table stakes
-3. **Oban** — background jobs
-4. **Absinthe** — GraphQL (unique strength in Elixir)
-5. **LiveView scaffolding** — the killer feature
-6. **Fly.io deploy** — best Elixir hosting
-7. Remaining categories
+1. **Plain Mix / OTP (`none`)** — proves Elixir is broader than Phoenix and gives the builder a valid "No Web Framework" path
+2. **Phoenix** + Ecto + PostgreSQL — core web stack
+3. **Oban** — useful in both Phoenix and non-Phoenix apps
+4. **phx_gen_auth** — Phoenix-gated auth
+5. **Absinthe** — GraphQL (unique strength in Elixir)
+6. **LiveView scaffolding** — the killer feature
+7. **Fly.io deploy** — best Elixir hosting
+8. Remaining categories
diff --git a/docs/plans/planned/new-ecosystems.md b/docs/plans/planned/new-ecosystems.md
index 1d56e212b..4b3cc6e54 100644
--- a/docs/plans/planned/new-ecosystems.md
+++ b/docs/plans/planned/new-ecosystems.md
@@ -10,7 +10,7 @@ Candidates for entirely new language ecosystem support beyond TypeScript, Rust,
|------|----------|--------|----------|
| [../completed/java-ecosystem-foundation-2026-04-29.md](../completed/java-ecosystem-foundation-2026-04-29.md) | Java foundation | Done | Shipped in v1.6.2 |
| [java-ecosystem-follow-ups.md](java-ecosystem-follow-ups.md) | Java expansion | Medium/Large | Follow-up — Micronaut, jOOQ, Keycloak, messaging, observability |
-| [elixir-ecosystem.md](elixir-ecosystem.md) | Elixir (Phoenix, LiveView, Ecto) | Large | 1 — unique differentiator, no competing scaffolding tools |
+| [elixir-ecosystem.md](elixir-ecosystem.md) | Elixir (plain Mix / OTP, Phoenix, LiveView, Ecto) | Large | 1 — unique differentiator, no competing scaffolding tools |
| [dotnet-ecosystem.md](dotnet-ecosystem.md) | C# (ASP.NET Core, EF Core, SignalR) | Large | 2 — enterprise demand |
---
@@ -34,7 +34,7 @@ Candidates for entirely new language ecosystem support beyond TypeScript, Rust,
## Priority Order
-1. **Elixir** — unique strengths (LiveView, BEAM), strong differentiator vs competitors
+1. **Elixir** — unique strengths (Mix / OTP, LiveView, BEAM), strong differentiator vs competitors
2. **C# / ASP.NET** — enterprise demand, high-performance
3. **Zig** — watch and wait
4. **Kotlin** — consider as a Java ecosystem extension rather than a separate ecosystem
diff --git a/packages/template-generator/src/generator.ts b/packages/template-generator/src/generator.ts
index 9f383d8f2..01b7eaa5c 100644
--- a/packages/template-generator/src/generator.ts
+++ b/packages/template-generator/src/generator.ts
@@ -18,6 +18,7 @@ import {
processPythonBaseTemplate,
processGoBaseTemplate,
processJavaBaseTemplate,
+ processElixirBaseTemplate,
processFrontendTemplates,
processBackendTemplates,
processDbTemplates,
@@ -71,8 +72,11 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise
} else if (config.ecosystem === "java") {
// Java ecosystem - use Maven project structure
await processJavaBaseTemplate(vfs, templates, config);
+ } else if (config.ecosystem === "elixir") {
+ // Elixir ecosystem - use Mix project structure with optional Phoenix modules
+ await processElixirBaseTemplate(vfs, templates, config);
} else {
- // TypeScript ecosystem - use package.json and TypeScript project structure
+ // TypeScript and React Native ecosystems use package.json and TS project structure.
await processBaseTemplate(vfs, templates, config);
await processFrontendTemplates(vfs, templates, config);
await processBackendTemplates(vfs, templates, config);
@@ -107,7 +111,7 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise
processCatalogs(vfs, config);
}
- if (config.ecosystem !== "typescript") {
+ if (config.ecosystem !== "typescript" && config.ecosystem !== "react-native") {
await processAddonTemplates(vfs, templates, config);
processEnvVariables(vfs, config);
}
diff --git a/packages/template-generator/src/processors/ai-docs-generator.ts b/packages/template-generator/src/processors/ai-docs-generator.ts
index e38c9b6f7..3b84ed1fa 100644
--- a/packages/template-generator/src/processors/ai-docs-generator.ts
+++ b/packages/template-generator/src/processors/ai-docs-generator.ts
@@ -230,6 +230,18 @@ function generateTechStackSection(config: ProjectConfig): string {
if (testingLibraries.length > 0) lines.push(`- Testing: ${testingLibraries.join(", ")}`);
}
+ if (config.ecosystem === "elixir") {
+ const libraries = (config.elixirLibraries || []).filter((library) => library !== "none");
+ const testingLibraries = (config.elixirTesting || []).filter((library) => library !== "none");
+ lines.push(`- Elixir Version: 1.17+`);
+ lines.push(
+ `- Scaffold: ${config.elixirWebFramework === "phoenix" ? "phoenix" : "plain-mix-otp"}`,
+ );
+ if (config.elixirDatabase !== "none") lines.push(`- Database: ${config.elixirDatabase}`);
+ if (libraries.length > 0) lines.push(`- Libraries: ${libraries.join(", ")}`);
+ if (testingLibraries.length > 0) lines.push(`- Testing: ${testingLibraries.join(", ")}`);
+ }
+
return lines.join("\n");
}
@@ -339,6 +351,19 @@ function generateStructureSection(config: ProjectConfig): string {
lines.push("├── src/test/java/ # Test suite");
}
lines.push("```");
+ } else if (config.ecosystem === "elixir") {
+ lines.push("```");
+ lines.push(`${config.projectName}/`);
+ lines.push("├── mix.exs # Mix project and dependencies");
+ lines.push("├── config/ # Environment configuration");
+ lines.push("├── lib/ # OTP application source");
+ if (config.elixirWebFramework === "phoenix") {
+ lines.push("│ └── */web/ # Phoenix endpoint, router, and controllers");
+ }
+ if ((config.elixirTesting || []).some((library) => library !== "none")) {
+ lines.push("├── test/ # ExUnit test suite");
+ }
+ lines.push("```");
}
return lines.join("\n");
@@ -420,6 +445,16 @@ function generateCommandsSection(config: ProjectConfig): string {
lines.push(`- \`javac -d out ${getJavaMainSourcePath(config)}\` - Compile the application`);
lines.push(`- \`java -cp out ${getJavaMainClass(config)}\` - Run the application`);
}
+ } else if (config.ecosystem === "elixir") {
+ lines.push(`- \`mix deps.get\` - Install dependencies`);
+ lines.push(
+ config.elixirWebFramework === "phoenix"
+ ? `- \`mix phx.server\` - Start Phoenix server`
+ : `- \`iex -S mix\` - Start the OTP application in IEx`,
+ );
+ if ((config.elixirTesting || []).includes("exunit")) {
+ lines.push(`- \`mix test\` - Run tests`);
+ }
}
return lines.join("\n");
@@ -509,6 +544,14 @@ function generateCursorRules(config: ProjectConfig): string {
} else {
rules.push(`Use javac/java directly for compile and run steps.`);
}
+ } else if (config.ecosystem === "elixir") {
+ rules.push(`You are working on an Elixir project.`);
+ rules.push(
+ `Scaffold: ${config.elixirWebFramework === "phoenix" ? "phoenix" : "plain-mix-otp"}`,
+ );
+ if (config.elixirDatabase !== "none") rules.push(`Database: ${config.elixirDatabase}`);
+ const libraries = (config.elixirLibraries || []).filter((library) => library !== "none");
+ if (libraries.length > 0) rules.push(`Libraries: ${libraries.join(", ")}`);
}
rules.push(``);
diff --git a/packages/template-generator/src/processors/env-vars.ts b/packages/template-generator/src/processors/env-vars.ts
index 2b58f4b7c..328dca5b2 100644
--- a/packages/template-generator/src/processors/env-vars.ts
+++ b/packages/template-generator/src/processors/env-vars.ts
@@ -512,6 +512,8 @@ function buildNativeVars(
frontend: string[],
backend: ProjectConfig["backend"],
auth: ProjectConfig["auth"],
+ mobilePush: ProjectConfig["mobilePush"],
+ mobileDeepLinking: ProjectConfig["mobileDeepLinking"],
): EnvVariable[] {
let envVarName = "EXPO_PUBLIC_SERVER_URL";
let serverUrl = "http://localhost:3000";
@@ -550,6 +552,24 @@ function buildNativeVars(
});
}
+ if (auth !== "none" && mobileDeepLinking === "expo-linking") {
+ vars.push({
+ key: "EXPO_PUBLIC_AUTH_REDIRECT_PATH",
+ value: "auth/callback",
+ condition: true,
+ comment: "Mobile auth callback path used with expo-linking",
+ });
+ }
+
+ if (mobilePush === "expo-notifications") {
+ vars.push({
+ key: "EXPO_PUBLIC_EAS_PROJECT_ID",
+ value: "your-eas-project-id",
+ condition: true,
+ comment: "EAS project ID for Expo push notification tokens",
+ });
+ }
+
return vars;
}
@@ -1672,8 +1692,15 @@ export function processEnvVariables(vfs: VirtualFileSystem, config: ProjectConfi
const nativeDir = "apps/native";
if (vfs.directoryExists(nativeDir)) {
const envPath = `${nativeDir}/.env`;
- const nativeVars = buildNativeVars(frontend, backend, auth);
+ const nativeVars = buildNativeVars(
+ frontend,
+ backend,
+ auth,
+ config.mobilePush,
+ config.mobileDeepLinking,
+ );
writeEnvFile(vfs, envPath, nativeVars);
+ writeEnvFile(vfs, `${nativeDir}/.env.example`, nativeVars);
}
}
diff --git a/packages/template-generator/src/processors/readme-generator.ts b/packages/template-generator/src/processors/readme-generator.ts
index bdeb63087..a84cd402a 100644
--- a/packages/template-generator/src/processors/readme-generator.ts
+++ b/packages/template-generator/src/processors/readme-generator.ts
@@ -74,12 +74,69 @@ export function processReadme(vfs: VirtualFileSystem, config: ProjectConfig): vo
content = generateGoReadmeContent(config);
} else if (config.ecosystem === "java") {
content = generateJavaReadmeContent(config);
+ } else if (config.ecosystem === "elixir") {
+ content = generateElixirReadmeContent(config);
} else {
content = generateReadmeContent(config);
}
vfs.writeFile("README.md", content);
}
+function generateElixirReadmeContent(config: ProjectConfig): string {
+ const libraries = (config.elixirLibraries || []).filter((library) => library !== "none");
+ const testing = (config.elixirTesting || []).filter((library) => library !== "none");
+ const isPhoenix = config.elixirWebFramework === "phoenix";
+ const runCommand = isPhoenix ? "mix phx.server" : "iex -S mix";
+ const features = [
+ "Elixir 1.17+ / OTP 27+",
+ isPhoenix ? "Phoenix web framework" : "Plain Mix / OTP application",
+ config.elixirDatabase === "ecto" ? "Ecto database layer" : null,
+ libraries.length > 0 ? `Libraries: ${libraries.join(", ")}` : null,
+ testing.length > 0 ? `Testing: ${testing.join(", ")}` : null,
+ ].filter(Boolean) as string[];
+
+ const testBlock = testing.includes("exunit")
+ ? `Run the test suite:
+
+\`\`\`bash
+mix test
+\`\`\`
+
+`
+ : "";
+
+ return `# ${config.projectName}
+
+This project was created with [Better Fullstack](https://github.com/Marve10s/Better-Fullstack) for the Elixir ecosystem.
+
+## Stack
+
+${features.map((feature) => `- ${feature}`).join("\n")}
+
+## Getting Started
+
+Make sure Elixir 1.17 or newer and OTP 27 or newer are installed.
+
+Install dependencies:
+
+\`\`\`bash
+mix deps.get
+\`\`\`
+
+${testBlock}Run the application:
+
+\`\`\`bash
+${runCommand}
+\`\`\`
+
+${
+ isPhoenix
+ ? "The Phoenix health endpoint is available at `http://localhost:4000/api/health`.\n"
+ : "The generated OTP app starts a supervised worker and can be explored from `iex -S mix`.\n"
+}
+`;
+}
+
function sanitizeJavaPackageSuffix(projectName: string): string {
const alphanumericOnly = projectName
.trim()
diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts
new file mode 100644
index 000000000..7b88e663b
--- /dev/null
+++ b/packages/template-generator/src/template-handlers/elixir-base.ts
@@ -0,0 +1,151 @@
+import type { ProjectConfig } from "@better-fullstack/types";
+
+import type { VirtualFileSystem } from "../core/virtual-fs";
+import type { TemplateData } from "./utils";
+
+import { isBinaryFile, processTemplateString, transformFilename } from "../core/template-processor";
+
+type ElixirTemplateContext = ProjectConfig & {
+ elixirAppName: string;
+ elixirModuleName: string;
+ elixirOtpApp: string;
+ isElixirPhoenix: boolean;
+ isElixirPlainOtp: boolean;
+ hasElixirEcto: boolean;
+ hasElixirJason: boolean;
+ hasElixirReq: boolean;
+ hasElixirOban: boolean;
+ hasElixirBroadway: boolean;
+ hasElixirTelemetry: boolean;
+ hasElixirNx: boolean;
+ hasElixirExUnit: boolean;
+ hasElixirMox: boolean;
+ hasElixirStreamData: boolean;
+};
+
+const ELIXIR_RESERVED_WORDS = new Set([
+ "after",
+ "and",
+ "catch",
+ "do",
+ "else",
+ "end",
+ "fn",
+ "in",
+ "not",
+ "or",
+ "rescue",
+ "true",
+ "false",
+ "nil",
+ "when",
+]);
+
+function sanitizeElixirAppName(projectName: string): string {
+ let sanitized = "";
+ let pendingSeparator = false;
+
+ for (const char of projectName.trim().toLowerCase()) {
+ const code = char.charCodeAt(0);
+ const isLowercaseLetter = code >= 97 && code <= 122;
+ const isDigit = code >= 48 && code <= 57;
+ if (isLowercaseLetter || isDigit) {
+ if (pendingSeparator && sanitized.length > 0) {
+ sanitized += "_";
+ }
+ sanitized += char;
+ pendingSeparator = false;
+ continue;
+ }
+
+ if (sanitized.length > 0) {
+ pendingSeparator = true;
+ }
+ }
+
+ if (sanitized.length === 0) return "app";
+
+ const firstCode = sanitized.charCodeAt(0);
+ const startsWithLetter = firstCode >= 97 && firstCode <= 122;
+ const withPrefix = startsWithLetter ? sanitized : `app_${sanitized}`;
+ const guarded = ELIXIR_RESERVED_WORDS.has(withPrefix) ? `app_${withPrefix}` : withPrefix;
+ return guarded;
+}
+
+function toElixirModuleName(appName: string): string {
+ return appName
+ .split("_")
+ .filter(Boolean)
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
+ .join("");
+}
+
+function createElixirTemplateContext(config: ProjectConfig): ElixirTemplateContext {
+ const elixirAppName = sanitizeElixirAppName(config.projectName);
+ const libraries = new Set((config.elixirLibraries || []).filter((library) => library !== "none"));
+ const testing = new Set((config.elixirTesting || []).filter((library) => library !== "none"));
+
+ return {
+ ...config,
+ elixirAppName,
+ elixirModuleName: toElixirModuleName(elixirAppName),
+ elixirOtpApp: elixirAppName,
+ isElixirPhoenix: config.elixirWebFramework === "phoenix",
+ isElixirPlainOtp: config.elixirWebFramework !== "phoenix",
+ hasElixirEcto: config.elixirDatabase === "ecto",
+ hasElixirJason: libraries.has("jason"),
+ hasElixirReq: libraries.has("req"),
+ hasElixirOban: libraries.has("oban"),
+ hasElixirBroadway: libraries.has("broadway"),
+ hasElixirTelemetry: libraries.has("telemetry"),
+ hasElixirNx: libraries.has("nx"),
+ hasElixirExUnit: testing.has("exunit"),
+ hasElixirMox: testing.has("mox"),
+ hasElixirStreamData: testing.has("stream-data"),
+ };
+}
+
+function shouldSkipElixirTemplate(templatePath: string, context: ElixirTemplateContext): boolean {
+ if (!context.isElixirPhoenix && templatePath.includes("/web/")) return true;
+ if (!context.hasElixirEcto && templatePath.includes("/repo.ex.hbs")) return true;
+ if (!context.hasElixirExUnit && templatePath.includes("/test/")) return true;
+ if (!context.hasElixirMox && templatePath.endsWith("/test/support/mox.ex.hbs")) return true;
+ return false;
+}
+
+function transformElixirOutputPath(relativePath: string, context: ElixirTemplateContext): string {
+ return transformFilename(relativePath.replace(/__elixir_app_name__/g, context.elixirAppName));
+}
+
+export async function processElixirBaseTemplate(
+ vfs: VirtualFileSystem,
+ templates: TemplateData,
+ config: ProjectConfig,
+): Promise {
+ if (config.ecosystem !== "elixir") return;
+
+ const prefix = "elixir-base/";
+ const context = createElixirTemplateContext(config);
+
+ for (const [templatePath, content] of templates) {
+ if (!templatePath.startsWith(prefix)) continue;
+ if (shouldSkipElixirTemplate(templatePath, context)) continue;
+
+ const relativePath = templatePath.slice(prefix.length);
+ const outputPath = transformElixirOutputPath(relativePath, context);
+
+ let processedContent: string;
+ if (isBinaryFile(templatePath)) {
+ processedContent = "[Binary file]";
+ } else if (templatePath.endsWith(".hbs")) {
+ processedContent = processTemplateString(content, context as ProjectConfig);
+ } else {
+ processedContent = content;
+ }
+
+ if (!isBinaryFile(templatePath) && processedContent.trim() === "") continue;
+
+ const sourcePath = isBinaryFile(templatePath) ? templatePath : undefined;
+ vfs.writeFile(outputPath, processedContent, sourcePath);
+ }
+}
diff --git a/packages/template-generator/src/template-handlers/frontend.ts b/packages/template-generator/src/template-handlers/frontend.ts
index db8b68678..271432393 100644
--- a/packages/template-generator/src/template-handlers/frontend.ts
+++ b/packages/template-generator/src/template-handlers/frontend.ts
@@ -10,7 +10,9 @@ export async function processFrontendTemplates(
config: ProjectConfig,
): Promise {
const hasReactWeb = config.frontend.some((f) =>
- ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext"].includes(f),
+ ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext"].includes(
+ f,
+ ),
);
const hasNuxtWeb = config.frontend.includes("nuxt");
const hasSvelteWeb = config.frontend.includes("svelte");
@@ -42,7 +44,14 @@ export async function processFrontendTemplates(
processTemplatesFromPrefix(vfs, templates, "frontend/react/web-base", "apps/web", config);
const reactFramework = config.frontend.find((f) =>
- ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext"].includes(f),
+ [
+ "tanstack-router",
+ "react-router",
+ "react-vite",
+ "tanstack-start",
+ "next",
+ "vinext",
+ ].includes(f),
);
if (reactFramework) {
processTemplatesFromPrefix(
@@ -86,6 +95,7 @@ export async function processFrontendTemplates(
processTemplatesFromPrefix(vfs, templates, "frontend/redwood", ".", config);
} else if (hasFreshWeb) {
// Fresh (Deno) outputs to apps/web like other frameworks
+ processTemplatesFromPrefix(vfs, templates, "frontend/fresh-root", ".", config);
processTemplatesFromPrefix(vfs, templates, "frontend/fresh", "apps/web", config);
}
}
diff --git a/packages/template-generator/src/template-handlers/index.ts b/packages/template-generator/src/template-handlers/index.ts
index fe614b4e0..6a43e9b5a 100644
--- a/packages/template-generator/src/template-handlers/index.ts
+++ b/packages/template-generator/src/template-handlers/index.ts
@@ -4,6 +4,7 @@ export { processRustBaseTemplate } from "./rust-base";
export { processPythonBaseTemplate } from "./python-base";
export { processGoBaseTemplate } from "./go-base";
export { processJavaBaseTemplate } from "./java-base";
+export { processElixirBaseTemplate } from "./elixir-base";
export { processFrontendTemplates } from "./frontend";
export { processBackendTemplates } from "./backend";
export { processDbTemplates } from "./database";
diff --git a/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs
index 0689c7867..dad39a24a 100644
--- a/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs
+++ b/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from "react-native";
import { Container } from "@/components/container";
import { useColorScheme } from "@/lib/use-color-scheme";
@@ -184,3 +185,4 @@ privateDataText: {
fontSize: 14,
},
});
+{{/if}}
diff --git a/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs
index 184ee1219..ba9205554 100644
--- a/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs
+++ b/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { authClient } from "@/lib/auth-client";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -185,3 +186,4 @@ const styles = StyleSheet.create((theme) => ({
padding: 16,
},
}));
+{{/if}}
diff --git a/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs
index 449d0e831..772202c7f 100644
--- a/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs
+++ b/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Text, View, Pressable } from "react-native";
import { Container } from "@/components/container";
import { authClient } from "@/lib/auth-client";
@@ -121,3 +122,4 @@ return (
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs
index a36f42a09..64adcab4e 100644
--- a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Redirect, Stack } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";
@@ -10,3 +11,4 @@ export default function AuthRoutesLayout() {
return ;
}
+{{/if}}
diff --git a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs
index 6f4b4e122..c7ee5dd1f 100644
--- a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs
+++ b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { useSignIn } from "@clerk/clerk-expo";
import { Link, useRouter } from "expo-router";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
@@ -65,3 +66,4 @@ export default function Page() {
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs
index d50f341d6..491bd5141 100644
--- a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs
+++ b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import * as React from "react";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
import { useSignUp } from "@clerk/clerk-expo";
@@ -108,3 +109,4 @@ export default function SignUpScreen() {
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs b/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs
index f0ddf05b5..20dd85815 100644
--- a/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs
+++ b/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs
@@ -3,6 +3,7 @@
"compilerOptions": {
"outDir": "./build",
"rootDir": ".",
+ "jsx": "react-jsx",
"paths": {
"#controllers/*": ["./app/controllers/*.js"],
"#middleware/*": ["./app/middleware/*.js"],
@@ -10,6 +11,6 @@
"#config/*": ["./config/*.js"]
}
},
- "include": ["**/*.ts"],
+ "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "build"]
}
diff --git a/packages/template-generator/templates/base/deno.json.hbs b/packages/template-generator/templates/base/deno.json.hbs
new file mode 100644
index 000000000..6a6837650
--- /dev/null
+++ b/packages/template-generator/templates/base/deno.json.hbs
@@ -0,0 +1,9 @@
+{{#if (includes frontend "fresh")}}
+{
+ "workspace": [
+ "./apps/web"
+ ],
+ "lock": false,
+ "nodeModulesDir": "auto"
+}
+{{/if}}
diff --git a/packages/template-generator/templates/elixir-base/config/config.exs.hbs b/packages/template-generator/templates/elixir-base/config/config.exs.hbs
new file mode 100644
index 000000000..e5c3dd0fc
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/config/config.exs.hbs
@@ -0,0 +1,31 @@
+import Config
+
+config :{{elixirOtpApp}},
+ app_name: "{{projectName}}"
+
+{{#if isElixirPhoenix}}
+config :{{elixirOtpApp}}, {{elixirModuleName}}Web.Endpoint,
+ url: [host: "localhost"],
+ adapter: Phoenix.Endpoint.Cowboy2Adapter,
+ render_errors: [
+ formats: [html: {{elixirModuleName}}Web.ErrorHTML, json: {{elixirModuleName}}Web.ErrorJSON],
+ layout: false
+ ],
+ pubsub_server: {{elixirModuleName}}.PubSub,
+ live_view: [signing_salt: "better-fullstack"]
+
+config :phoenix, :json_library, Jason
+{{/if}}
+
+{{#if hasElixirEcto}}
+config :{{elixirOtpApp}},
+ ecto_repos: [{{elixirModuleName}}.Repo]
+
+config :{{elixirOtpApp}}, {{elixirModuleName}}.Repo,
+ database: "{{elixirAppName}}_#{config_env()}",
+ username: System.get_env("POSTGRES_USER", "postgres"),
+ password: System.get_env("POSTGRES_PASSWORD", "postgres"),
+ hostname: System.get_env("POSTGRES_HOST", "localhost")
+{{/if}}
+
+import_config "#{config_env()}.exs"
diff --git a/packages/template-generator/templates/elixir-base/config/dev.exs.hbs b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs
new file mode 100644
index 000000000..cb05938a8
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs
@@ -0,0 +1,12 @@
+import Config
+
+{{#if isElixirPhoenix}}
+config :{{elixirOtpApp}}, {{elixirModuleName}}Web.Endpoint,
+ http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT", "4000"))],
+ check_origin: false,
+ code_reloader: true,
+ debug_errors: true,
+ secret_key_base: "better_fullstack_dev_secret_key_base_for_local_development_only"
+{{/if}}
+
+config :logger, :console, format: "[$level] $message\n"
diff --git a/packages/template-generator/templates/elixir-base/config/prod.exs.hbs b/packages/template-generator/templates/elixir-base/config/prod.exs.hbs
new file mode 100644
index 000000000..c2c757055
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/config/prod.exs.hbs
@@ -0,0 +1,7 @@
+import Config
+
+{{#if isElixirPhoenix}}
+config :{{elixirOtpApp}}, {{elixirModuleName}}Web.Endpoint,
+ cache_static_manifest: "priv/static/cache_manifest.json",
+ server: true
+{{/if}}
diff --git a/packages/template-generator/templates/elixir-base/config/test.exs.hbs b/packages/template-generator/templates/elixir-base/config/test.exs.hbs
new file mode 100644
index 000000000..98ffb69be
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/config/test.exs.hbs
@@ -0,0 +1,10 @@
+import Config
+
+{{#if isElixirPhoenix}}
+config :{{elixirOtpApp}}, {{elixirModuleName}}Web.Endpoint,
+ http: [ip: {127, 0, 0, 1}, port: 4002],
+ server: false,
+ secret_key_base: "better_fullstack_test_secret_key_base_for_local_testing_only"
+{{/if}}
+
+config :logger, level: :warning
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__.ex.hbs
new file mode 100644
index 000000000..502c6a94a
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__.ex.hbs
@@ -0,0 +1,9 @@
+defmodule {{elixirModuleName}} do
+ @moduledoc """
+ Public API for the {{projectName}} Elixir application.
+ """
+
+ def hello do
+ :world
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/application.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/application.ex.hbs
new file mode 100644
index 000000000..1d764f7c0
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/application.ex.hbs
@@ -0,0 +1,28 @@
+defmodule {{elixirModuleName}}.Application do
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [
+{{#if hasElixirEcto}}
+ {{elixirModuleName}}.Repo,
+{{/if}}
+{{#if isElixirPhoenix}}
+ {Phoenix.PubSub, name: {{elixirModuleName}}.PubSub},
+ {{elixirModuleName}}Web.Endpoint,
+{{/if}}
+ {{elixirModuleName}}.Worker
+ ]
+
+ opts = [strategy: :one_for_one, name: {{elixirModuleName}}.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+
+{{#if isElixirPhoenix}}
+ @impl true
+ def config_change(changed, _new, removed) do
+ {{elixirModuleName}}Web.Endpoint.config_change(changed, removed)
+ :ok
+ end
+{{/if}}
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/repo.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/repo.ex.hbs
new file mode 100644
index 000000000..e3f151760
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/repo.ex.hbs
@@ -0,0 +1,5 @@
+defmodule {{elixirModuleName}}.Repo do
+ use Ecto.Repo,
+ otp_app: :{{elixirOtpApp}},
+ adapter: Ecto.Adapters.Postgres
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/error_html.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/error_html.ex.hbs
new file mode 100644
index 000000000..9fd90176f
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/error_html.ex.hbs
@@ -0,0 +1,5 @@
+defmodule {{elixirModuleName}}Web.ErrorHTML do
+ def render(_template, _assigns) do
+ "Server error"
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/error_json.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/error_json.ex.hbs
new file mode 100644
index 000000000..676fd1017
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/error_json.ex.hbs
@@ -0,0 +1,5 @@
+defmodule {{elixirModuleName}}Web.ErrorJSON do
+ def render(_template, _assigns) do
+ %{errors: %{detail: "Server error"}}
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/health_controller.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/health_controller.ex.hbs
new file mode 100644
index 000000000..053b91b96
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/health_controller.ex.hbs
@@ -0,0 +1,7 @@
+defmodule {{elixirModuleName}}Web.HealthController do
+ use Phoenix.Controller, formats: [:json]
+
+ def show(conn, _params) do
+ json(conn, %{status: "ok", framework: "phoenix"})
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/page_controller.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/page_controller.ex.hbs
new file mode 100644
index 000000000..50971b08b
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/controllers/page_controller.ex.hbs
@@ -0,0 +1,13 @@
+defmodule {{elixirModuleName}}Web.PageController do
+ use Phoenix.Controller, formats: [:html]
+
+ def home(conn, _params) do
+ html(conn, """
+
+ Better Fullstack
+ {{projectName}}
+ Generated with Elixir, OTP, and Phoenix.
+
+ """)
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/endpoint.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/endpoint.ex.hbs
new file mode 100644
index 000000000..29e8c0fdf
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/endpoint.ex.hbs
@@ -0,0 +1,12 @@
+defmodule {{elixirModuleName}}Web.Endpoint do
+ use Phoenix.Endpoint, otp_app: :{{elixirOtpApp}}
+
+ plug Plug.RequestId
+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+ plug Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Phoenix.json_library()
+
+ plug {{elixirModuleName}}Web.Router
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/router.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/router.ex.hbs
new file mode 100644
index 000000000..2964b6e09
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/web/router.ex.hbs
@@ -0,0 +1,23 @@
+defmodule {{elixirModuleName}}Web.Router do
+ use Phoenix.Router
+
+ pipeline :browser do
+ plug :accepts, ["html"]
+ end
+
+ pipeline :api do
+ plug :accepts, ["json"]
+ end
+
+ scope "/", {{elixirModuleName}}Web do
+ pipe_through :browser
+
+ get "/", PageController, :home
+ end
+
+ scope "/api", {{elixirModuleName}}Web do
+ pipe_through :api
+
+ get "/health", HealthController, :show
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/worker.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/worker.ex.hbs
new file mode 100644
index 000000000..7abe9be59
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/lib/__elixir_app_name__/worker.ex.hbs
@@ -0,0 +1,15 @@
+defmodule {{elixirModuleName}}.Worker do
+ use GenServer
+
+ require Logger
+
+ def start_link(_opts) do
+ GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
+ end
+
+ @impl true
+ def init(state) do
+ Logger.info("{{projectName}} worker started")
+ {:ok, state}
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/mix.exs.hbs b/packages/template-generator/templates/elixir-base/mix.exs.hbs
new file mode 100644
index 000000000..b8b7f8652
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/mix.exs.hbs
@@ -0,0 +1,72 @@
+defmodule {{elixirModuleName}}.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :{{elixirOtpApp}},
+ version: "0.1.0",
+ elixir: "~> 1.17",
+ start_permanent: Mix.env() == :prod,
+ deps: deps(),
+ aliases: aliases()
+ ]
+ end
+
+ def application do
+ [
+ mod: { {{elixirModuleName}}.Application, [] },
+ extra_applications: [:logger{{#if hasElixirEcto}}, :ecto_sql{{/if}}{{#if hasElixirTelemetry}}, :telemetry{{/if}}]
+ ]
+ end
+
+ defp deps do
+ [
+{{#if isElixirPhoenix}}
+ {:phoenix, "~> 1.7"},
+ {:phoenix_live_view, "~> 1.0"},
+ {:phoenix_html, "~> 4.0"},
+ {:plug_cowboy, "~> 2.7"},
+{{/if}}
+{{#if hasElixirEcto}}
+ {:ecto_sql, "~> 3.12"},
+ {:postgrex, ">= 0.0.0"},
+{{/if}}
+{{#if hasElixirJason}}
+ {:jason, "~> 1.4"},
+{{/if}}
+{{#if hasElixirReq}}
+ {:req, "~> 0.5"},
+{{/if}}
+{{#if hasElixirOban}}
+ {:oban, "~> 2.19"},
+{{/if}}
+{{#if hasElixirBroadway}}
+ {:broadway, "~> 1.1"},
+{{/if}}
+{{#if hasElixirTelemetry}}
+ {:telemetry_metrics, "~> 1.0"},
+{{/if}}
+{{#if hasElixirNx}}
+ {:nx, "~> 0.9"},
+{{/if}}
+{{#if hasElixirMox}}
+ {:mox, "~> 1.2", only: :test},
+{{/if}}
+{{#if hasElixirStreamData}}
+ {:stream_data, "~> 1.1", only: :test}
+{{/if}}
+ ]
+ end
+
+ defp aliases do
+ [
+ setup: ["deps.get"{{#if hasElixirEcto}}, "ecto.setup"{{/if}}],
+ test: ["test"]
+{{#if hasElixirEcto}}
+ ,
+ "ecto.setup": ["ecto.create", "ecto.migrate"],
+ "ecto.reset": ["ecto.drop", "ecto.setup"]
+{{/if}}
+ ]
+ end
+end
diff --git a/packages/template-generator/templates/elixir-base/test/__elixir_app_name___test.exs.hbs b/packages/template-generator/templates/elixir-base/test/__elixir_app_name___test.exs.hbs
new file mode 100644
index 000000000..e7d118cb6
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/test/__elixir_app_name___test.exs.hbs
@@ -0,0 +1,18 @@
+defmodule {{elixirModuleName}}Test do
+ use ExUnit.Case, async: true
+{{#if hasElixirStreamData}}
+ use ExUnitProperties
+{{/if}}
+
+ test "hello returns world" do
+ assert {{elixirModuleName}}.hello() == :world
+ end
+
+{{#if hasElixirStreamData}}
+ property "hello is stable" do
+ check all _value <- term() do
+ assert {{elixirModuleName}}.hello() == :world
+ end
+ end
+{{/if}}
+end
diff --git a/packages/template-generator/templates/elixir-base/test/support/mox.ex.hbs b/packages/template-generator/templates/elixir-base/test/support/mox.ex.hbs
new file mode 100644
index 000000000..29d2b4279
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/test/support/mox.ex.hbs
@@ -0,0 +1,3 @@
+defmodule {{elixirModuleName}}.ExampleBehaviour do
+ @callback call(term()) :: term()
+end
diff --git a/packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs b/packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs
new file mode 100644
index 000000000..503636aa7
--- /dev/null
+++ b/packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs
@@ -0,0 +1,6 @@
+ExUnit.start()
+
+{{#if hasElixirMox}}
+Code.require_file("support/mox.ex", __DIR__)
+Mox.defmock({{elixirModuleName}}.ExampleMock, for: {{elixirModuleName}}.ExampleBehaviour)
+{{/if}}
diff --git a/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs b/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs
index 7e15ae9f0..b2bc023fb 100644
--- a/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs
+++ b/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs
@@ -1 +1 @@
-export { WelcomeEmail } from "./welcome";
+export { WelcomeEmail } from "./welcome.js";
diff --git a/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs b/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs
index 7e15ae9f0..b2bc023fb 100644
--- a/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs
+++ b/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs
@@ -1 +1 @@
-export { WelcomeEmail } from "./welcome";
+export { WelcomeEmail } from "./welcome.js";
diff --git a/packages/template-generator/templates/frontend/fresh-root/deno.json.hbs b/packages/template-generator/templates/frontend/fresh-root/deno.json.hbs
new file mode 100644
index 000000000..8a070ded3
--- /dev/null
+++ b/packages/template-generator/templates/frontend/fresh-root/deno.json.hbs
@@ -0,0 +1,7 @@
+{
+ "lock": false,
+ "nodeModulesDir": "auto",
+ "workspace": [
+ "./apps/web"
+ ]
+}
diff --git a/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs b/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs
index a96f37580..ddf072389 100644
--- a/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs
+++ b/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs
@@ -1,3 +1,4 @@
+/** @jsxImportSource preact */
interface HeaderProps {
projectName: string;
}
diff --git a/packages/template-generator/templates/frontend/fresh/deno.json.hbs b/packages/template-generator/templates/frontend/fresh/deno.json.hbs
index 559a3ece3..6923b000e 100644
--- a/packages/template-generator/templates/frontend/fresh/deno.json.hbs
+++ b/packages/template-generator/templates/frontend/fresh/deno.json.hbs
@@ -1,5 +1,4 @@
{
- "lock": false,
"tasks": {
"check": "deno fmt --check . && deno lint . && deno check",
"dev": "vite",
@@ -22,7 +21,11 @@
"@/": "./",
"fresh": "jsr:@fresh/core@^2.2.0",
"preact": "npm:preact@^10.27.2",
+ "preact/": "npm:preact@^10.27.2/",
+ "preact/jsx-runtime": "npm:preact@^10.27.2/jsx-runtime",
+ "preact/jsx-dev-runtime": "npm:preact@^10.27.2/jsx-dev-runtime",
"@preact/signals": "npm:@preact/signals@^2.5.0",
+ "@preact/signals/": "npm:@preact/signals@^2.5.0/",
"@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8",
"vite": "npm:vite@^7.3.1"{{#if (eq cssFramework "tailwind")}},
"tailwindcss": "npm:tailwindcss@^4.1.12",
@@ -37,7 +40,7 @@
"deno.ns"
],
"jsx": "precompile",
- "jsxImportSource": "preact",
+ "jsxImportSource": "npm:preact@^10.27.2",
"jsxPrecompileSkipElements": [
"a",
"img",
@@ -57,6 +60,5 @@
"types": [
"vite/client"
]
- },
- "nodeModulesDir": "auto"
+ }
}
diff --git a/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs b/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs
index 90ea3ba81..dddd1952f 100644
--- a/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs
+++ b/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs
@@ -1,3 +1,4 @@
+/** @jsxImportSource preact */
import { useSignal } from "@preact/signals";
interface CounterProps {
diff --git a/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs b/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs
index e3fd13bd5..06bea8bbf 100644
--- a/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs
+++ b/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs
@@ -1,3 +1,4 @@
+/** @jsxImportSource preact */
import { define } from "../utils.ts";
import Header from "../components/Header.tsx";
diff --git a/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs b/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs
index 485297ebd..69b4ff1ef 100644
--- a/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs
@@ -1,3 +1,4 @@
+/** @jsxImportSource preact */
import { Head } from "fresh/runtime";
import { define } from "../utils.ts";
import Counter from "../islands/Counter.tsx";
diff --git a/packages/template-generator/templates/frontend/native/bare/app.json.hbs b/packages/template-generator/templates/frontend/native/bare/app.json.hbs
index 21d0859d2..051d37925 100644
--- a/packages/template-generator/templates/frontend/native/bare/app.json.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app.json.hbs
@@ -1,50 +1,73 @@
{
- "expo": {
- "name": "{{projectName}}",
- "slug": "{{projectName}}",
- "version": "1.0.0",
- "orientation": "portrait",
- "icon": "./assets/images/icon.png",
- "scheme": "mybettertapp",
- "userInterfaceStyle": "automatic",
- "newArchEnabled": true,
- "ios": {
- "supportsTablet": true
- },
- "android": {
- "adaptiveIcon": {
- "backgroundColor": "#E6F4FE",
- "foregroundImage": "./assets/images/android-icon-foreground.png",
- "backgroundImage": "./assets/images/android-icon-background.png",
- "monochromeImage": "./assets/images/android-icon-monochrome.png"
- },
- "edgeToEdgeEnabled": true,
- "predictiveBackGestureEnabled": false,
- "package": "com.anonymous.mybettertapp"
- },
- "web": {
- "output": "static",
- "favicon": "./assets/images/favicon.png"
- },
- "plugins": [
- "expo-router",
- [
- "expo-splash-screen",
- {
- "image": "./assets/images/splash-icon.png",
- "imageWidth": 200,
- "resizeMode": "contain",
- "backgroundColor": "#ffffff",
- "dark": {
- "backgroundColor": "#000000"
- }
- }
- ]
- ],
- "experiments": {
- "typedRoutes": true,
- "reactCompiler": true
- }
- }
+ "expo": {
+ "name": "{{projectName}}",
+ "slug": "{{projectName}}",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "{{projectName}}",
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.betterfullstack.app"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "backgroundColor": "#E6F4FE",
+ "foregroundImage": "./assets/images/android-icon-foreground.png",
+ "backgroundImage": "./assets/images/android-icon-background.png",
+ "monochromeImage": "./assets/images/android-icon-monochrome.png"
+ },
+ "edgeToEdgeEnabled": true,
+ "predictiveBackGestureEnabled": false,
+ "package": "com.betterfullstack.app"
+ },
+ "web": {
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
+ },
+ {{#if (eq mobileOTA "expo-updates")}}
+ "updates": {
+ "url": "https://u.expo.dev/your-eas-project-id"
+ },
+ "runtimeVersion": {
+ "policy": "appVersion"
+ },
+ "extra": {
+ "eas": {
+ "projectId": "your-eas-project-id"
+ }
+ },
+ {{/if}}
+ "plugins": [
+ {{#if (eq mobileNavigation "expo-router")}}
+ "expo-router",
+ {{/if}}
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/images/splash-icon.png",
+ "imageWidth": 200,
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff",
+ "dark": {
+ "backgroundColor": "#000000"
+ }
+ }
+ ]{{#if (eq mobilePush "expo-notifications")}},
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/images/icon.png",
+ "color": "#111827"
+ }
+ ]
+ {{/if}}
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "reactCompiler": true
+ }
+ }
}
-
diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs
index 627082b33..ef879c79e 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { TabBarIcon } from "@/components/tabbar-icon";
import { useColorScheme } from "@/lib/use-color-scheme";
import { Tabs } from "expo-router";
@@ -38,4 +39,4 @@ export default function TabLayout() {
);
}
-
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs
index 1d55bf21c..752a09470 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { ScrollView, Text, View, StyleSheet } from "react-native";
import { useColorScheme } from "@/lib/use-color-scheme";
@@ -40,4 +41,4 @@ const styles = StyleSheet.create({
fontSize: 16,
},
});
-
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs
index 0ffa6a31c..e1f80f0a9 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { ScrollView, Text, View, StyleSheet } from "react-native";
import { useColorScheme } from "@/lib/use-color-scheme";
@@ -40,4 +41,4 @@ const styles = StyleSheet.create({
fontSize: 16,
},
});
-
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs
index df33a92cf..df2891353 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
import { Drawer } from "expo-router/drawer";
@@ -75,4 +76,4 @@ const DrawerLayout = () => {
};
export default DrawerLayout;
-
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs
index 994d0a047..7a328322e 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from "react-native";
import { Container } from "@/components/container";
import { useColorScheme } from "@/lib/use-color-scheme";
@@ -232,3 +233,4 @@ marginBottom: 8,
fontWeight: "bold",
},
});
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs
index 100e1dd59..7525680b5 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { Link, Stack } from "expo-router";
import { Text, View, StyleSheet } from "react-native";
@@ -62,4 +63,4 @@ const styles = StyleSheet.create({
padding: 12,
},
});
-
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs
index 38e01a6a5..33bcae8ec 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
{{#if (includes examples "ai")}}
import "@/polyfills";
{{/if}}
@@ -43,6 +44,13 @@ import React, { useRef } from "react";
import { useColorScheme } from "@/lib/use-color-scheme";
import { Platform, StyleSheet } from "react-native";
import { setAndroidNavigationBar } from "@/lib/android-navigation-bar";
+import { MobileUIProvider } from "@/components/mobile-ui-provider";
+{{#if (eq mobilePush "expo-notifications")}}
+import { registerForPushNotificationsAsync } from "@/lib/notifications";
+{{/if}}
+{{#if (eq mobileOTA "expo-updates")}}
+import { useUpdateCheck } from "@/lib/updates";
+{{/if}}
const LIGHT_THEME: Theme = {
...DefaultTheme,
@@ -75,6 +83,14 @@ const styles = StyleSheet.create({
});
export default function RootLayout() {
+ {{#if (eq mobileOTA "expo-updates")}}
+ useUpdateCheck();
+ {{/if}}
+ React.useEffect(() => {
+ {{#if (eq mobilePush "expo-notifications")}}
+ registerForPushNotificationsAsync().catch(console.warn);
+ {{/if}}
+ }, []);
const hasMounted = useRef(false);
const { colorScheme, isDarkColorScheme } = useColorScheme();
const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false);
@@ -101,11 +117,13 @@ export default function RootLayout() {
+
+
@@ -115,10 +133,12 @@ export default function RootLayout() {
-
+
+
+
@@ -127,10 +147,12 @@ export default function RootLayout() {
-
+
+
+
@@ -141,10 +163,12 @@ export default function RootLayout() {
-
+
+
+
@@ -152,10 +176,12 @@ export default function RootLayout() {
-
+
+
+
{{/unless}}
@@ -163,3 +189,4 @@ export default function RootLayout() {
>
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs
index e568296c8..8be2990de 100644
--- a/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { Text, View, StyleSheet } from "react-native";
import { useColorScheme } from "@/lib/use-color-scheme";
@@ -31,4 +32,4 @@ const styles = StyleSheet.create({
fontWeight: "bold",
},
});
-
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/bare/package.json.hbs b/packages/template-generator/templates/frontend/native/bare/package.json.hbs
index 767e7ecfe..be2308070 100644
--- a/packages/template-generator/templates/frontend/native/bare/package.json.hbs
+++ b/packages/template-generator/templates/frontend/native/bare/package.json.hbs
@@ -1,20 +1,30 @@
{
"name": "native",
"version": "1.0.0",
- "main": "expo-router/entry",
+ "main": "{{#if (eq mobileNavigation 'react-navigation')}}index.js{{else}}expo-router/entry{{/if}}",
"scripts": {
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
- "web": "expo start --web"
+ "web": "expo start --web"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}},
+ "test": "jest"
+ {{/if}}
},
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-navigation/bottom-tabs": "^7.16.1",
"@react-navigation/drawer": "^7.10.2",
"@react-navigation/native": "^7.2.4",
+ "@react-navigation/native-stack": "^7.8.1",
"@tanstack/react-form": "^1.32.0",
+ {{#if (eq mobileUI "tamagui")}}
+ "@tamagui/config": "^2.0.0-rc.42",
+ "tamagui": "^2.0.0-rc.42",
+ {{/if}}
+ {{#if (eq mobileUI "gluestack-ui")}}
+ "@gluestack-ui/themed": "^1.1.73",
+ {{/if}}
"@tanstack/react-query": "^5.100.10",
{{#if (includes examples "ai")}}
"@stardazed/streams-text-encoding": "^1.0.2",
@@ -22,19 +32,35 @@
{{/if}}
"expo": "^55.0.24",
"expo-constants": "^55.0.16",
+ {{#if (eq mobilePush "expo-notifications")}}
+ "expo-device": "^8.0.9",
+ {{/if}}
"expo-crypto": "^55.0.15",
+ {{#if (eq mobileDeepLinking "expo-linking")}}
"expo-linking": "^55.0.15",
+ {{/if}}
"expo-navigation-bar": "^55.0.13",
"expo-network": "^55.0.14",
+ {{#if (eq mobilePush "expo-notifications")}}
+ "expo-notifications": "^56.0.12",
+ {{/if}}
+ {{#if (eq mobileNavigation "expo-router")}}
"expo-router": "^55.0.14",
+ {{/if}}
"expo-secure-store": "^55.0.14",
"expo-splash-screen": "^55.0.21",
"expo-status-bar": "^55.0.6",
"expo-system-ui": "^55.0.18",
+ {{#if (eq mobileOTA "expo-updates")}}
+ "expo-updates": "^56.0.15",
+ {{/if}}
"expo-web-browser": "^55.0.16",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-native": "^0.85.3",
+ {{#if (eq mobileStorage "mmkv")}}
+ "react-native-mmkv": "^4.1.0",
+ {{/if}}
"react-native-gesture-handler": "^2.31.2",
"react-native-reanimated": "^4.3.1",
"react-native-safe-area-context": "^5.7.0",
@@ -44,8 +70,14 @@
},
"devDependencies": {
"@babel/core": "^7.29.0",
- "@types/react": "^19.2.14"
+ "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}},
+ "@types/jest": "^29.5.14",
+ "@testing-library/react-native": "^13.3.3",
+ "@react-native/jest-preset": "^0.85.3",
+ "jest": "^29.7.0",
+ "jest-expo": "^55.0.18",
+ "react-test-renderer": "^19.2.6"
+ {{/if}}
},
"private": true
}
-
diff --git a/packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs b/packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs
new file mode 100644
index 000000000..8bb7458d7
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs
@@ -0,0 +1,6 @@
+{{#if (or (eq mobileTesting "maestro") (eq mobileTesting "maestro-react-native-testing-library"))}}
+appId: com.betterfullstack.app
+---
+- launchApp
+- assertVisible: "Better Fullstack Mobile"
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/App.tsx.hbs b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs
new file mode 100644
index 000000000..577199480
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs
@@ -0,0 +1,103 @@
+{{#if (eq mobileNavigation "react-navigation")}}
+{{#if (includes examples "ai")}}
+import "@/polyfills";
+{{/if}}
+
+{{#if (eq backend "convex")}}
+ {{#if (eq auth "better-auth")}}
+import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
+import { ConvexReactClient } from "convex/react";
+import { authClient } from "@/lib/auth-client";
+import { env } from "@{{projectName}}/env/native";
+ {{else}}
+import { ConvexProvider, ConvexReactClient } from "convex/react";
+import { env } from "@{{projectName}}/env/native";
+ {{/if}}
+ {{#if (eq auth "clerk")}}
+import { ClerkProvider, useAuth } from "@clerk/clerk-expo";
+import { tokenCache } from "@clerk/clerk-expo/token-cache";
+import { ConvexProviderWithClerk } from "convex/react-clerk";
+ {{/if}}
+{{else}}
+ {{#unless (eq api "none")}}
+import { QueryClientProvider } from "@tanstack/react-query";
+ {{/unless}}
+{{/if}}
+import { NavigationContainer } from "@react-navigation/native";
+import { StatusBar } from "expo-status-bar";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { StyleSheet } from "react-native";
+import { MobileUIProvider } from "@/components/mobile-ui-provider";
+{{#if (eq mobileDeepLinking "expo-linking")}}
+import { linking } from "@/lib/deep-linking";
+{{/if}}
+{{#if (eq api "trpc")}}
+import { queryClient } from "@/utils/trpc";
+{{/if}}
+{{#if (eq api "orpc")}}
+import { queryClient } from "@/utils/orpc";
+{{/if}}
+import { RootNavigator } from "@/navigation/native-navigation";
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
+
+{{#if (eq backend "convex")}}
+const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
+ unsavedChangesWarning: false,
+});
+{{/if}}
+
+function AppShell() {
+ return (
+
+
+
+ {{#if (eq mobileDeepLinking "expo-linking")}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+
+
+ );
+}
+
+export default function App() {
+ return (
+ {{#if (eq backend "convex")}}
+ {{#if (eq auth "clerk")}}
+
+
+
+
+
+ {{else if (eq auth "better-auth")}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{else}}
+ {{#unless (eq api "none")}}
+
+
+
+ {{else}}
+
+ {{/unless}}
+ {{/if}}
+ );
+}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs b/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs
new file mode 100644
index 000000000..91e2489e2
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs
@@ -0,0 +1,33 @@
+{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}
+///
+
+import type { ReactNode } from "react";
+import { render } from "@testing-library/react-native";
+import { Text } from "react-native";
+
+{{#if (eq mobileUI "gluestack-ui")}}
+jest.mock("@gluestack-ui/themed", () => ({
+ GluestackUIProvider: ({ children }: { children: ReactNode }) => children,
+}));
+
+{{/if}}
+{{#if (eq mobileUI "tamagui")}}
+jest.mock("@tamagui/config/v3", () => ({ config: {} }));
+jest.mock("tamagui", () => ({
+ createTamagui: (config: unknown) => config,
+ TamaguiProvider: ({ children }: { children: ReactNode }) => children,
+}));
+
+{{/if}}
+import { MobileUIProvider } from "@/components/mobile-ui-provider";
+
+test("renders children inside the mobile provider", () => {
+ const screen = render(
+
+ Mobile ready
+ ,
+ );
+
+ expect(screen.getByText("Mobile ready")).toBeTruthy();
+});
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs
new file mode 100644
index 000000000..41224a4b3
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs
@@ -0,0 +1,24 @@
+import type { PropsWithChildren } from "react";
+{{#if (eq mobileUI "tamagui")}}
+import { TamaguiProvider, createTamagui } from "tamagui";
+import { config } from "@tamagui/config/v3";
+
+const tamaguiConfig = createTamagui(config);
+{{/if}}
+{{#if (eq mobileUI "gluestack-ui")}}
+import { GluestackUIProvider } from "@gluestack-ui/themed";
+{{/if}}
+
+export function MobileUIProvider({ children }: PropsWithChildren) {
+ {{#if (eq mobileUI "tamagui")}}
+ return (
+
+ {children}
+
+ );
+ {{else if (eq mobileUI "gluestack-ui")}}
+ return {children} ;
+ {{else}}
+ return <>{children}>;
+ {{/if}}
+}
diff --git a/packages/template-generator/templates/frontend/native/base/index.js.hbs b/packages/template-generator/templates/frontend/native/base/index.js.hbs
new file mode 100644
index 000000000..dd659cd34
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/index.js.hbs
@@ -0,0 +1,7 @@
+{{#if (eq mobileNavigation "react-navigation")}}
+import { registerRootComponent } from "expo";
+
+import App from "./App";
+
+registerRootComponent(App);
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/jest.config.js.hbs b/packages/template-generator/templates/frontend/native/base/jest.config.js.hbs
new file mode 100644
index 000000000..53a3b2796
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/jest.config.js.hbs
@@ -0,0 +1,10 @@
+{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}
+module.exports = {
+ preset: "jest-expo",
+ transformIgnorePatterns: [
+ "/node_modules/(?!(.bun|.pnpm|react-native|@react-native|@react-native-community|expo|@expo|@expo-google-fonts|react-navigation|@react-navigation|@sentry/react-native|native-base|@gluestack-ui|react-native-mmkv))",
+ "/node_modules/react-native-reanimated/plugin/",
+ "/node_modules/@react-native/babel-preset/",
+ ],
+};
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs
new file mode 100644
index 000000000..22cc063a4
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs
@@ -0,0 +1,25 @@
+{{#if (eq mobileDeepLinking "expo-linking")}}
+import * as Linking from "expo-linking";
+
+export const appScheme = "{{projectName}}";
+
+export const linking = {
+ prefixes: [Linking.createURL("/"), `${appScheme}://`],
+ config: {
+ screens: {
+ Main: "",
+ Modal: "modal",
+ Home: "",
+ Settings: "settings",
+ },
+ },
+};
+
+export function getAuthRedirectUri(path = "auth/callback") {
+ return Linking.createURL(path);
+}
+
+export function getDeepLinkUrl(path = "") {
+ return Linking.createURL(path);
+}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs
new file mode 100644
index 000000000..00811681f
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs
@@ -0,0 +1,15 @@
+{{#if (eq mobileStorage "mmkv")}}
+import { createMMKV } from "react-native-mmkv";
+
+export const mobileStorage = createMMKV({
+ id: "{{projectName}}.storage",
+});
+
+export function getStoredString(key: string) {
+ return mobileStorage.getString(key) ?? null;
+}
+
+export function setStoredString(key: string, value: string) {
+ mobileStorage.set(key, value);
+}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs
new file mode 100644
index 000000000..a1b3a0486
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs
@@ -0,0 +1,52 @@
+{{#if (eq mobilePush "expo-notifications")}}
+import Constants from "expo-constants";
+import * as Device from "expo-device";
+import * as Notifications from "expo-notifications";
+import { Platform } from "react-native";
+
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldPlaySound: false,
+ shouldSetBadge: false,
+ shouldShowBanner: true,
+ shouldShowList: true,
+ }),
+});
+
+export async function registerForPushNotificationsAsync() {
+ if (Platform.OS === "android") {
+ await Notifications.setNotificationChannelAsync("default", {
+ name: "default",
+ importance: Notifications.AndroidImportance.MAX,
+ });
+ }
+
+ if (!Device.isDevice) {
+ return null;
+ }
+
+ const existing = (await Notifications.getPermissionsAsync()) as {
+ granted?: boolean;
+ status?: string;
+ };
+ const existingGranted = existing.granted ?? existing.status === "granted";
+ const requested = existingGranted
+ ? existing
+ : ((await Notifications.requestPermissionsAsync()) as {
+ granted?: boolean;
+ status?: string;
+ });
+ const finalStatus =
+ (requested.granted ?? requested.status === "granted") ? "granted" : "denied";
+
+ if (finalStatus !== "granted") {
+ return null;
+ }
+
+ const projectId = Constants.expoConfig?.extra?.eas?.projectId;
+ const token = await Notifications.getExpoPushTokenAsync(
+ projectId ? { projectId } : undefined,
+ );
+ return token.data;
+}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs
new file mode 100644
index 000000000..148e188b1
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs
@@ -0,0 +1,30 @@
+{{#if (eq mobileOTA "expo-updates")}}
+import * as Updates from "expo-updates";
+import { useEffect, useState } from "react";
+
+export function useUpdateCheck() {
+ const [isChecking, setIsChecking] = useState(false);
+ const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
+
+ useEffect(() => {
+ let isMounted = true;
+ setIsChecking(true);
+ Updates.checkForUpdateAsync()
+ .then((result) => {
+ if (isMounted) setIsUpdateAvailable(result.isAvailable);
+ })
+ .catch(() => {
+ if (isMounted) setIsUpdateAvailable(false);
+ })
+ .finally(() => {
+ if (isMounted) setIsChecking(false);
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return { isChecking, isUpdateAvailable };
+}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs
new file mode 100644
index 000000000..07dcaf871
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs
@@ -0,0 +1,153 @@
+{{#if (eq mobileNavigation "react-navigation")}}
+import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import { Text, View, Pressable, StyleSheet } from "react-native";
+{{#if (eq auth "clerk")}}
+import { useUser } from "@clerk/clerk-expo";
+{{/if}}
+{{#if (eq mobilePush "expo-notifications")}}
+import { registerForPushNotificationsAsync } from "@/lib/notifications";
+{{/if}}
+{{#if (eq mobileOTA "expo-updates")}}
+import { useUpdateCheck } from "@/lib/updates";
+{{/if}}
+{{#if (eq mobileStorage "mmkv")}}
+import { mobileStorage } from "@/lib/mobile-storage";
+{{/if}}
+{{#if (eq mobileDeepLinking "expo-linking")}}
+import { getAuthRedirectUri } from "@/lib/deep-linking";
+{{/if}}
+import { useEffect } from "react";
+
+type RootStackParamList = {
+ Main: undefined;
+ Modal: undefined;
+};
+
+type TabParamList = {
+ Home: undefined;
+ Settings: undefined;
+};
+
+const Stack = createNativeStackNavigator();
+const Tab = createBottomTabNavigator();
+
+function HomeScreen() {
+ {{#if (eq auth "clerk")}}
+ const { user } = useUser();
+ {{/if}}
+ {{#if (eq mobileOTA "expo-updates")}}
+ const { isChecking, isUpdateAvailable } = useUpdateCheck();
+ {{/if}}
+
+ useEffect(() => {
+ {{#if (eq mobilePush "expo-notifications")}}
+ registerForPushNotificationsAsync().catch(console.warn);
+ {{/if}}
+ {{#if (eq mobileStorage "mmkv")}}
+ mobileStorage.set("lastOpenedAt", new Date().toISOString());
+ {{/if}}
+ }, []);
+
+ return (
+
+ Better Fullstack Mobile
+ Production-minded Expo starter
+ {{#if (eq auth "clerk")}}
+ {user ? `Signed in as ${user.primaryEmailAddress?.emailAddress}` : "Auth routes are ready for Clerk."}
+ {{/if}}
+ {{#if (eq auth "better-auth")}}
+ {{#if (eq mobileDeepLinking "expo-linking")}}
+ Use {getAuthRedirectUri()} as the Better Auth mobile callback URL.
+ {{else}}
+ Better Auth mobile routes are ready for your callback URL.
+ {{/if}}
+ {{/if}}
+ {{#if (eq mobileOTA "expo-updates")}}
+ {isChecking ? "Checking for updates..." : isUpdateAvailable ? "An update is ready." : "App is up to date."}
+ {{/if}}
+
+ );
+}
+
+function SettingsScreen() {
+ return (
+
+ Mobile integrations
+ {{#if (eq mobileDeepLinking "expo-linking")}}
+ Deep link redirect URI: {getAuthRedirectUri()}
+ {{/if}}
+ {{#if (eq mobileStorage "mmkv")}}
+ MMKV is configured in lib/mobile-storage.ts.
+ {{/if}}
+
+ );
+}
+
+function ModalScreen({ navigation }: { navigation: { goBack: () => void } }) {
+ return (
+
+ Modal
+ navigation.goBack()} style={styles.button}>
+ Close
+
+
+ );
+}
+
+function Tabs() {
+ return (
+
+
+
+
+ );
+}
+
+export function RootNavigator() {
+ return (
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ justifyContent: "center",
+ gap: 12,
+ padding: 24,
+ backgroundColor: "#f8fafc",
+ },
+ eyebrow: {
+ fontSize: 13,
+ fontWeight: "700",
+ letterSpacing: 0,
+ textTransform: "uppercase",
+ color: "#2563eb",
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: "800",
+ color: "#0f172a",
+ },
+ body: {
+ fontSize: 16,
+ lineHeight: 24,
+ color: "#475569",
+ },
+ button: {
+ alignSelf: "flex-start",
+ borderRadius: 8,
+ backgroundColor: "#111827",
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ buttonText: {
+ color: "#ffffff",
+ fontWeight: "700",
+ },
+});
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs
index f5ab3dfed..051d37925 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs
@@ -5,11 +5,12 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
- "scheme": "mybettertapp",
+ "scheme": "{{projectName}}",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "bundleIdentifier": "com.betterfullstack.app"
},
"android": {
"adaptiveIcon": {
@@ -20,14 +21,29 @@
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
- "package": "com.anonymous.mybettertapp"
+ "package": "com.betterfullstack.app"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
+ {{#if (eq mobileOTA "expo-updates")}}
+ "updates": {
+ "url": "https://u.expo.dev/your-eas-project-id"
+ },
+ "runtimeVersion": {
+ "policy": "appVersion"
+ },
+ "extra": {
+ "eas": {
+ "projectId": "your-eas-project-id"
+ }
+ },
+ {{/if}}
"plugins": [
+ {{#if (eq mobileNavigation "expo-router")}}
"expo-router",
+ {{/if}}
[
"expo-splash-screen",
{
@@ -39,7 +55,15 @@
"backgroundColor": "#000000"
}
}
+ ]{{#if (eq mobilePush "expo-notifications")}},
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/images/icon.png",
+ "color": "#111827"
+ }
]
+ {{/if}}
],
"experiments": {
"typedRoutes": true,
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs
index 760a1c02f..6c1f49f36 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Tabs } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
@@ -37,3 +38,4 @@ export default function TabLayout() {
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs
index e430dbc11..758c89776 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -35,3 +36,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.mutedForeground,
},
}));
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs
index 2d1a92231..076c25352 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -35,3 +36,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.mutedForeground,
},
}));
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs
index 9fff74661..dc56e3520 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
import { Drawer } from "expo-router/drawer";
@@ -73,3 +74,4 @@ const DrawerLayout = () => {
};
export default DrawerLayout;
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs
index 52e7d08d6..44bfa8227 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { ScrollView, Text, View, TouchableOpacity } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
@@ -331,3 +332,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.mutedForeground,
},
}));
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs
index ab80f8c26..0a62154a0 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Link, Stack } from "expo-router";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -63,3 +64,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: "500",
},
}));
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs
index 821d526ce..fdd1450eb 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
{{#if (includes examples "ai")}}
import "@/polyfills";
{{/if}}
@@ -28,9 +29,16 @@ import { QueryClientProvider } from "@tanstack/react-query";
{{/unless}}
{{/if}}
import { Stack } from "expo-router";
+import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useUnistyles } from "react-native-unistyles";
import { StatusBar } from "expo-status-bar";
+{{#if (eq mobilePush "expo-notifications")}}
+import { registerForPushNotificationsAsync } from "@/lib/notifications";
+{{/if}}
+{{#if (eq mobileOTA "expo-updates")}}
+import { useUpdateCheck } from "@/lib/updates";
+{{/if}}
export const unstable_settings = {
initialRouteName: "(drawer)",
@@ -43,6 +51,14 @@ const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
{{/if}}
export default function RootLayout() {
+ {{#if (eq mobileOTA "expo-updates")}}
+ useUpdateCheck();
+ {{/if}}
+ useEffect(() => {
+ {{#if (eq mobilePush "expo-notifications")}}
+ registerForPushNotificationsAsync().catch(console.warn);
+ {{/if}}
+ }, []);
const { theme } = useUnistyles();
return (
@@ -167,3 +183,4 @@ export default function RootLayout() {
{{/if}}
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs
index 18941b674..60e7a665d 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -31,3 +32,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
}));
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs b/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs
index 1df324146..cadf09302 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs
@@ -1,2 +1,9 @@
-import 'expo-router/entry';
import './unistyles';
+{{#if (eq mobileNavigation "react-navigation")}}
+import { registerRootComponent } from 'expo';
+import App from './App';
+
+registerRootComponent(App);
+{{else}}
+import 'expo-router/entry';
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs
index 6ceef5997..29a2badb2 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs
@@ -2,38 +2,64 @@
"name": "native",
"version": "1.0.0",
"private": true,
- "main": "index.js",
+ "main": "{{#if (eq mobileNavigation 'react-navigation')}}index.js{{else}}expo-router/entry{{/if}}",
"scripts": {
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
- "web": "expo start --web"
+ "web": "expo start --web"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}},
+ "test": "jest"
+ {{/if}}
},
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-navigation/bottom-tabs": "^7.16.1",
"@react-navigation/drawer": "^7.10.2",
"@react-navigation/native": "^7.2.4",
+ "@react-navigation/native-stack": "^7.8.1",
{{#if (includes examples "ai")}}
"@stardazed/streams-text-encoding": "^1.0.2",
"@ungap/structured-clone": "^1.3.1",
{{/if}}
"@tanstack/react-form": "^1.32.0",
+ {{#if (eq mobileUI "tamagui")}}
+ "@tamagui/config": "^2.0.0-rc.42",
+ "tamagui": "^2.0.0-rc.42",
+ {{/if}}
+ {{#if (eq mobileUI "gluestack-ui")}}
+ "@gluestack-ui/themed": "^1.1.73",
+ {{/if}}
"expo": "^55.0.24",
"expo-constants": "^55.0.16",
+ {{#if (eq mobilePush "expo-notifications")}}
+ "expo-device": "^8.0.9",
+ {{/if}}
"expo-crypto": "^55.0.15",
+ {{#if (eq mobileDeepLinking "expo-linking")}}
"expo-linking": "^55.0.15",
+ {{/if}}
+ {{#if (eq mobilePush "expo-notifications")}}
+ "expo-notifications": "^56.0.12",
+ {{/if}}
+ {{#if (eq mobileNavigation "expo-router")}}
"expo-router": "^55.0.14",
+ {{/if}}
"expo-secure-store": "^55.0.14",
"expo-splash-screen": "^55.0.21",
"expo-status-bar": "^55.0.6",
"expo-system-ui": "^55.0.18",
+ {{#if (eq mobileOTA "expo-updates")}}
+ "expo-updates": "^56.0.15",
+ {{/if}}
"expo-dev-client": "^55.0.34",
"expo-web-browser": "^55.0.16",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-native": "^0.85.3",
"react-native-edge-to-edge": "^1.8.1",
+ {{#if (eq mobileStorage "mmkv")}}
+ "react-native-mmkv": "^4.1.0",
+ {{/if}}
"react-native-gesture-handler": "^2.31.2",
"react-native-nitro-modules": "^0.35.6",
"react-native-reanimated": "^4.3.1",
@@ -46,6 +72,13 @@
"devDependencies": {
"ajv": "^8.20.0",
"@babel/core": "^7.29.0",
- "@types/react": "^19.2.14"
+ "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}},
+ "@types/jest": "^29.5.14",
+ "@testing-library/react-native": "^13.3.3",
+ "@react-native/jest-preset": "^0.85.3",
+ "jest": "^29.7.0",
+ "jest-expo": "^55.0.18",
+ "react-test-renderer": "^19.2.6"
+ {{/if}}
}
}
diff --git a/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs b/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs
index 4c58baac7..19d9d4b52 100644
--- a/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs
+++ b/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs
@@ -1,6 +1,7 @@
const sharedColors = {
success: "#22C55E",
destructive: "#EF4444",
+ destructiveForeground: "#FFFFFF",
warning: "#F59E0B",
info: "#3B82F6",
} as const;
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs
index a54d40243..1cdc0cd27 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs
@@ -1,15 +1,70 @@
{
"expo": {
+ "name": "{{projectName}}",
+ "slug": "{{projectName}}",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
"scheme": "{{projectName}}",
"userInterfaceStyle": "automatic",
- "orientation": "default",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.betterfullstack.app"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "backgroundColor": "#E6F4FE",
+ "foregroundImage": "./assets/images/android-icon-foreground.png",
+ "backgroundImage": "./assets/images/android-icon-background.png",
+ "monochromeImage": "./assets/images/android-icon-monochrome.png"
+ },
+ "edgeToEdgeEnabled": true,
+ "predictiveBackGestureEnabled": false,
+ "package": "com.betterfullstack.app"
+ },
"web": {
- "bundler": "metro"
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
},
- "name": "{{projectName}}",
- "slug": "{{projectName}}",
+ {{#if (eq mobileOTA "expo-updates")}}
+ "updates": {
+ "url": "https://u.expo.dev/your-eas-project-id"
+ },
+ "runtimeVersion": {
+ "policy": "appVersion"
+ },
+ "extra": {
+ "eas": {
+ "projectId": "your-eas-project-id"
+ }
+ },
+ {{/if}}
"plugins": [
- "expo-font"
+ {{#if (eq mobileNavigation "expo-router")}}
+ "expo-router",
+ {{/if}}
+ "expo-font",
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/images/splash-icon.png",
+ "imageWidth": 200,
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff",
+ "dark": {
+ "backgroundColor": "#000000"
+ }
+ }
+ ]{{#if (eq mobilePush "expo-notifications")}},
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/images/icon.png",
+ "color": "#111827"
+ }
+ ]
+ {{/if}}
],
"experiments": {
"typedRoutes": true,
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs
index 3c6918435..6e299e03d 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useThemeColor } from "heroui-native";
@@ -44,3 +45,4 @@ export default function TabLayout() {
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs
index e092affe3..4c54ad110 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { Text, View } from "react-native";
import { Card } from "heroui-native";
@@ -13,3 +14,4 @@ export default function Home() {
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs
index 01bc323f1..4ba4748f3 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Container } from "@/components/container";
import { Text, View } from "react-native";
import { Card } from "heroui-native";
@@ -13,3 +14,4 @@ export default function TabTwo() {
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs
index 8a8f947a8..4cf78054f 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import React, { useCallback } from "react";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
@@ -75,3 +76,4 @@ function DrawerLayout() {
}
export default DrawerLayout;
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs
index 5e64642da..d00b7367d 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Text, View } from "react-native";
import { Container } from "@/components/container";
{{#if (eq api "orpc")}}
@@ -27,7 +28,7 @@ import { api } from "@{{projectName}}/backend/convex/_generated/api";
{{#unless (or (eq backend "none") (and (eq backend "convex") (eq auth "better-auth")))}}
import { Ionicons } from "@expo/vector-icons";
{{/unless}}
-import { Button, Chip, Divider, Spinner, Surface, useThemeColor } from "heroui-native";
+import { Button, Chip, Spinner, Surface, useThemeColor } from "heroui-native";
export default function Home() {
{{#if (eq api "orpc")}}
@@ -82,7 +83,7 @@ return (
-
+
@@ -189,3 +190,4 @@ return (
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs
index ed78975f2..d07b8d4b1 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Link, Stack } from "expo-router";
import { Button, Surface } from "heroui-native";
import { Text, View } from "react-native";
@@ -25,3 +26,4 @@ export default function NotFoundScreen() {
>
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs
index 096cdf17f..bb53a3543 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
{{#if (includes examples "ai")}}
import "@/polyfills";
{{/if}}
@@ -28,8 +29,15 @@ import "@/global.css";
import { Stack } from "expo-router";
import { HeroUINativeProvider } from "heroui-native";
+import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
+{{#if (eq mobilePush "expo-notifications")}}
+import { registerForPushNotificationsAsync } from "@/lib/notifications";
+{{/if}}
+{{#if (eq mobileOTA "expo-updates")}}
+import { useUpdateCheck } from "@/lib/updates";
+{{/if}}
import { AppThemeProvider } from "@/contexts/app-theme-context";
{{#if (eq api "trpc")}}
@@ -62,6 +70,14 @@ function StackLayout() {
}
export default function Layout() {
+ {{#if (eq mobileOTA "expo-updates")}}
+ useUpdateCheck();
+ {{/if}}
+ useEffect(() => {
+ {{#if (eq mobilePush "expo-notifications")}}
+ registerForPushNotificationsAsync().catch(console.warn);
+ {{/if}}
+ }, []);
return (
{{#if (eq backend "convex")}}
{{#if (eq auth "clerk")}}
@@ -130,3 +146,4 @@ export default function Layout() {
{{/if}}
);
}
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs
index 868c280cb..40ccfb7d4 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs
@@ -1,3 +1,4 @@
+{{#if (eq mobileNavigation "expo-router")}}
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Button, Surface, useThemeColor } from "heroui-native";
@@ -35,3 +36,4 @@ function Modal() {
}
export default Modal;
+{{/if}}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs
index 16f8915a7..08a2b927d 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs
@@ -2,14 +2,16 @@
"name": "native",
"version": "1.0.0",
"private": true,
- "main": "expo-router/entry",
+ "main": "{{#if (eq mobileNavigation 'react-navigation')}}index.js{{else}}expo-router/entry{{/if}}",
"scripts": {
"start": "expo start",
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
- "web": "expo start --web"
+ "web": "expo start --web"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}},
+ "test": "jest"
+ {{/if}}
},
"dependencies": {
"@expo/metro-runtime": "^55.0.11",
@@ -17,23 +19,42 @@
"@gorhom/bottom-sheet": "^5",
"@react-navigation/drawer": "^7.10.2",
"@react-navigation/elements": "^2.9.18",
+ "@react-navigation/bottom-tabs": "^7.16.1",
+ "@react-navigation/native": "^7.2.4",
+ "@react-navigation/native-stack": "^7.8.1",
{{#if (includes examples "ai")}}
"@stardazed/streams-text-encoding": "^1.0.2",
"@ungap/structured-clone": "^1.3.1",
{{/if}}
"expo": "^55.0.24",
"expo-constants": "^55.0.16",
+ {{#if (eq mobilePush "expo-notifications")}}
+ "expo-device": "^8.0.9",
+ {{/if}}
"expo-font": "^55.0.7",
"expo-haptics": "^55.0.14",
+ {{#if (eq mobileDeepLinking "expo-linking")}}
"expo-linking": "^55.0.15",
+ {{/if}}
"expo-network": "^55.0.14",
+ {{#if (eq mobilePush "expo-notifications")}}
+ "expo-notifications": "^56.0.12",
+ {{/if}}
+ {{#if (eq mobileNavigation "expo-router")}}
"expo-router": "^55.0.14",
+ {{/if}}
"expo-secure-store": "^55.0.14",
"expo-status-bar": "^55.0.6",
+ {{#if (eq mobileOTA "expo-updates")}}
+ "expo-updates": "^56.0.15",
+ {{/if}}
"heroui-native": "^1.0.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-native": "^0.85.3",
+ {{#if (eq mobileStorage "mmkv")}}
+ "react-native-mmkv": "^4.1.0",
+ {{/if}}
"react-native-gesture-handler": "^2.31.2",
"react-native-keyboard-controller": "^1.21.7",
"react-native-reanimated": "^4.3.1",
@@ -49,6 +70,13 @@
},
"devDependencies": {
"@types/node": "^25.8.0",
- "@types/react": "^19.2.14"
+ "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}},
+ "@types/jest": "^29.5.14",
+ "@testing-library/react-native": "^13.3.3",
+ "@react-native/jest-preset": "^0.85.3",
+ "jest": "^29.7.0",
+ "jest-expo": "^55.0.18",
+ "react-test-renderer": "^19.2.6"
+ {{/if}}
}
}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs
index 6f9774af6..f889a00d0 100644
--- a/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs
+++ b/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs
@@ -2,12 +2,14 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
+ "jsx": "react-jsx",
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
- "**/*.tsx"
+ "**/*.tsx",
+ "uniwind-env.d.ts"
]
}
diff --git a/packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs b/packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs
new file mode 100644
index 000000000..71f7bd466
--- /dev/null
+++ b/packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs
@@ -0,0 +1,3 @@
+///
+
+declare module "*.css";
diff --git a/packages/template-generator/templates/packages/env/src/native.ts.hbs b/packages/template-generator/templates/packages/env/src/native.ts.hbs
index a1414e1f1..6eb673e24 100644
--- a/packages/template-generator/templates/packages/env/src/native.ts.hbs
+++ b/packages/template-generator/templates/packages/env/src/native.ts.hbs
@@ -14,6 +14,12 @@ export const env = createEnv({
{{/if}}
{{else}}
EXPO_PUBLIC_SERVER_URL: z.url(),
+{{/if}}
+{{#if (and (ne auth "none") (eq mobileDeepLinking "expo-linking"))}}
+ EXPO_PUBLIC_AUTH_REDIRECT_PATH: z.string().default("auth/callback"),
+{{/if}}
+{{#if (eq mobilePush "expo-notifications")}}
+ EXPO_PUBLIC_EAS_PROJECT_ID: z.string().optional(),
{{/if}}
},
runtimeEnv: process.env,
diff --git a/packages/template-generator/test/_fixtures/config-factory.ts b/packages/template-generator/test/_fixtures/config-factory.ts
index 7eff2139d..4d2a9b1ca 100644
--- a/packages/template-generator/test/_fixtures/config-factory.ts
+++ b/packages/template-generator/test/_fixtures/config-factory.ts
@@ -31,6 +31,13 @@ const DEFAULT_CONFIG = {
email: "none",
cssFramework: "tailwind",
uiLibrary: "none",
+ mobileNavigation: "none",
+ mobileUI: "none",
+ mobileStorage: "none",
+ mobileTesting: "none",
+ mobilePush: "none",
+ mobileOTA: "none",
+ mobileDeepLinking: "none",
shadcnBase: "radix",
shadcnStyle: "nova",
shadcnIconLibrary: "lucide",
@@ -83,6 +90,10 @@ const DEFAULT_CONFIG = {
javaAuth: "none",
javaLibraries: [],
javaTestingLibraries: [],
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: [],
+ elixirTesting: [],
aiDocs: [],
};
diff --git a/packages/template-generator/test/template-handlers/elixir-base.test.ts b/packages/template-generator/test/template-handlers/elixir-base.test.ts
new file mode 100644
index 000000000..330b02f78
--- /dev/null
+++ b/packages/template-generator/test/template-handlers/elixir-base.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "bun:test";
+
+import { VirtualFileSystem } from "../../src/core/virtual-fs";
+import { processElixirBaseTemplate } from "../../src/template-handlers/elixir-base";
+import { makeConfig } from "../_fixtures/config-factory";
+import { makeTemplates } from "../_fixtures/template-factory";
+
+const ELIXIR_TEMPLATES = {
+ "elixir-base/mix.exs.hbs":
+ "defmodule {{elixirModuleName}}.MixProject do {{#if isElixirPhoenix}}phoenix{{/if}}{{#if hasElixirJason}}jason{{/if}} end",
+ "elixir-base/config/config.exs.hbs": "config :{{elixirOtpApp}}",
+ "elixir-base/lib/__elixir_app_name__.ex.hbs": "defmodule {{elixirModuleName}} do end",
+ "elixir-base/lib/__elixir_app_name__/application.ex.hbs":
+ "defmodule {{elixirModuleName}}.Application do end",
+ "elixir-base/lib/__elixir_app_name__/worker.ex.hbs":
+ "defmodule {{elixirModuleName}}.Worker do end",
+ "elixir-base/lib/__elixir_app_name__/repo.ex.hbs":
+ "defmodule {{elixirModuleName}}.Repo do end",
+ "elixir-base/lib/__elixir_app_name__/web/router.ex.hbs":
+ "defmodule {{elixirModuleName}}Web.Router do end",
+ "elixir-base/test/test_helper.exs.hbs": "ExUnit.start()",
+ "elixir-base/test/__elixir_app_name___test.exs.hbs": "defmodule {{elixirModuleName}}Test do end",
+};
+
+describe("processElixirBaseTemplate", () => {
+ it("returns early when ecosystem is not elixir", async () => {
+ const vfs = new VirtualFileSystem();
+ await processElixirBaseTemplate(
+ vfs,
+ makeTemplates(ELIXIR_TEMPLATES),
+ makeConfig({ ecosystem: "typescript" }),
+ );
+ expect(vfs.getFileCount()).toBe(0);
+ });
+
+ it("emits a plain Mix / OTP scaffold when elixirWebFramework is none", async () => {
+ const vfs = new VirtualFileSystem();
+ await processElixirBaseTemplate(
+ vfs,
+ makeTemplates(ELIXIR_TEMPLATES),
+ makeConfig({
+ projectName: "my-elixir-app",
+ ecosystem: "elixir",
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: ["jason"],
+ elixirTesting: ["exunit"],
+ }),
+ );
+
+ expect(vfs.exists("mix.exs")).toBe(true);
+ expect(vfs.exists("lib/my_elixir_app.ex")).toBe(true);
+ expect(vfs.exists("lib/my_elixir_app/application.ex")).toBe(true);
+ expect(vfs.exists("lib/my_elixir_app/worker.ex")).toBe(true);
+ expect(vfs.exists("lib/my_elixir_app/web/router.ex")).toBe(false);
+ expect(vfs.exists("lib/my_elixir_app/repo.ex")).toBe(false);
+ expect(vfs.exists("test/my_elixir_app_test.exs")).toBe(true);
+ expect(vfs.readFile("mix.exs")).toContain("jason");
+ expect(vfs.readFile("lib/my_elixir_app.ex")).toContain("defmodule MyElixirApp");
+ });
+
+ it("adds Phoenix and Ecto-specific files when selected", async () => {
+ const vfs = new VirtualFileSystem();
+ await processElixirBaseTemplate(
+ vfs,
+ makeTemplates(ELIXIR_TEMPLATES),
+ makeConfig({
+ projectName: "phx-service",
+ ecosystem: "elixir",
+ elixirWebFramework: "phoenix",
+ elixirDatabase: "ecto",
+ elixirLibraries: ["jason", "telemetry"],
+ elixirTesting: [],
+ }),
+ );
+
+ expect(vfs.exists("mix.exs")).toBe(true);
+ expect(vfs.exists("lib/phx_service/repo.ex")).toBe(true);
+ expect(vfs.exists("lib/phx_service/web/router.ex")).toBe(true);
+ expect(vfs.exists("test/phx_service_test.exs")).toBe(false);
+ expect(vfs.readFile("mix.exs")).toContain("phoenix");
+ expect(vfs.readFile("lib/phx_service/web/router.ex")).toContain("defmodule PhxServiceWeb.Router");
+ });
+});
diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts
index d5810e3bb..688b2765e 100644
--- a/packages/types/src/compatibility.ts
+++ b/packages/types/src/compatibility.ts
@@ -51,6 +51,13 @@ export type CompatibilityCategory =
| "cms"
| "featureFlags"
| "analytics"
+ | "mobileNavigation"
+ | "mobileUI"
+ | "mobileStorage"
+ | "mobileTesting"
+ | "mobilePush"
+ | "mobileOTA"
+ | "mobileDeepLinking"
| "codeQuality"
| "documentation"
| "appPlatforms"
@@ -88,7 +95,11 @@ export type CompatibilityCategory =
| "javaOrm"
| "javaAuth"
| "javaLibraries"
- | "javaTestingLibraries";
+ | "javaTestingLibraries"
+ | "elixirWebFramework"
+ | "elixirDatabase"
+ | "elixirLibraries"
+ | "elixirTesting";
export type CompatibilityIssue = {
code: string;
@@ -109,7 +120,7 @@ export type CompatibilityAdjustment = {
};
export type CompatibilityInput = {
- ecosystem: "typescript" | "rust" | "python" | "go" | "java";
+ ecosystem: "typescript" | "react-native" | "rust" | "python" | "go" | "java" | "elixir";
projectName: string | null;
webFrontend: string[];
nativeFrontend: string[];
@@ -149,6 +160,13 @@ export type CompatibilityInput = {
i18n: string;
search: string;
fileStorage: string;
+ mobileNavigation: string;
+ mobileUI: string;
+ mobileStorage: string;
+ mobileTesting: string;
+ mobilePush: string;
+ mobileOTA: string;
+ mobileDeepLinking: string;
codeQuality: string[];
documentation: string[];
appPlatforms: string[];
@@ -194,6 +212,10 @@ export type CompatibilityInput = {
javaAuth: string;
javaLibraries: string[];
javaTestingLibraries: string[];
+ elixirWebFramework: string;
+ elixirDatabase: string;
+ elixirLibraries: string[];
+ elixirTesting: string[];
};
const TYPESCRIPT_CATEGORY_ORDER: CompatibilityCategory[] = [
@@ -230,6 +252,13 @@ const TYPESCRIPT_CATEGORY_ORDER: CompatibilityCategory[] = [
"i18n",
"search",
"fileStorage",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
"animation",
"cms",
"codeQuality",
@@ -272,6 +301,10 @@ const CATEGORY_ORDER: CompatibilityCategory[] = [
"javaAuth",
"javaLibraries",
"javaTestingLibraries",
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
];
const DEFAULT_RUNTIME = "bun";
@@ -385,6 +418,13 @@ export const getCategoryDisplayName = (categoryKey: string): string => {
javaTestingLibraries: "Java Testing Libraries",
};
+ const elixirCategoryNames: Record = {
+ elixirWebFramework: "Elixir Web Framework",
+ elixirDatabase: "Elixir Database",
+ elixirLibraries: "Elixir Libraries",
+ elixirTesting: "Elixir Testing",
+ };
+
if (rustCategoryNames[categoryKey]) {
return rustCategoryNames[categoryKey];
}
@@ -401,9 +441,20 @@ export const getCategoryDisplayName = (categoryKey: string): string => {
return javaCategoryNames[categoryKey];
}
+ if (elixirCategoryNames[categoryKey]) {
+ return elixirCategoryNames[categoryKey];
+ }
+
// Custom display names for TypeScript categories
const tsCategoryNames: Record = {
i18n: "Internationalization (i18n)",
+ mobileNavigation: "Mobile Navigation",
+ mobileUI: "Mobile UI",
+ mobileStorage: "Mobile Storage",
+ mobileTesting: "Mobile Testing",
+ mobilePush: "Mobile Push",
+ mobileOTA: "Mobile OTA",
+ mobileDeepLinking: "Mobile Deep Linking",
};
if (tsCategoryNames[categoryKey]) {
@@ -1072,6 +1123,139 @@ export const analyzeStackCompatibility = (
}
}
+ let hasNativeFrontend = nextStack.nativeFrontend.some((f) => f !== "none");
+
+ if (nextStack.ecosystem === "react-native") {
+ const reactNativeOnlyCategories = [
+ ["webFrontend", ["none"], "Web frontend set to 'None' (React Native ecosystem)"],
+ ["backend", "none", "Backend set to 'None' (React Native ecosystem)"],
+ ["runtime", "none", "Runtime set to 'None' (React Native ecosystem)"],
+ ["api", "none", "API set to 'None' (React Native ecosystem)"],
+ ["database", "none", "Database set to 'None' (React Native ecosystem)"],
+ ["orm", "none", "ORM set to 'None' (React Native ecosystem)"],
+ ["dbSetup", "none", "Database setup set to 'None' (React Native ecosystem)"],
+ ["webDeploy", "none", "Web deployment set to 'None' (React Native ecosystem)"],
+ ["serverDeploy", "none", "Server deployment set to 'None' (React Native ecosystem)"],
+ ["cssFramework", "none", "CSS framework set to 'None' (React Native ecosystem)"],
+ ["uiLibrary", "none", "UI library set to 'None' (React Native ecosystem)"],
+ ["testing", "none", "Web testing set to 'None' (React Native ecosystem)"],
+ ["forms", "none", "Web forms set to 'None' (React Native ecosystem)"],
+ ["stateManagement", "none", "Web state management set to 'None' (React Native ecosystem)"],
+ ["animation", "none", "Web animation set to 'None' (React Native ecosystem)"],
+ ["realtime", "none", "Realtime set to 'None' (React Native ecosystem)"],
+ ["jobQueue", "none", "Job queue set to 'None' (React Native ecosystem)"],
+ ["fileUpload", "none", "File upload set to 'None' (React Native ecosystem)"],
+ ["payments", "none", "Payments set to 'None' (React Native ecosystem)"],
+ ["email", "none", "Email set to 'None' (React Native ecosystem)"],
+ ["search", "none", "Search set to 'None' (React Native ecosystem)"],
+ ["fileStorage", "none", "File storage set to 'None' (React Native ecosystem)"],
+ ["cms", "none", "CMS set to 'None' (React Native ecosystem)"],
+ ["caching", "none", "Caching set to 'None' (React Native ecosystem)"],
+ ["i18n", "none", "i18n set to 'None' (React Native ecosystem)"],
+ ["featureFlags", "none", "Feature flags set to 'None' (React Native ecosystem)"],
+ ["analytics", "none", "Analytics set to 'None' (React Native ecosystem)"],
+ ["aiSdk", "none", "AI SDK set to 'None' (React Native ecosystem)"],
+ ["backendLibraries", "none", "Backend libraries set to 'None' (React Native ecosystem)"],
+ ["examples", [], "Examples cleared (React Native ecosystem)"],
+ ] as const;
+
+ for (const [category, value, message] of reactNativeOnlyCategories) {
+ const currentValue = nextStack[category];
+ const isSameArray =
+ Array.isArray(currentValue) &&
+ Array.isArray(value) &&
+ currentValue.length === value.length &&
+ currentValue.every((entry, index) => entry === value[index]);
+ if (Array.isArray(value) ? !isSameArray : currentValue !== value) {
+ (nextStack as Record)[category] = Array.isArray(value) ? [...value] : value;
+ changed = true;
+ changes.push({ category, message });
+ }
+ }
+
+ if (!hasNativeFrontend) {
+ nextStack.nativeFrontend = ["native-bare"];
+ hasNativeFrontend = true;
+ changed = true;
+ changes.push({
+ category: "nativeFrontend",
+ message: "Native frontend set to 'Expo + Bare' (React Native ecosystem)",
+ });
+ }
+ }
+
+ if (
+ nextStack.ecosystem === "typescript" &&
+ nextStack.nativeFrontend.some((frontend) => frontend !== "none")
+ ) {
+ nextStack.nativeFrontend = ["none"];
+ hasNativeFrontend = false;
+ changed = true;
+ changes.push({
+ category: "nativeFrontend",
+ message: "Native frontend set to 'None' (use React Native ecosystem for Expo apps)",
+ });
+ }
+
+ if (!hasNativeFrontend) {
+ const nativeOnlyCategories = [
+ ["mobileNavigation", "none", "Mobile navigation set to 'None' (no native frontend)"],
+ ["mobileUI", "none", "Mobile UI set to 'None' (no native frontend)"],
+ ["mobileStorage", "none", "Mobile storage set to 'None' (no native frontend)"],
+ ["mobileTesting", "none", "Mobile testing set to 'None' (no native frontend)"],
+ ["mobilePush", "none", "Mobile push set to 'None' (no native frontend)"],
+ ["mobileOTA", "none", "Mobile OTA set to 'None' (no native frontend)"],
+ ["mobileDeepLinking", "none", "Mobile deep linking set to 'None' (no native frontend)"],
+ ] as const;
+
+ for (const [category, value, message] of nativeOnlyCategories) {
+ if (nextStack[category] !== value) {
+ nextStack[category] = value;
+ changed = true;
+ changes.push({ category, message });
+ }
+ }
+ } else {
+ if (nextStack.mobileNavigation === "none") {
+ nextStack.mobileNavigation = "expo-router";
+ changed = true;
+ changes.push({
+ category: "mobileNavigation",
+ message: "Mobile navigation set to 'Expo Router' (native frontend selected)",
+ });
+ }
+
+ if (nextStack.mobileDeepLinking === "none" && nextStack.auth !== "none") {
+ nextStack.mobileDeepLinking = "expo-linking";
+ changed = true;
+ changes.push({
+ category: "mobileDeepLinking",
+ message: "Mobile deep linking set to 'Expo Linking' (required for mobile auth redirects)",
+ });
+ }
+
+ if (nextStack.nativeFrontend.includes("native-uniwind") && nextStack.mobileUI !== "uniwind") {
+ nextStack.mobileUI = "uniwind";
+ changed = true;
+ changes.push({
+ category: "mobileUI",
+ message: "Mobile UI set to 'Uniwind' (required by Expo + Uniwind)",
+ });
+ }
+
+ if (
+ nextStack.nativeFrontend.includes("native-unistyles") &&
+ nextStack.mobileUI !== "unistyles"
+ ) {
+ nextStack.mobileUI = "unistyles";
+ changed = true;
+ changes.push({
+ category: "mobileUI",
+ message: "Mobile UI set to 'Unistyles' (required by Expo + Unistyles)",
+ });
+ }
+ }
+
// UI libraries requiring Tailwind - auto-adjust CSS framework or clear UI library
const requiresTailwind = ["shadcn-ui", "daisyui", "nextui"].includes(nextStack.uiLibrary);
if (requiresTailwind && nextStack.cssFramework !== "tailwind") {
@@ -1494,6 +1678,28 @@ export const getDisabledReason = (
}
}
+ if (currentStack.ecosystem === "react-native") {
+ const reactNativeCategories = new Set([
+ "nativeFrontend",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
+ "auth",
+ "packageManager",
+ "aiDocs",
+ "git",
+ "install",
+ ]);
+
+ if (!reactNativeCategories.has(category) && optionId !== "none" && optionId !== "false") {
+ return "React Native ecosystem only supports native mobile options";
+ }
+ }
+
// ============================================
// NO BACKEND - locks down backend-dependent options
// ============================================
@@ -2115,6 +2321,50 @@ export const getDisabledReason = (
}
}
+ const mobileCategories = new Set([
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
+ ]);
+
+ if (mobileCategories.has(category)) {
+ const hasNativeFrontend = currentStack.nativeFrontend.some((f) => f !== "none");
+ if (!hasNativeFrontend && optionId !== "none") {
+ return `${getCategoryDisplayName(category)} requires a native Expo frontend`;
+ }
+
+ if (category === "mobileNavigation" && optionId === "react-navigation") {
+ return null;
+ }
+
+ if (category === "mobileUI") {
+ if (optionId === "uniwind" && !currentStack.nativeFrontend.includes("native-uniwind")) {
+ return "Uniwind mobile UI requires Expo + Uniwind frontend";
+ }
+ if (optionId === "unistyles" && !currentStack.nativeFrontend.includes("native-unistyles")) {
+ return "Unistyles mobile UI requires Expo + Unistyles frontend";
+ }
+ if (
+ ["tamagui", "gluestack-ui"].includes(optionId) &&
+ currentStack.nativeFrontend.some((f) => ["native-uniwind", "native-unistyles"].includes(f))
+ ) {
+ return "Tamagui and Gluestack UI require Expo + Bare to avoid conflicting styling setup";
+ }
+ }
+
+ if (
+ (category === "mobilePush" && optionId === "expo-notifications") ||
+ (category === "mobileOTA" && optionId === "expo-updates") ||
+ (category === "mobileDeepLinking" && optionId === "expo-linking")
+ ) {
+ return null;
+ }
+ }
+
// ============================================
// UI LIBRARY CONSTRAINTS
// ============================================
@@ -2953,10 +3203,12 @@ export function evaluateCompatibility(input: CompatibilityInput): CompatibilityE
["stateManagement", input.stateManagement],
["animation", input.animation],
["pythonApi", input.pythonApi],
- ["javaWebFramework", input.javaWebFramework],
+ ["javaWebFramework", input.javaWebFramework],
["javaBuildTool", input.javaBuildTool],
["javaOrm", input.javaOrm],
["javaAuth", input.javaAuth],
+ ["elixirWebFramework", input.elixirWebFramework],
+ ["elixirDatabase", input.elixirDatabase],
];
for (const [category, optionId] of scalarChecks) {
diff --git a/packages/types/src/defaults.ts b/packages/types/src/defaults.ts
index 66c89ccbe..0babbc757 100644
--- a/packages/types/src/defaults.ts
+++ b/packages/types/src/defaults.ts
@@ -51,6 +51,13 @@ export function createCliDefaultProjectConfigBase(
observability: "none",
featureFlags: "none",
analytics: "none",
+ mobileNavigation: "none",
+ mobileUI: "none",
+ mobileStorage: "none",
+ mobileTesting: "none",
+ mobilePush: "none",
+ mobileOTA: "none",
+ mobileDeepLinking: "none",
cms: "none",
caching: "none",
i18n: "none",
@@ -87,6 +94,10 @@ export function createCliDefaultProjectConfigBase(
javaAuth: "none",
javaLibraries: [],
javaTestingLibraries: ["junit5"],
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: ["jason"],
+ elixirTesting: ["exunit"],
aiDocs: ["claude-md"],
};
}
diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts
index cf536474e..18761829c 100644
--- a/packages/types/src/option-metadata.ts
+++ b/packages/types/src/option-metadata.ts
@@ -15,10 +15,21 @@ import {
DATABASE_VALUES,
EFFECT_VALUES,
EMAIL_VALUES,
+ ELIXIR_DATABASE_VALUES,
+ ELIXIR_LIBRARIES_VALUES,
+ ELIXIR_TESTING_VALUES,
+ ELIXIR_WEB_FRAMEWORK_VALUES,
FEATURE_FLAGS_VALUES,
FILE_STORAGE_VALUES,
FILE_UPLOAD_VALUES,
FORMS_VALUES,
+ MOBILE_DEEP_LINKING_VALUES,
+ MOBILE_NAVIGATION_VALUES,
+ MOBILE_OTA_VALUES,
+ MOBILE_PUSH_VALUES,
+ MOBILE_STORAGE_VALUES,
+ MOBILE_TESTING_VALUES,
+ MOBILE_UI_VALUES,
GO_API_VALUES,
GO_CLI_VALUES,
GO_AUTH_VALUES,
@@ -110,6 +121,13 @@ export type OptionCategory =
| "cms"
| "featureFlags"
| "analytics"
+ | "mobileNavigation"
+ | "mobileUI"
+ | "mobileStorage"
+ | "mobileTesting"
+ | "mobilePush"
+ | "mobileOTA"
+ | "mobileDeepLinking"
| "codeQuality"
| "documentation"
| "appPlatforms"
@@ -158,7 +176,11 @@ export type OptionCategory =
| "javaOrm"
| "javaAuth"
| "javaLibraries"
- | "javaTestingLibraries";
+ | "javaTestingLibraries"
+ | "elixirWebFramework"
+ | "elixirDatabase"
+ | "elixirLibraries"
+ | "elixirTesting";
export type OptionSelectionMode = "single" | "multiple";
@@ -265,6 +287,8 @@ const MULTI_SELECT_CATEGORIES = new Set([
"pythonAi",
"javaLibraries",
"javaTestingLibraries",
+ "elixirLibraries",
+ "elixirTesting",
]);
const CATEGORY_VALUE_IDS: Record = {
@@ -302,6 +326,13 @@ const CATEGORY_VALUE_IDS: Record = {
cms: CMS_VALUES,
featureFlags: FEATURE_FLAGS_VALUES,
analytics: ANALYTICS_VALUES,
+ mobileNavigation: MOBILE_NAVIGATION_VALUES,
+ mobileUI: MOBILE_UI_VALUES,
+ mobileStorage: MOBILE_STORAGE_VALUES,
+ mobileTesting: MOBILE_TESTING_VALUES,
+ mobilePush: MOBILE_PUSH_VALUES,
+ mobileOTA: MOBILE_OTA_VALUES,
+ mobileDeepLinking: MOBILE_DEEP_LINKING_VALUES,
codeQuality: CODE_QUALITY_VALUES,
documentation: DOCUMENTATION_VALUES,
appPlatforms: APP_PLATFORM_VALUES,
@@ -351,6 +382,10 @@ const CATEGORY_VALUE_IDS: Record = {
javaAuth: JAVA_AUTH_VALUES,
javaLibraries: JAVA_LIBRARIES_VALUES,
javaTestingLibraries: JAVA_TESTING_LIBRARIES_VALUES,
+ elixirWebFramework: ELIXIR_WEB_FRAMEWORK_VALUES,
+ elixirDatabase: ELIXIR_DATABASE_VALUES,
+ elixirLibraries: ELIXIR_LIBRARIES_VALUES,
+ elixirTesting: ELIXIR_TESTING_VALUES,
};
const EXACT_LABEL_OVERRIDES: Partial>>> = {
@@ -480,6 +515,33 @@ const EXACT_LABEL_OVERRIDES: Partial>>> =
@@ -871,6 +952,13 @@ export const OPTION_CATEGORY_METADATA: Record;
export const STACK_SELECTION_KEYS = Object.keys(
@@ -514,6 +549,13 @@ const CLI_SCALAR_CONFIG_FIELDS = [
["search", "search"],
["fileStorage", "fileStorage"],
["analytics", "analytics"],
+ ["mobileNavigation", "mobileNavigation"],
+ ["mobileUI", "mobileUI"],
+ ["mobileStorage", "mobileStorage"],
+ ["mobileTesting", "mobileTesting"],
+ ["mobilePush", "mobilePush"],
+ ["mobileOTA", "mobileOTA"],
+ ["mobileDeepLinking", "mobileDeepLinking"],
["featureFlags", "featureFlags"],
["fileUpload", "fileUpload"],
["git", "git"],
@@ -561,6 +603,8 @@ const CLI_SCALAR_CONFIG_FIELDS = [
["javaBuildTool", "javaBuildTool"],
["javaOrm", "javaOrm"],
["javaAuth", "javaAuth"],
+ ["elixirWebFramework", "elixirWebFramework"],
+ ["elixirDatabase", "elixirDatabase"],
] as const satisfies readonly (readonly [keyof CLIInput, keyof ProjectConfig])[];
const CLI_NON_EMPTY_ARRAY_CONFIG_FIELDS = [
@@ -575,6 +619,8 @@ const CLI_DEFINED_ARRAY_CONFIG_FIELDS = [
["pythonAi", "pythonAi"],
["javaLibraries", "javaLibraries"],
["javaTestingLibraries", "javaTestingLibraries"],
+ ["elixirLibraries", "elixirLibraries"],
+ ["elixirTesting", "elixirTesting"],
] as const satisfies readonly (readonly [keyof CLIInput, keyof ProjectConfig])[];
const PACKAGE_MANAGER_COMMANDS = {
@@ -637,6 +683,23 @@ const JAVA_CONFIG_KEYS = [
"javaTestingLibraries",
] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[];
+const ELIXIR_CONFIG_KEYS = [
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
+] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[];
+
+const REACT_NATIVE_CONFIG_KEYS = [
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
+] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[];
+
const COMMAND_ADDONS = new Set([
"pwa",
"tauri",
@@ -837,6 +900,13 @@ function buildProjectConfigBase(
observability: stack.observability as ProjectConfig["observability"],
featureFlags: stack.featureFlags as ProjectConfig["featureFlags"],
analytics: stack.analytics as ProjectConfig["analytics"],
+ mobileNavigation: stack.mobileNavigation as ProjectConfig["mobileNavigation"],
+ mobileUI: stack.mobileUI as ProjectConfig["mobileUI"],
+ mobileStorage: stack.mobileStorage as ProjectConfig["mobileStorage"],
+ mobileTesting: stack.mobileTesting as ProjectConfig["mobileTesting"],
+ mobilePush: stack.mobilePush as ProjectConfig["mobilePush"],
+ mobileOTA: stack.mobileOTA as ProjectConfig["mobileOTA"],
+ mobileDeepLinking: stack.mobileDeepLinking as ProjectConfig["mobileDeepLinking"],
cms: stack.cms as ProjectConfig["cms"],
caching: stack.caching as ProjectConfig["caching"],
i18n: stack.i18n as ProjectConfig["i18n"],
@@ -875,6 +945,12 @@ function buildProjectConfigBase(
javaTestingLibraries: toUniqueNonNoneArray(
stack.javaTestingLibraries,
) as ProjectConfig["javaTestingLibraries"],
+ elixirWebFramework: stack.elixirWebFramework as ProjectConfig["elixirWebFramework"],
+ elixirDatabase: stack.elixirDatabase as ProjectConfig["elixirDatabase"],
+ elixirLibraries: toUniqueNonNoneArray(
+ stack.elixirLibraries,
+ ) as ProjectConfig["elixirLibraries"],
+ elixirTesting: toUniqueNonNoneArray(stack.elixirTesting) as ProjectConfig["elixirTesting"],
aiDocs: toUniqueNonNoneArray(stack.aiDocs) as ProjectConfig["aiDocs"],
};
}
@@ -917,12 +993,14 @@ export function isCliDefaultStackSelection(
relativePath: projectName,
};
const ignoredKeys =
- selection.ecosystem === "typescript"
+ selection.ecosystem === "typescript" || selection.ecosystem === "react-native"
? new Set([
...RUST_CONFIG_KEYS,
...PYTHON_CONFIG_KEYS,
...GO_CONFIG_KEYS,
...JAVA_CONFIG_KEYS,
+ ...ELIXIR_CONFIG_KEYS,
+ ...(selection.ecosystem === "typescript" ? REACT_NATIVE_CONFIG_KEYS : []),
])
: new Set();
@@ -1029,6 +1107,35 @@ function generateTypeScriptCommand(selection: StackSelectionInput, projectName:
return `${base} ${projectName} ${flags.join(" ")}`;
}
+function generateReactNativeCommand(selection: StackSelectionInput, projectName: string) {
+ const flags = [
+ "--ecosystem react-native",
+ `--frontend ${
+ selection.nativeFrontend
+ .filter((value, _, values) => value !== "none" || values.length === 1)
+ .join(" ") || "native-bare"
+ }`,
+ `--auth ${selection.auth}`,
+ `--mobile-navigation ${selection.mobileNavigation}`,
+ `--mobile-ui ${selection.mobileUI}`,
+ `--mobile-storage ${selection.mobileStorage}`,
+ `--mobile-testing ${selection.mobileTesting}`,
+ `--mobile-push ${selection.mobilePush}`,
+ `--mobile-ota ${selection.mobileOTA}`,
+ `--mobile-deep-linking ${selection.mobileDeepLinking}`,
+ `--package-manager ${selection.packageManager}`,
+ selection.git === "false" ? "--no-git" : "--git",
+ selection.install === "false" ? "--no-install" : "--install",
+ formatArrayFlag("ai-docs", selection.aiDocs),
+ ];
+
+ if (selection.yolo === "true") {
+ flags.push("--yolo");
+ }
+
+ return `${getBaseCommand(selection)} ${projectName} ${flags.join(" ")}`;
+}
+
function generateRustCommand(selection: StackSelectionInput, projectName: string) {
const flags: string[] = [
"--ecosystem rust",
@@ -1125,10 +1232,28 @@ function generateJavaCommand(selection: StackSelectionInput, projectName: string
return `${getBaseCommand(selection)} ${projectName} ${flags.join(" ")}`;
}
+function generateElixirCommand(selection: StackSelectionInput, projectName: string) {
+ const flags: string[] = [
+ "--ecosystem elixir",
+ `--elixir-web-framework ${selection.elixirWebFramework}`,
+ `--elixir-database ${selection.elixirDatabase}`,
+ formatArrayFlag("elixir-libraries", selection.elixirLibraries),
+ formatArrayFlag("elixir-testing", selection.elixirTesting),
+ formatArrayFlag("ai-docs", selection.aiDocs),
+ ];
+
+ if (selection.git === "false") flags.push("--no-git");
+ if (selection.install === "false") flags.push("--no-install");
+
+ return `${getBaseCommand(selection)} ${projectName} ${flags.join(" ")}`;
+}
+
export function generateStackSelectionCommand(selection: StackSelectionInput): string {
const projectName = getProjectName(selection);
switch (selection.ecosystem) {
+ case "react-native":
+ return generateReactNativeCommand(selection, projectName);
case "rust":
return generateRustCommand(selection, projectName);
case "python":
@@ -1137,6 +1262,8 @@ export function generateStackSelectionCommand(selection: StackSelectionInput): s
return generateGoCommand(selection, projectName);
case "java":
return generateJavaCommand(selection, projectName);
+ case "elixir":
+ return generateElixirCommand(selection, projectName);
case "typescript":
default:
return generateTypeScriptCommand(selection, projectName);
diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts
index 683992af5..7e8e28a8a 100644
--- a/packages/types/src/types.ts
+++ b/packages/types/src/types.ts
@@ -43,6 +43,13 @@ import type {
ObservabilitySchema,
FeatureFlagsSchema,
AnalyticsSchema,
+ MobileNavigationSchema,
+ MobileUISchema,
+ MobileStorageSchema,
+ MobileTestingSchema,
+ MobilePushSchema,
+ MobileOTASchema,
+ MobileDeepLinkingSchema,
CMSSchema,
CachingSchema,
I18nSchema,
@@ -80,6 +87,10 @@ import type {
JavaAuthSchema,
JavaLibrariesSchema,
JavaTestingLibrariesSchema,
+ ElixirWebFrameworkSchema,
+ ElixirDatabaseSchema,
+ ElixirLibrariesSchema,
+ ElixirTestingSchema,
AiDocsSchema,
ShadcnBaseSchema,
ShadcnStyleSchema,
@@ -127,6 +138,13 @@ export type Logging = z.infer;
export type Observability = z.infer;
export type FeatureFlags = z.infer;
export type Analytics = z.infer;
+export type MobileNavigation = z.infer;
+export type MobileUI = z.infer;
+export type MobileStorage = z.infer;
+export type MobileTesting = z.infer;
+export type MobilePush = z.infer;
+export type MobileOTA = z.infer;
+export type MobileDeepLinking = z.infer;
export type CMS = z.infer;
export type Caching = z.infer;
export type I18n = z.infer;
@@ -164,6 +182,10 @@ export type JavaOrm = z.infer;
export type JavaAuth = z.infer;
export type JavaLibraries = z.infer;
export type JavaTestingLibraries = z.infer;
+export type ElixirWebFramework = z.infer;
+export type ElixirDatabase = z.infer;
+export type ElixirLibraries = z.infer;
+export type ElixirTesting = z.infer;
export type AiDocs = z.infer;
export type ShadcnBase = z.infer;
export type ShadcnStyle = z.infer;
diff --git a/testing/lib/generate-combos/fingerprint.ts b/testing/lib/generate-combos/fingerprint.ts
index 67d9c760e..6b8b3963b 100644
--- a/testing/lib/generate-combos/fingerprint.ts
+++ b/testing/lib/generate-combos/fingerprint.ts
@@ -17,6 +17,8 @@ const ARRAY_OPTION_KEYS = new Set([
"pythonAi",
"javaLibraries",
"javaTestingLibraries",
+ "elixirLibraries",
+ "elixirTesting",
"aiDocs",
] as const);
diff --git a/testing/lib/generate-combos/options.test.ts b/testing/lib/generate-combos/options.test.ts
new file mode 100644
index 000000000..7d07044cd
--- /dev/null
+++ b/testing/lib/generate-combos/options.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "bun:test";
+
+import { generateBatch } from "./options";
+import { createSeededRandom, seedFromString } from "./seed-random";
+
+describe("smoke combo generation", () => {
+ it("keeps native frontends in the React Native ecosystem", () => {
+ const combos = generateBatch(
+ {
+ count: 24,
+ ecosystems: ["typescript", "react-native"],
+ installMode: "no-install",
+ rng: createSeededRandom(seedFromString("react-native-ecosystem-split")),
+ },
+ {
+ fingerprintKeys: new Set(),
+ legacyNames: new Set(),
+ historyCount: 0,
+ },
+ );
+
+ const nativeFrontend = (frontend: string) => frontend.startsWith("native-");
+
+ for (const combo of combos) {
+ if (combo.ecosystem === "typescript") {
+ expect(combo.config.frontend.some(nativeFrontend)).toBe(false);
+ }
+
+ if (combo.config.frontend.some(nativeFrontend)) {
+ expect(combo.ecosystem).toBe("react-native");
+ }
+ }
+ });
+});
diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts
index 6068c68b7..fd4e9b5ff 100644
--- a/testing/lib/generate-combos/options.ts
+++ b/testing/lib/generate-combos/options.ts
@@ -15,6 +15,10 @@ import {
DATABASE_VALUES,
EFFECT_VALUES,
EMAIL_VALUES,
+ ELIXIR_DATABASE_VALUES,
+ ELIXIR_LIBRARIES_VALUES,
+ ELIXIR_TESTING_VALUES,
+ ELIXIR_WEB_FRAMEWORK_VALUES,
EXAMPLES_VALUES,
FILE_STORAGE_VALUES,
FILE_UPLOAD_VALUES,
@@ -33,6 +37,13 @@ import {
JAVA_WEB_FRAMEWORK_VALUES,
JOB_QUEUE_VALUES,
LOGGING_VALUES,
+ MOBILE_DEEP_LINKING_VALUES,
+ MOBILE_NAVIGATION_VALUES,
+ MOBILE_OTA_VALUES,
+ MOBILE_PUSH_VALUES,
+ MOBILE_STORAGE_VALUES,
+ MOBILE_TESTING_VALUES,
+ MOBILE_UI_VALUES,
OBSERVABILITY_VALUES,
ORM_VALUES,
PAYMENTS_VALUES,
@@ -179,11 +190,6 @@ function sampleTypeScriptFrontends(): CLIInput["frontend"] {
picked.push(web);
}
- const native = sampleScalar(NATIVE_FRONTENDS, picked.length === 0 ? 0.85 : 0.95);
- if (native !== "none") {
- picked.push(native);
- }
-
if (picked.length === 0 && _rng() > 0.25) {
picked.push(sampleOne(WEB_FRONTENDS.filter((value) => value !== "none")));
}
@@ -316,6 +322,41 @@ function makeTypeScriptDraft(args: GeneratorArgs): CandidateDraft {
};
}
+function makeReactNativeDraft(args: GeneratorArgs): CandidateDraft {
+ const frontend = [sampleOne(NATIVE_FRONTENDS.filter((value) => value !== "none"))];
+ const mobileUI = frontend.includes("native-uniwind")
+ ? "uniwind"
+ : frontend.includes("native-unistyles")
+ ? "unistyles"
+ : sampleScalar(MOBILE_UI_VALUES, 0.55, "mobileUI");
+
+ return {
+ ecosystem: "react-native",
+ options: {
+ ...createCommonOptions("react-native", args),
+ frontend,
+ backend: "none",
+ runtime: "none",
+ api: "none",
+ database: "none",
+ orm: "none",
+ dbSetup: "none",
+ auth: "none",
+ cssFramework: "none",
+ uiLibrary: "none",
+ forms: "none",
+ testing: "none",
+ mobileNavigation: sampleScalar(MOBILE_NAVIGATION_VALUES, 0.05, "mobileNavigation"),
+ mobileUI,
+ mobileStorage: sampleScalar(MOBILE_STORAGE_VALUES, 0.55, "mobileStorage"),
+ mobileTesting: sampleScalar(MOBILE_TESTING_VALUES, 0.45, "mobileTesting"),
+ mobilePush: sampleScalar(MOBILE_PUSH_VALUES, 0.7, "mobilePush"),
+ mobileOTA: sampleScalar(MOBILE_OTA_VALUES, 0.7, "mobileOTA"),
+ mobileDeepLinking: sampleScalar(MOBILE_DEEP_LINKING_VALUES, 0.35, "mobileDeepLinking"),
+ },
+ };
+}
+
function makeRustDraft(args: GeneratorArgs): CandidateDraft {
return {
ecosystem: "rust",
@@ -425,6 +466,56 @@ function makeJavaDraft(args: GeneratorArgs): CandidateDraft {
};
}
+function makeElixirDraft(args: GeneratorArgs): CandidateDraft {
+ const elixirWebFramework = sampleScalar(
+ ELIXIR_WEB_FRAMEWORK_VALUES,
+ 0.45,
+ "elixirWebFramework",
+ );
+ const elixirDatabase =
+ elixirWebFramework === "phoenix"
+ ? sampleScalar(ELIXIR_DATABASE_VALUES, 0.25, "elixirDatabase")
+ : sampleScalar(ELIXIR_DATABASE_VALUES, 0.55, "elixirDatabase");
+ const elixirLibraries = sampleArray(
+ ELIXIR_LIBRARIES_VALUES,
+ elixirWebFramework === "phoenix" ? 0.25 : 0.35,
+ 3,
+ "elixirLibraries",
+ );
+
+ if (elixirLibraries.includes("oban") && elixirDatabase === "none") {
+ return {
+ ecosystem: "elixir",
+ options: {
+ ...createCommonOptions("elixir", args),
+ elixirWebFramework,
+ elixirDatabase: "ecto",
+ elixirLibraries,
+ elixirTesting: sampleArray(ELIXIR_TESTING_VALUES, 0.15, 2, "elixirTesting"),
+ email: sampleScalar(CROSS_ECOSYSTEM_EMAIL_VALUES, 0.75, "email"),
+ observability: sampleScalar(CROSS_ECOSYSTEM_OBSERVABILITY_VALUES, 0.75, "observability"),
+ caching: sampleScalar(CROSS_ECOSYSTEM_CACHING_VALUES, 0.75, "caching"),
+ search: sampleScalar(CROSS_ECOSYSTEM_SEARCH_VALUES, 0.75, "search"),
+ },
+ };
+ }
+
+ return {
+ ecosystem: "elixir",
+ options: {
+ ...createCommonOptions("elixir", args),
+ elixirWebFramework,
+ elixirDatabase,
+ elixirLibraries,
+ elixirTesting: sampleArray(ELIXIR_TESTING_VALUES, 0.15, 2, "elixirTesting"),
+ email: sampleScalar(CROSS_ECOSYSTEM_EMAIL_VALUES, 0.75, "email"),
+ observability: sampleScalar(CROSS_ECOSYSTEM_OBSERVABILITY_VALUES, 0.75, "observability"),
+ caching: sampleScalar(CROSS_ECOSYSTEM_CACHING_VALUES, 0.75, "caching"),
+ search: sampleScalar(CROSS_ECOSYSTEM_SEARCH_VALUES, 0.75, "search"),
+ },
+ };
+}
+
function buildProvidedFlags(options: CLIInput): Set {
const providedFlags = new Set();
@@ -452,7 +543,12 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje
projectDir: path.resolve(process.cwd(), projectName),
relativePath: projectName,
ecosystem: draft.ecosystem,
- frontend: draft.ecosystem === "typescript" ? getDefaultConfig().frontend : [],
+ frontend:
+ draft.ecosystem === "typescript"
+ ? getDefaultConfig().frontend
+ : draft.ecosystem === "react-native"
+ ? ["native-bare"]
+ : [],
backend: "none",
runtime: "none",
database: "none",
@@ -526,6 +622,10 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje
javaAuth: "none",
javaLibraries: [],
javaTestingLibraries: [],
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: [],
+ elixirTesting: [],
aiDocs: [],
packageManager: "bun",
git: false,
@@ -533,6 +633,42 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje
};
}
+function applyDerivedMobileDefaults(config: ProjectConfig, providedFlags: Set) {
+ if (config.ecosystem !== "typescript" && config.ecosystem !== "react-native") return;
+
+ const hasNativeFrontend = config.frontend.some((frontend) => frontend.startsWith("native-"));
+ if (!hasNativeFrontend) {
+ config.mobileNavigation = "none";
+ config.mobileUI = "none";
+ config.mobileStorage = "none";
+ config.mobileTesting = "none";
+ config.mobilePush = "none";
+ config.mobileOTA = "none";
+ config.mobileDeepLinking = "none";
+ return;
+ }
+
+ if (!providedFlags.has("mobileNavigation") && config.mobileNavigation === "none") {
+ config.mobileNavigation = "expo-router";
+ }
+
+ if (!providedFlags.has("mobileUI")) {
+ if (config.frontend.includes("native-uniwind")) {
+ config.mobileUI = "uniwind";
+ } else if (config.frontend.includes("native-unistyles")) {
+ config.mobileUI = "unistyles";
+ }
+ }
+
+ if (
+ !providedFlags.has("mobileDeepLinking") &&
+ config.mobileDeepLinking === "none" &&
+ config.auth !== "none"
+ ) {
+ config.mobileDeepLinking = "expo-linking";
+ }
+}
+
function validateDraft(draft: CandidateDraft, projectName: string): ProjectConfig {
const providedFlags = buildProvidedFlags(draft.options);
const processed = processFlags({ ...draft.options, projectName } as CLIInput, projectName);
@@ -544,6 +680,8 @@ function validateDraft(draft: CandidateDraft, projectName: string): ProjectConfi
projectDir: path.resolve(process.cwd(), projectName),
} as ProjectConfig;
+ applyDerivedMobileDefaults(config, providedFlags);
+
runWithContext({ silent: true }, () => {
validateFullConfig(config, providedFlags, { ...draft.options, projectName } as CLIInput);
});
@@ -584,6 +722,8 @@ function createDraft(ecosystem: Ecosystem, args: GeneratorArgs): CandidateDraft
switch (ecosystem) {
case "typescript":
return makeTypeScriptDraft(args);
+ case "react-native":
+ return makeReactNativeDraft(args);
case "rust":
return makeRustDraft(args);
case "python":
@@ -592,6 +732,8 @@ function createDraft(ecosystem: Ecosystem, args: GeneratorArgs): CandidateDraft
return makeGoDraft(args);
case "java":
return makeJavaDraft(args);
+ case "elixir":
+ return makeElixirDraft(args);
}
}
diff --git a/testing/lib/generate-combos/render.test.ts b/testing/lib/generate-combos/render.test.ts
new file mode 100644
index 000000000..f81174153
--- /dev/null
+++ b/testing/lib/generate-combos/render.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "bun:test";
+import { createCliDefaultProjectConfigBase, type ProjectConfig } from "@better-fullstack/types";
+
+import { buildCommand } from "./render";
+
+describe("smoke combo command rendering", () => {
+ it("includes mobile flags for React Native commands", () => {
+ const config: ProjectConfig = {
+ ...createCliDefaultProjectConfigBase("bun"),
+ projectName: "mobile-smoke",
+ relativePath: "mobile-smoke",
+ projectDir: "/tmp/mobile-smoke",
+ ecosystem: "react-native",
+ frontend: ["native-unistyles"],
+ mobileNavigation: "expo-router",
+ mobileUI: "unistyles",
+ mobileStorage: "none",
+ mobileTesting: "none",
+ mobilePush: "none",
+ mobileOTA: "none",
+ mobileDeepLinking: "none",
+ git: false,
+ install: false,
+ };
+
+ expect(buildCommand("mobile-smoke", config)).toContain(
+ "--mobile-navigation expo-router --mobile-ui unistyles --mobile-storage none --mobile-testing none --mobile-push none --mobile-ota none --mobile-deep-linking none",
+ );
+ });
+});
diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts
index b4826c475..29de7722a 100644
--- a/testing/lib/generate-combos/render.ts
+++ b/testing/lib/generate-combos/render.ts
@@ -13,6 +13,10 @@ function renderFlag(flag: string, value: string | readonly string[]): string {
return `--${flag} ${formatted}`;
}
+function withExplicitScalar(value: string | undefined, fallback = "none"): string {
+ return value ?? fallback;
+}
+
export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): string {
const ecosystem = typeof fingerprint.ecosystem === "string" ? fingerprint.ecosystem : "combo";
@@ -29,6 +33,20 @@ export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): str
typeof fingerprint.cssFramework === "string" ? fingerprint.cssFramework : undefined,
typeof fingerprint.uiLibrary === "string" ? fingerprint.uiLibrary : undefined,
],
+ "react-native": [
+ Array.isArray(fingerprint.frontend)
+ ? fingerprint.frontend.filter((value) => value !== "none").join("-")
+ : undefined,
+ typeof fingerprint.mobileNavigation === "string" ? fingerprint.mobileNavigation : undefined,
+ typeof fingerprint.mobileUI === "string" ? fingerprint.mobileUI : undefined,
+ typeof fingerprint.mobileStorage === "string" ? fingerprint.mobileStorage : undefined,
+ typeof fingerprint.mobileTesting === "string" ? fingerprint.mobileTesting : undefined,
+ typeof fingerprint.mobilePush === "string" ? fingerprint.mobilePush : undefined,
+ typeof fingerprint.mobileOTA === "string" ? fingerprint.mobileOTA : undefined,
+ typeof fingerprint.mobileDeepLinking === "string"
+ ? fingerprint.mobileDeepLinking
+ : undefined,
+ ],
rust: [
typeof fingerprint.rustWebFramework === "string" ? fingerprint.rustWebFramework : undefined,
typeof fingerprint.rustFrontend === "string" ? fingerprint.rustFrontend : undefined,
@@ -73,6 +91,18 @@ export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): str
? fingerprint.javaTestingLibraries.filter((value) => value !== "none").join("-")
: undefined,
],
+ elixir: [
+ typeof fingerprint.elixirWebFramework === "string"
+ ? fingerprint.elixirWebFramework
+ : undefined,
+ typeof fingerprint.elixirDatabase === "string" ? fingerprint.elixirDatabase : undefined,
+ Array.isArray(fingerprint.elixirLibraries)
+ ? fingerprint.elixirLibraries.filter((value) => value !== "none").join("-")
+ : undefined,
+ Array.isArray(fingerprint.elixirTesting)
+ ? fingerprint.elixirTesting.filter((value) => value !== "none").join("-")
+ : undefined,
+ ],
} as const;
const ecosystemTokens =
@@ -131,6 +161,13 @@ export function buildCommand(name: string, config: ProjectConfig): string {
["i18n", config.i18n],
["search", config.search],
["file-storage", config.fileStorage],
+ ["mobile-navigation", withExplicitScalar(config.mobileNavigation)],
+ ["mobile-ui", withExplicitScalar(config.mobileUI)],
+ ["mobile-storage", withExplicitScalar(config.mobileStorage)],
+ ["mobile-testing", withExplicitScalar(config.mobileTesting)],
+ ["mobile-push", withExplicitScalar(config.mobilePush)],
+ ["mobile-ota", withExplicitScalar(config.mobileOTA)],
+ ["mobile-deep-linking", withExplicitScalar(config.mobileDeepLinking)],
["web-deploy", config.webDeploy],
["server-deploy", config.serverDeploy],
];
@@ -142,6 +179,18 @@ export function buildCommand(name: string, config: ProjectConfig): string {
["search", config.search],
];
+ const reactNativeFlags: Array<[string, string | readonly string[]]> = [
+ ["frontend", withExplicitNone(config.frontend)],
+ ["auth", config.auth],
+ ["mobile-navigation", withExplicitScalar(config.mobileNavigation)],
+ ["mobile-ui", withExplicitScalar(config.mobileUI)],
+ ["mobile-storage", withExplicitScalar(config.mobileStorage)],
+ ["mobile-testing", withExplicitScalar(config.mobileTesting)],
+ ["mobile-push", withExplicitScalar(config.mobilePush)],
+ ["mobile-ota", withExplicitScalar(config.mobileOTA)],
+ ["mobile-deep-linking", withExplicitScalar(config.mobileDeepLinking)],
+ ];
+
const rustFlags: Array<[string, string | readonly string[]]> = [
["rust-web-framework", config.rustWebFramework],
["rust-frontend", config.rustFrontend],
@@ -186,6 +235,13 @@ export function buildCommand(name: string, config: ProjectConfig): string {
["java-testing-libraries", withExplicitNone(config.javaTestingLibraries)],
];
+ const elixirFlags: Array<[string, string | readonly string[]]> = [
+ ["elixir-web-framework", config.elixirWebFramework],
+ ["elixir-database", config.elixirDatabase],
+ ["elixir-libraries", withExplicitNone(config.elixirLibraries)],
+ ["elixir-testing", withExplicitNone(config.elixirTesting)],
+ ];
+
const orderedFlags = [...commonFlags];
switch (config.ecosystem) {
case "typescript":
@@ -207,6 +263,9 @@ export function buildCommand(name: string, config: ProjectConfig): string {
if (config.shadcnFont) orderedFlags.push(["shadcn-font", config.shadcnFont]);
if (config.shadcnRadius) orderedFlags.push(["shadcn-radius", config.shadcnRadius]);
break;
+ case "react-native":
+ orderedFlags.push(...reactNativeFlags);
+ break;
case "rust":
orderedFlags.push(...sharedServiceFlags, ...rustFlags);
break;
@@ -219,6 +278,9 @@ export function buildCommand(name: string, config: ProjectConfig): string {
case "java":
orderedFlags.push(...sharedServiceFlags, ...javaFlags);
break;
+ case "elixir":
+ orderedFlags.push(...sharedServiceFlags, ...elixirFlags);
+ break;
}
for (const [flag, value] of orderedFlags) {
diff --git a/testing/lib/generate-combos/types.ts b/testing/lib/generate-combos/types.ts
index 5f6fb1c1c..176232b72 100644
--- a/testing/lib/generate-combos/types.ts
+++ b/testing/lib/generate-combos/types.ts
@@ -20,16 +20,18 @@ export type GeneratorArgs = {
export const DEFAULT_ARGS: GeneratorArgs = {
count: 10,
- ecosystems: ["typescript", "rust", "python", "go", "java"],
+ ecosystems: ["typescript", "react-native", "rust", "python", "go", "java", "elixir"],
installMode: "install",
};
export const DEFAULT_ECOSYSTEM_WEIGHTS: Record = {
typescript: 4,
+ "react-native": 2,
rust: 2,
python: 2,
go: 2,
java: 2,
+ elixir: 2,
};
export const TEMPLATE_FINGERPRINT_KEYS = [
@@ -71,6 +73,13 @@ export const TEMPLATE_FINGERPRINT_KEYS = [
"caching",
"search",
"fileStorage",
+ "mobileNavigation",
+ "mobileUI",
+ "mobileStorage",
+ "mobileTesting",
+ "mobilePush",
+ "mobileOTA",
+ "mobileDeepLinking",
"addons",
"examples",
"aiDocs",
@@ -105,6 +114,10 @@ export const TEMPLATE_FINGERPRINT_KEYS = [
"javaAuth",
"javaLibraries",
"javaTestingLibraries",
+ "elixirWebFramework",
+ "elixirDatabase",
+ "elixirLibraries",
+ "elixirTesting",
] as const;
export type TemplateFingerprintKey = (typeof TEMPLATE_FINGERPRINT_KEYS)[number];
diff --git a/testing/lib/presets.test.ts b/testing/lib/presets.test.ts
index e14fd9828..fea870ba5 100644
--- a/testing/lib/presets.test.ts
+++ b/testing/lib/presets.test.ts
@@ -30,6 +30,8 @@ const PR_BROAD_PRESET_NAMES = [
"preset-react-vite-hono",
"preset-solid-start-express",
"preset-angular-fets",
+ "preset-vinext-minimal",
+ "preset-vinext-basic",
];
describe("preset groups", () => {
@@ -51,4 +53,10 @@ describe("preset groups", () => {
...PR_BROAD_PRESET_NAMES,
]);
});
+
+ it("renders complete CLI commands for all presets", () => {
+ for (const combo of getPresetCombos("all")) {
+ expect(combo.command).not.toContain(" undefined");
+ }
+ });
});
diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts
index bb0433eab..1f24a5e52 100644
--- a/testing/lib/presets.ts
+++ b/testing/lib/presets.ts
@@ -83,6 +83,10 @@ export function makeBaseConfig(name: string, ecosystem: Ecosystem): ProjectConfi
javaAuth: "none",
javaLibraries: [],
javaTestingLibraries: [],
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: [],
+ elixirTesting: [],
versionChannel: "stable",
} as ProjectConfig;
}
@@ -516,6 +520,27 @@ const SMOKE_TEST_PRESETS: Record = {
javaTestingLibraries: [],
},
},
+
+ // === ELIXIR PRESETS ===
+ "elixir-mix-otp": {
+ ecosystem: "elixir",
+ overrides: {
+ elixirWebFramework: "none",
+ elixirDatabase: "none",
+ elixirLibraries: ["jason", "req", "telemetry"],
+ elixirTesting: ["exunit", "stream-data"],
+ },
+ },
+ "elixir-phoenix-ecto": {
+ ecosystem: "elixir",
+ overrides: {
+ elixirWebFramework: "phoenix",
+ elixirDatabase: "ecto",
+ elixirLibraries: ["jason", "req", "oban", "telemetry"],
+ elixirTesting: ["exunit", "mox"],
+ observability: "sentry",
+ },
+ },
};
const PRESET_GROUPS = {
@@ -529,6 +554,7 @@ const PRESET_GROUPS = {
"python-fastapi-sqlalchemy",
"go-gin-gorm",
"java-spring-maven",
+ "elixir-mix-otp",
"frontend-only-react-vite",
],
"pr-broad": [
@@ -543,6 +569,7 @@ const PRESET_GROUPS = {
"go-echo-sqlc",
"java-spring-gradle-jpa",
"java-plain-cli",
+ "elixir-phoenix-ecto",
"react-vite-hono",
"solid-start-express",
"angular-fets",
diff --git a/testing/lib/verify.ts b/testing/lib/verify.ts
index 751d14a80..f90103901 100644
--- a/testing/lib/verify.ts
+++ b/testing/lib/verify.ts
@@ -204,6 +204,16 @@ function skippedStep(step: string): StepResult {
return { step, success: true, durationMs: 0, skipped: true };
}
+function templateFailure(step: string, stderr: string): StepResult {
+ return {
+ step,
+ success: false,
+ durationMs: 0,
+ stderr,
+ classification: "template",
+ };
+}
+
async function runAdvisoryStep(
step: string,
command: string,
@@ -311,6 +321,54 @@ export async function verifyTypeScript(
return wrapResult("typescript", comboName, projectDir, steps);
}
+export async function verifyReactNative(
+ comboName: string,
+ projectDir: string,
+ options?: VerifyOptions,
+): Promise {
+ const steps: StepResult[] = [];
+ const nativeDir = join(projectDir, "apps", "native");
+
+ if (!existsSync(nativeDir)) {
+ steps.push(templateFailure("structure", "Expected React Native app at apps/native"));
+ return wrapResult("react-native", comboName, projectDir, steps);
+ }
+
+ for (const requiredFile of ["package.json", "app.json", "tsconfig.json"]) {
+ if (!existsSync(join(nativeDir, requiredFile))) {
+ steps.push(templateFailure("structure", `Expected apps/native/${requiredFile}`));
+ return wrapResult("react-native", comboName, projectDir, steps);
+ }
+ }
+
+ steps.push(await runStep("install", "bun", ["install"], projectDir));
+ if (!steps.at(-1)!.success) return wrapResult("react-native", comboName, projectDir, steps);
+
+ steps.push(
+ await runStep(
+ "typecheck",
+ "bunx",
+ ["tsc", "-p", "apps/native/tsconfig.json", "--noEmit"],
+ projectDir,
+ ),
+ );
+ if (!steps.at(-1)!.success) return wrapResult("react-native", comboName, projectDir, steps);
+
+ if (
+ hasPackageScript(nativeDir, "test") &&
+ (options?.config?.mobileTesting === "react-native-testing-library" ||
+ options?.config?.mobileTesting === "maestro-react-native-testing-library")
+ ) {
+ steps.push(await runStep("test", "bun", ["run", "test", "--runInBand"], nativeDir));
+ } else {
+ steps.push(skippedStep("test"));
+ }
+
+ steps.push(skippedStep("simulator"));
+
+ return wrapResult("react-native", comboName, projectDir, steps);
+}
+
export async function verifyRust(comboName: string, projectDir: string): Promise {
const steps: StepResult[] = [];
@@ -428,6 +486,8 @@ export function getVerifier(
switch (ecosystem) {
case "typescript":
return verifyTypeScript;
+ case "react-native":
+ return verifyReactNative;
case "rust":
return verifyRust;
case "python":
diff --git a/testing/smoke-test.ts b/testing/smoke-test.ts
index b3e142d70..0dc743886 100644
--- a/testing/smoke-test.ts
+++ b/testing/smoke-test.ts
@@ -2,8 +2,7 @@
import type { Ecosystem } from "@better-fullstack/types";
-import { existsSync } from "node:fs";
-import { readFileSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
@@ -68,7 +67,10 @@ function parseArgs(argv: string[]): SmokeTestArgs {
i++;
break;
case "--ecosystem":
- if (next && ["typescript", "rust", "python", "go", "java"].includes(next)) {
+ if (
+ next &&
+ ["typescript", "react-native", "rust", "python", "go", "java", "elixir"].includes(next)
+ ) {
args.ecosystem = next as Ecosystem;
}
i++;
@@ -163,7 +165,9 @@ function generateCombos(args: SmokeTestArgs) {
const generatorArgs: GeneratorArgs = {
count: args.count,
- ecosystems: args.ecosystem ? [args.ecosystem] : ["typescript", "rust", "python", "go", "java"],
+ ecosystems: args.ecosystem
+ ? [args.ecosystem]
+ : ["typescript", "react-native", "rust", "python", "go", "java", "elixir"],
installMode: "no-install",
rng,
forceOptions: args.forceOptions,