feat: add clerk auth support with convex (#548)

This commit is contained in:
Aman Varshney
2025-08-29 00:21:08 +05:30
committed by GitHub
parent 8d48ae0359
commit 54bcdf1cbc
153 changed files with 1954 additions and 771 deletions

View File

@@ -49,8 +49,8 @@ Add `export BTS_TELEMETRY_DISABLED=1` to your shell profile to make it permanent
## Where to view analytics
- Charts: [`/analytics`](/analytics)
- Raw JSON snapshot: `https://r2.amanv.dev/analytics-data.json`
- CSV export: `https://r2.amanv.dev/export.csv`
- Raw JSON snapshot: `https://r2.better-t-stack.dev/analytics-data.json`
- CSV export: `https://r2.better-t-stack.dev/export.csv`
Notes:

View File

@@ -37,7 +37,7 @@ The file is JSONC with comments enabled and includes a `$schema` URL for tooling
"database": "sqlite",
"orm": "drizzle",
"api": "trpc",
"auth": true,
"auth": "better-auth",
"addons": ["turborepo"],
"examples": [],
"dbSetup": "none",

View File

@@ -59,7 +59,7 @@ create-better-t-stack --runtime workers --backend hono --database sqlite --orm d
#### Convex Backend
When using `--backend convex`, the following options are automatically set:
- `--auth false` (Convex handles auth)
- `--auth clerk` (if compatible frontends selected, otherwise `none`)
- `--database none` (Convex provides database)
- `--orm none` (Convex provides data layer)
- `--api none` (Convex provides API)
@@ -67,10 +67,12 @@ When using `--backend convex`, the following options are automatically set:
- `--db-setup none` (Convex manages hosting)
- `--examples todo` (Todo example works with Convex)
**Note:** Convex supports Clerk authentication with compatible frontends (React frameworks, Next.js, TanStack Start, and native frameworks). Nuxt, Svelte, and Solid are not compatible with Clerk.
#### No Backend
When using `--backend none`, the following options are automatically set:
- `--auth false` (No backend for auth)
- `--auth none` (No backend for auth)
- `--database none` (No backend for database)
- `--orm none` (No database)
- `--api none` (No backend for API)
@@ -158,17 +160,30 @@ create-better-t-stack --frontend next native-nativewind
## Authentication Requirements
Authentication requires:
### Better-Auth Requirements
Better-Auth authentication requires:
- A backend framework (cannot be `none`)
- A database (cannot be `none`)
- An ORM (cannot be `none`)
```bash
# ❌ Invalid - Auth without database
create-better-t-stack --auth --database none
### Clerk Requirements
Clerk authentication requires:
- Convex backend (`--backend convex`)
- Compatible frontends (React frameworks, Next.js, TanStack Start, native frameworks)
- Not compatible with Nuxt, Svelte, or Solid
# ✅ Valid - Auth with full stack
create-better-t-stack --auth --database postgres --orm drizzle --backend hono
```bash
# ❌ Invalid - Better-Auth without database
create-better-t-stack --auth better-auth --database none
# ✅ Valid - Better-Auth with full stack
create-better-t-stack --auth better-auth --database postgres --orm drizzle --backend hono
# ✅ Valid - Clerk with Convex
create-better-t-stack --auth clerk --backend convex --frontend tanstack-router
# ❌ Invalid - Clerk without Convex
create-better-t-stack --auth clerk --backend hono
```
## Example Compatibility
@@ -203,8 +218,11 @@ create-better-t-stack --frontend tanstack-router
### "Authentication requires a database"
```bash
# Fix by adding database
create-better-t-stack --auth --database postgres --orm drizzle
# Fix by adding database for Better-Auth
create-better-t-stack --auth better-auth --database postgres --orm drizzle
# Or use Clerk with Convex (no database required)
create-better-t-stack --auth clerk --backend convex
```
## Validation Strategy

View File

@@ -33,6 +33,7 @@ create-better-t-stack [project-directory] [options]
- `--database <type>`: `none`, `sqlite`, `postgres`, `mysql`, `mongodb`
- `--orm <type>`: `none`, `drizzle`, `prisma`, `mongoose`
- `--api <type>`: `none`, `trpc`, `orpc`
- `--auth <provider>`: `better-auth`, `clerk`, `none` (see [Options](/docs/cli/options#authentication))
- `--db-setup <setup>`: `none`, `turso`, `d1`, `neon`, `supabase`, `prisma-postgres`, `mongodb-atlas`, `docker`
- `--examples <types...>`: `none`, `todo`, `ai`
- `--web-deploy <setup>`: `none`, `wrangler`, `alchemy`

View File

@@ -217,16 +217,24 @@ create-better-t-stack --frontend none
## Authentication
### `--auth / --no-auth`
### `--auth <provider>`
Include or exclude authentication setup using Better-Auth.
Choose authentication provider:
- `better-auth`: Better-Auth authentication (default)
- `clerk`: Clerk authentication (only with Convex backend)
- `none`: No authentication
```bash
create-better-t-stack --auth
create-better-t-stack --no-auth
create-better-t-stack --auth better-auth
create-better-t-stack --auth clerk
create-better-t-stack --auth none
```
**Note:** Authentication requires both a database and backend framework to be selected. It is automatically disabled when using Convex backend or when no backend is selected.
**Note:**
- `better-auth` requires both a database and backend framework
- `clerk` is only available with Convex backend
- Authentication is automatically set to `none` when using `--backend none` or `--database none` (unless using Convex)
## Addons
@@ -312,7 +320,7 @@ create-better-t-stack \
--runtime bun \
--frontend tanstack-router \
--api trpc \
--auth \
--auth better-auth \
--addons pwa biome \
--examples todo \
--package-manager bun \

View File

@@ -33,7 +33,7 @@ async function createProject() {
backend: "hono",
database: "sqlite",
orm: "drizzle",
auth: true,
auth: "better-auth",
packageManager: "bun",
install: false, // Don't install deps automatically
disableAnalytics: true, // Disable analytics
@@ -258,7 +258,7 @@ create-better-t-stack my-app \
--backend hono \
--database postgres \
--orm drizzle \
--auth \
--auth better-auth \
--yes
```
@@ -269,7 +269,7 @@ const result = await init("my-app", {
backend: "hono",
database: "postgres",
orm: "drizzle",
auth: true,
auth: "better-auth",
yes: true
});
```
@@ -286,7 +286,7 @@ const result = await init("my-app", {
backend: "hono",
database: "postgres",
orm: "drizzle",
auth: true,
auth: "better-auth",
addons: ["biome", "turborepo"],
examples: ["todo"],
packageManager: "bun",

View File

@@ -5,13 +5,14 @@ description: Valid and invalid combinations across frontend, backend, runtime, d
## Rules
- **Convex backend**: Disables authentication, database, ORM, and API options
- **Convex backend**: Sets database, ORM, and API to `none`; auth to `clerk` (if compatible frontends) or `none`
- **Backend `none`**: Forces API, ORM, database, authentication, and runtime to `none`; disables examples
- **Frontend `none`**: Backend-only project; PWA/Tauri/examples may be disabled
- **API `none`**: No tRPC/oRPC setup; use framework-native APIs
- **Database `none`**: Disables ORM and authentication
- **Database `none`**: Disables ORM and Better-Auth (but allows Clerk with Convex)
- **ORM `none`**: No ORM setup; manage DB manually
- **Runtime `none`**: Only with Convex backend or when backend is `none`
- **Auth `clerk`**: Only available with Convex backend and compatible frontends
## Cloudflare Workers

View File

@@ -91,7 +91,7 @@ Skip prompts and use the default stack:
--backend hono \
--database sqlite \
--orm drizzle \
--auth \
--auth better-auth \
--addons turborepo
```
</Tab>
@@ -102,7 +102,7 @@ Skip prompts and use the default stack:
--backend hono \
--database sqlite \
--orm drizzle \
--auth \
--auth better-auth \
--addons turborepo
```
</Tab>
@@ -113,34 +113,37 @@ Skip prompts and use the default stack:
--backend hono \
--database sqlite \
--orm drizzle \
--auth \
--auth better-auth \
--addons turborepo
```
</Tab>
</Tabs>
### Convex + React
### Convex + React + Clerk
<Tabs items={['bun', 'pnpm', 'npm']}>
<Tab value="bun">
```bash
bun create better-t-stack@latest my-convex-app \
--frontend tanstack-router \
--backend convex
--backend convex \
--auth clerk
```
</Tab>
<Tab value="pnpm">
```bash
pnpm create better-t-stack@latest my-convex-app \
--frontend tanstack-router \
--backend convex
--backend convex \
--auth clerk
```
</Tab>
<Tab value="npm">
```bash
npx create-better-t-stack@latest my-convex-app \
--frontend tanstack-router \
--backend convex
--backend convex \
--auth clerk
```
</Tab>
</Tabs>
@@ -193,7 +196,7 @@ Skip prompts and use the default stack:
--backend hono \
--database sqlite \
--orm drizzle \
--auth
--auth better-auth
```
</Tab>
<Tab value="pnpm">
@@ -203,7 +206,7 @@ Skip prompts and use the default stack:
--backend hono \
--database sqlite \
--orm drizzle \
--auth
--auth better-auth
```
</Tab>
<Tab value="npm">
@@ -213,7 +216,7 @@ Skip prompts and use the default stack:
--backend hono \
--database sqlite \
--orm drizzle \
--auth
--auth better-auth
```
</Tab>
</Tabs>
@@ -254,6 +257,7 @@ See the full list in the [CLI Reference](/docs/cli). Key flags:
- `--database`: sqlite, postgres, mysql, mongodb, none
- `--orm`: drizzle, prisma, mongoose, none
- `--api`: trpc, orpc, none
- `--auth`: better-auth, clerk, none
- `--addons`: turborepo, pwa, tauri, biome, husky, starlight, fumadocs, ultracite, oxlint, ruler, none
- `--examples`: todo, ai, none

View File

@@ -256,7 +256,7 @@ apps/docs/
"frontend": ["<next|tanstack-router|react-router|tanstack-start|nuxt|svelte|solid>"] ,
"addons": ["<turborepo|biome|husky|pwa|starlight>"] ,
"examples": ["<ai|todo|none>"] ,
"auth": <true|false>,
"auth": <"better-auth"|"clerk"|"none">,
"packageManager": "<bun|pnpm|npm>",
"dbSetup": "<none|docker|d1>",
"api": "<none|trpc|orpc>",
@@ -336,7 +336,9 @@ Notes:
- **Monorepo**: `apps/*` always; `packages/*` only when needed (Convex)
- **React web base**: shadcn/ui primitives, `components.json`, common utilities
- **API clients**: `src/utils/trpc.ts` or `src/utils/orpc.ts` added to web/native when selected
- **Auth**: Adds `src/lib/auth.ts` on the server and login/dashboard pages on the web app
- **Auth**: Adds authentication setup based on provider:
- `better-auth`: `src/lib/auth.ts` on server and login/dashboard pages on web app
- `clerk`: Clerk provider setup and authentication components
- **ORM/DB**: Drizzle/Prisma/Mongoose files added only when selected
- **Extras**: `pnpm-workspace.yaml`, `bunfig.toml`, or `.npmrc` added based on package manager and choices
- **Deploy**: Workers deploy adds `wrangler.jsonc` templates to the appropriate app(s)

View File

@@ -62,7 +62,7 @@ async function generateAnalyticsData() {
try {
console.log("🔄 Fetching analytics data...");
const response = await fetch("https://r2.amanv.dev/export.csv");
const response = await fetch("https://r2.better-t-stack.dev/export.csv");
let csvText = await response.text();
// Fix malformed CSV data - if it's all on one line, try to split it properly

View File

@@ -1,3 +1,4 @@
"use client";
import { Check, ChevronDown, ChevronRight, Copy, Terminal } from "lucide-react";
import Link from "next/link";
import { useState } from "react";

View File

@@ -1,5 +1,6 @@
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks";
"use client";
import type { api } from "@better-t-stack/backend/convex/_generated/api";
import { type Preloaded, usePreloadedQuery } from "convex/react";
import {
ChevronDown,
ChevronUp,
@@ -25,77 +26,17 @@ import {
sortSponsors,
} from "@/lib/sponsor-utils";
export default function SponsorsSection() {
export default function SponsorsSection({
preloadedSponsors,
}: {
preloadedSponsors: Preloaded<typeof api.sponsors.getSponsors>;
}) {
const sponsorsData = usePreloadedQuery(preloadedSponsors);
const [showPastSponsors, setShowPastSponsors] = useState(false);
const sponsorsQuery = useQueryWithStatus(api.sponsors.getSponsors);
if (sponsorsQuery.isPending) {
return (
<div className="mb-12">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
SPONSORS_DATABASE.JSON
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
[LOADING... RECORDS]
</span>
</div>
</div>
<div className="rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_SPONSORS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
</div>
);
}
if (sponsorsQuery.isError) {
return (
<div className="mb-12">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
SPONSORS_DATABASE.JSON
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
[ERROR RECORDS]
</span>
</div>
</div>
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-destructive">
ERROR_LOADING_SPONSORS.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Please try again later!
</span>
</div>
</div>
</div>
</div>
);
}
const sponsors =
sponsorsQuery.data?.map((sponsor) => ({
sponsorsData.map((sponsor) => ({
...sponsor,
sponsor: {
...sponsor.sponsor,
@@ -106,7 +47,7 @@ export default function SponsorsSection() {
const visibleSponsors = filterVisibleSponsors(sponsors);
const sortedSponsors = sortSponsors(visibleSponsors);
const currentSponsors = filterCurrentSponsors(sortedSponsors);
const pastSponsors = filterPastSponsors(sortedSponsors);
const pastSponsors = filterPastSponsors(sortSponsors(sponsors));
const specialSponsors = sortSpecialSponsors(
filterSpecialSponsors(currentSponsors),
);

View File

@@ -157,7 +157,8 @@ function TechIcon({
theme === "light" &&
(icon.includes("drizzle") ||
icon.includes("prisma") ||
icon.includes("express"))
icon.includes("express") ||
icon.includes("clerk"))
) {
iconSrc = icon.replace(".svg", "-light.svg");
}
@@ -205,11 +206,24 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
database: "none",
orm: "none",
api: "none",
auth: "false",
dbSetup: "none",
examples: ["todo"],
};
const hasClerkCompatibleFrontend =
nextStack.webFrontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(
f,
),
) ||
nextStack.nativeFrontend.some((f) =>
["native-nativewind", "native-unistyles"].includes(f),
);
if (nextStack.auth !== "clerk" || !hasClerkCompatibleFrontend) {
convexOverrides.auth = "none";
}
for (const [key, value] of Object.entries(convexOverrides)) {
const catKey = key as keyof StackState;
if (JSON.stringify(nextStack[catKey]) !== JSON.stringify(value)) {
@@ -257,7 +271,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
}
} else if (isBackendNone) {
const noneOverrides: Partial<StackState> = {
auth: "false",
auth: "none",
database: "none",
orm: "none",
api: "none",
@@ -336,20 +350,20 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
message: "ORM set to 'None' (requires a database)",
});
}
if (nextStack.auth === "true") {
if (nextStack.auth !== "none" && nextStack.backend !== "convex") {
notes.database.notes.push(
"Database 'None' selected: Auth will be disabled.",
);
notes.auth.notes.push(
"Authentication requires a database. It will be disabled.",
"Authentication requires a database. It will be set to 'None'.",
);
notes.database.hasIssue = true;
notes.auth.hasIssue = true;
nextStack.auth = "false";
nextStack.auth = "none";
changed = true;
changes.push({
category: "database",
message: "Authentication disabled (requires a database)",
message: "Authentication set to 'None' (requires a database)",
});
}
if (nextStack.dbSetup !== "none") {
@@ -696,6 +710,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
notes.runtime.hasIssue = true;
notes.dbSetup.hasIssue = true;
nextStack.dbSetup = "d1";
changed = true;
changes.push({
category: "runtime",
message:
@@ -725,6 +740,57 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
});
}
if (nextStack.auth === "clerk") {
const hasClerkCompatibleFrontend =
nextStack.webFrontend.some((f) =>
[
"tanstack-router",
"react-router",
"tanstack-start",
"next",
].includes(f),
) ||
nextStack.nativeFrontend.some((f) =>
["native-nativewind", "native-unistyles"].includes(f),
);
if (!hasClerkCompatibleFrontend) {
notes.auth.notes.push(
"Clerk auth is not compatible with the selected frontends. Auth will be set to 'None'.",
);
notes.webFrontend.notes.push(
"Selected frontends are not compatible with Clerk auth. Auth will be disabled.",
);
notes.auth.hasIssue = true;
notes.webFrontend.hasIssue = true;
nextStack.auth = "none";
changed = true;
changes.push({
category: "auth",
message:
"Auth set to 'None' (Clerk not compatible with selected frontends)",
});
}
}
if (nextStack.backend === "convex" && nextStack.auth === "better-auth") {
notes.auth.notes.push(
"Better-Auth is not compatible with Convex backend. Auth will be set to 'None'.",
);
notes.backend.notes.push(
"Convex backend only supports Clerk auth or no auth. Auth will be disabled.",
);
notes.auth.hasIssue = true;
notes.backend.hasIssue = true;
nextStack.auth = "none";
changed = true;
changes.push({
category: "auth",
message:
"Auth set to 'None' (Better-Auth not compatible with Convex)",
});
}
const incompatibleAddons: string[] = [];
const isPWACompat = hasPWACompatibleFrontend(nextStack.webFrontend);
const isTauriCompat = hasTauriCompatibleFrontend(nextStack.webFrontend);
@@ -1120,9 +1186,7 @@ const generateCommand = (stackState: StackState): string => {
flags.push(`--orm ${stackState.orm}`);
}
if (!checkDefault("auth", stackState.auth)) {
if (stackState.auth === "false" && DEFAULT_STACK.auth === "true") {
flags.push("--no-auth");
}
flags.push(`--auth ${stackState.auth}`);
}
if (!checkDefault("dbSetup", stackState.dbSetup)) {
flags.push(`--db-setup ${stackState.dbSetup}`);
@@ -1527,16 +1591,12 @@ const StackBuilder = () => {
update[catKey] = techId;
} else {
if (
(category === "git" ||
category === "install" ||
category === "auth") &&
(category === "git" || category === "install") &&
techId === "false"
) {
update[catKey] = "true";
} else if (
(category === "git" ||
category === "install" ||
category === "auth") &&
(category === "git" || category === "install") &&
techId === "true"
) {
update[catKey] = "false";

View File

@@ -1,3 +1,4 @@
"use client";
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { useNpmDownloadCounter } from "@erquhart/convex-oss-stats/react";
import NumberFlow, { continuous } from "@number-flow/react";
@@ -12,33 +13,16 @@ import {
Users,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function StatsSection() {
const [analyticsData, setAnalyticsData] = useState<{
export default function StatsSection({
analyticsData,
}: {
analyticsData: {
totalProjects: number;
avgProjectsPerDay: string;
lastUpdated: string | null;
} | null>(null);
useEffect(() => {
const fetchAnalytics = async () => {
try {
const response = await fetch(
"https://r2.amanv.dev/analytics-minimal.json",
);
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
}
} catch (error) {
console.error("Failed to fetch analytics data:", error);
}
};
fetchAnalytics();
}, []);
};
}) {
const githubRepo = useQuery(api.stats.getGithubRepo, {
name: "AmanVarshney01/create-better-t-stack",
});

View File

@@ -1,12 +1,11 @@
"use client";
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks";
import type { api } from "@better-t-stack/backend/convex/_generated/api";
import { type Preloaded, usePreloadedQuery } from "convex/react";
import { Play, Terminal } from "lucide-react";
import { motion } from "motion/react";
import Image from "next/image";
import { Suspense } from "react";
import { Tweet, TweetSkeleton, type TwitterComponents } from "react-tweet";
import { Tweet, type TwitterComponents } from "react-tweet";
export const components: TwitterComponents = {
AvatarImg: (props) => {
@@ -92,101 +91,27 @@ const TweetCard = ({ tweetId, index }: { tweetId: string; index: number }) => (
</div>
<div className="w-full min-w-0 overflow-hidden">
<div style={{ width: "100%", minWidth: 0, maxWidth: "100%" }}>
<Suspense fallback={<TweetSkeleton />}>
<Tweet id={tweetId} components={components} />
</Suspense>
{/* <Suspense fallback={<TweetSkeleton />}> */}
<Tweet id={tweetId} components={components} />
{/* </Suspense> */}
</div>
</div>
</div>
</motion.div>
);
export default function Testimonials() {
const videosQuery = useQueryWithStatus(api.testimonials.getVideos);
const tweetsQuery = useQueryWithStatus(api.testimonials.getTweets);
export default function Testimonials({
preloadedTestimonialsTweet,
preloadedTestimonialsVideos,
}: {
preloadedTestimonialsTweet: Preloaded<typeof api.testimonials.getTweets>;
preloadedTestimonialsVideos: Preloaded<typeof api.testimonials.getVideos>;
}) {
const videosData = usePreloadedQuery(preloadedTestimonialsVideos);
const tweetsData = usePreloadedQuery(preloadedTestimonialsTweet);
const videos = videosQuery.data || [];
const tweets = tweetsQuery.data || [];
if (videosQuery.isPending || tweetsQuery.isPending) {
return (
<div className="mb-12 w-full max-w-full overflow-hidden px-4">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Play className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
VIDEO_TESTIMONIALS.LOG
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[LOADING... ENTRIES]
</span>
</div>
<div className="mb-6 rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_VIDEOS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
DEVELOPER_TESTIMONIALS.LOG
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[LOADING... ENTRIES]
</span>
</div>
<div className="rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_TWEETS.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
</div>
);
}
if (videosQuery.isError || tweetsQuery.isError) {
return (
<div className="mb-12 w-full max-w-full overflow-hidden px-4">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Play className="h-5 w-5 text-primary" />
<span className="font-bold text-lg sm:text-xl">
VIDEO_TESTIMONIALS.LOG
</span>
</div>
<div className="hidden h-px flex-1 bg-border sm:block" />
<span className="w-full text-right text-muted-foreground text-xs sm:w-auto sm:text-left">
[ERROR ENTRIES]
</span>
</div>
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-destructive">
ERROR_LOADING_TESTIMONIALS.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Please try again later!
</span>
</div>
</div>
</div>
</div>
);
}
const videos = videosData || [];
const tweets = tweetsData || [];
const getResponsiveColumns = (numCols: number) => {
const columns: string[][] = Array(numCols)

View File

@@ -3,17 +3,13 @@ import Image from "next/image";
import Link from "next/link";
import discordIcon from "@/public/icon/discord.svg";
interface AnalyticsHeaderProps {
totalProjects: number;
lastUpdated: string | null;
loadingLastUpdated: boolean;
}
export function AnalyticsHeader({
totalProjects,
lastUpdated,
loadingLastUpdated,
}: AnalyticsHeaderProps) {
}: {
totalProjects: number;
lastUpdated: string | null;
}) {
return (
<div className="mb-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
@@ -57,7 +53,7 @@ export function AnalyticsHeader({
</Link>
{" | "}
<Link
href="https://r2.amanv.dev/export.csv"
href="https://r2.better-t-stack.dev/export.csv"
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-primary"
@@ -69,12 +65,7 @@ export function AnalyticsHeader({
<div className="mt-2 flex items-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Last updated:{" "}
{loadingLastUpdated
? "CHECKING..."
: lastUpdated
? `${lastUpdated} UTC`
: "UNKNOWN"}
Last updated: {lastUpdated ? `${lastUpdated} UTC` : "UNKNOWN"}
</span>
</div>
</div>

View File

@@ -0,0 +1,56 @@
"use client";
import Footer from "../../_components/footer";
import { AddonsExamplesCharts } from "./addons-examples-charts";
import { AnalyticsHeader } from "./analytics-header";
import { DevEnvironmentCharts } from "./dev-environment-charts";
import { MetricsCards } from "./metrics-cards";
import { StackConfigurationCharts } from "./stack-configuration-charts";
import { TimelineCharts } from "./timeline-charts";
import type { AggregatedAnalyticsData } from "./types";
export default function AnalyticsPage({
data,
}: {
data: AggregatedAnalyticsData | null;
}) {
const totalProjects = data?.summary?.totalProjects || 0;
const avgProjectsPerDay = data?.summary?.avgProjectsPerDay || 0;
const authEnabledPercent = data?.summary?.authEnabledPercent || 0;
const mostPopularFrontend = data?.summary?.mostPopularFrontend || "None";
const mostPopularBackend = data?.summary?.mostPopularBackend || "None";
const mostPopularORM = data?.summary?.mostPopularORM || "None";
const mostPopularAPI = data?.summary?.mostPopularAPI || "None";
const mostPopularPackageManager =
data?.summary?.mostPopularPackageManager || "npm";
return (
<div className="mx-auto min-h-svh max-w-[1280px]">
<div className="container mx-auto space-y-8 px-4 py-8 pt-16">
<AnalyticsHeader
totalProjects={totalProjects}
lastUpdated={data?.lastUpdated || null}
/>
<MetricsCards
totalProjects={totalProjects}
avgProjectsPerDay={avgProjectsPerDay}
authEnabledPercent={authEnabledPercent}
mostPopularFrontend={mostPopularFrontend}
mostPopularBackend={mostPopularBackend}
mostPopularORM={mostPopularORM}
mostPopularAPI={mostPopularAPI}
mostPopularPackageManager={mostPopularPackageManager}
/>
<TimelineCharts data={data} />
<StackConfigurationCharts data={data} />
<AddonsExamplesCharts data={data} />
<DevEnvironmentCharts data={data} />
</div>
<Footer />
</div>
);
}

View File

@@ -1,8 +0,0 @@
export { AddonsExamplesCharts } from "./addons-examples-charts";
export { AnalyticsHeader } from "./analytics-header";
export * from "./data-utils";
export { DevEnvironmentCharts } from "./dev-environment-charts";
export { MetricsCards } from "./metrics-cards";
export { StackConfigurationCharts } from "./stack-configuration-charts";
export { TimelineCharts } from "./timeline-charts";
export * from "./types";

View File

@@ -1,78 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Footer from "../_components/footer";
import {
AddonsExamplesCharts,
type AggregatedAnalyticsData,
AnalyticsHeader,
DevEnvironmentCharts,
MetricsCards,
StackConfigurationCharts,
TimelineCharts,
} from "./_components";
import AnalyticsPage from "./_components/analytics-page";
export default function AnalyticsPage() {
const [data, setData] = useState<AggregatedAnalyticsData | null>(null);
const [loadingLastUpdated, setLoadingLastUpdated] = useState(true);
const loadAnalyticsData = useCallback(async () => {
try {
const response = await fetch("https://r2.amanv.dev/analytics-data.json");
const analyticsData = await response.json();
setData(analyticsData);
console.log("Loaded aggregated analytics data from R2 bucket");
console.log(`Data generated at: ${analyticsData.generatedAt}`);
} catch (error: unknown) {
console.error("Error loading analytics data:", error);
} finally {
setLoadingLastUpdated(false);
}
}, []);
useEffect(() => {
loadAnalyticsData();
}, [loadAnalyticsData]);
const totalProjects = data?.summary?.totalProjects || 0;
const avgProjectsPerDay = data?.summary?.avgProjectsPerDay || 0;
const authEnabledPercent = data?.summary?.authEnabledPercent || 0;
const mostPopularFrontend = data?.summary?.mostPopularFrontend || "None";
const mostPopularBackend = data?.summary?.mostPopularBackend || "None";
const mostPopularORM = data?.summary?.mostPopularORM || "None";
const mostPopularAPI = data?.summary?.mostPopularAPI || "None";
const mostPopularPackageManager =
data?.summary?.mostPopularPackageManager || "npm";
return (
<div className="mx-auto min-h-svh max-w-[1280px]">
<div className="container mx-auto space-y-8 px-4 py-8 pt-16">
<AnalyticsHeader
totalProjects={totalProjects}
lastUpdated={data?.lastUpdated || null}
loadingLastUpdated={loadingLastUpdated}
/>
<MetricsCards
totalProjects={totalProjects}
avgProjectsPerDay={avgProjectsPerDay}
authEnabledPercent={authEnabledPercent}
mostPopularFrontend={mostPopularFrontend}
mostPopularBackend={mostPopularBackend}
mostPopularORM={mostPopularORM}
mostPopularAPI={mostPopularAPI}
mostPopularPackageManager={mostPopularPackageManager}
/>
<TimelineCharts data={data} />
<StackConfigurationCharts data={data} />
<AddonsExamplesCharts data={data} />
<DevEnvironmentCharts data={data} />
</div>
<Footer />
</div>
export default async function Analytics() {
const response = await fetch(
"https://r2.better-t-stack.dev/analytics-data.json",
);
const analyticsData = await response.json();
return <AnalyticsPage data={analyticsData} />;
}

View File

@@ -1,33 +1,16 @@
"use client";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { usePathname } from "next/navigation";
import { type ReactNode, useEffect } from "react";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
export default function Layout({ children }: { children: ReactNode }) {
const pathname = usePathname();
useEffect(() => {
const header = document.querySelector("#nd-nav");
if (!header) return;
const main = document.querySelector("main");
if (!main) return;
if (pathname === "/new") {
header.classList.remove("*:mx-auto", "*:max-w-fd-container");
} else {
header.classList.add("*:mx-auto", "*:max-w-fd-container");
}
}, [pathname]);
return (
<HomeLayout
{...baseOptions}
style={
{
"--spacing-fd-container": "1280px",
"--spacing-fd-container": "100%",
} as object
}
>

View File

@@ -1,4 +1,5 @@
"use client";
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { preloadQuery } from "convex/nextjs";
import CommandSection from "./_components/command-section";
import Footer from "./_components/footer";
import HeroSection from "./_components/hero-section";
@@ -6,15 +7,32 @@ import SponsorsSection from "./_components/sponsors-section";
import StatsSection from "./_components/stats-section";
import Testimonials from "./_components/testimonials";
export default function HomePage() {
export default async function HomePage() {
const preloadedSponsors = await preloadQuery(api.sponsors.getSponsors);
const preloadedTestimonialsTweet = await preloadQuery(
api.testimonials.getTweets,
);
const preloadedTestimonialsVideos = await preloadQuery(
api.testimonials.getVideos,
);
const minimalAnalytics = await fetch(
"https://r2.better-t-stack.dev/analytics-minimal.json",
);
const minimalAnalyticsData = await minimalAnalytics.json();
return (
<div className="mx-auto min-h-svh max-w-[1280px]">
<main className="mx-auto px-4 pt-12">
<HeroSection />
<CommandSection />
<StatsSection />
<SponsorsSection />
<Testimonials />
<StatsSection analyticsData={minimalAnalyticsData} />
<SponsorsSection preloadedSponsors={preloadedSponsors} />
<Testimonials
preloadedTestimonialsTweet={preloadedTestimonialsTweet}
preloadedTestimonialsVideos={preloadedTestimonialsVideos}
/>
</main>
<Footer />
</div>

View File

@@ -0,0 +1,72 @@
"use client";
import type { api } from "@better-t-stack/backend/convex/_generated/api";
import { type Preloaded, usePreloadedQuery } from "convex/react";
import { Terminal } from "lucide-react";
import Footer from "../../_components/footer";
import ShowcaseItem from "../_components/ShowcaseItem";
export default function ShowcasePage({
preloadedShowcase,
}: {
preloadedShowcase: Preloaded<typeof api.showcase.getShowcaseProjects>;
}) {
const showcaseProjects = usePreloadedQuery(preloadedShowcase);
return (
<main className="mx-auto min-h-svh max-w-[1280px]">
<div className="container mx-auto space-y-8 px-4 py-8 pt-16">
<div className="mb-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary" />
<span className="font-bold text-lg sm:text-xl">
PROJECT_SHOWCASE.SH
</span>
</div>
<div className="h-px flex-1 bg-border" />
<span className="text-muted-foreground text-xs">
[{showcaseProjects.length} PROJECTS FOUND]
</span>
</div>
</div>
{showcaseProjects.length === 0 ? (
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-muted-foreground">
NO_SHOWCASE_PROJECTS_FOUND.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Be the first to showcase your project!
</span>
</div>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{showcaseProjects.map((project, index) => (
<ShowcaseItem key={project._id} {...project} index={index} />
))}
</div>
)}
<div className="mt-8">
<div className="rounded border border-border p-4">
<div className="flex items-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Want to showcase your project? Submit via GitHub issues
</span>
</div>
</div>
</div>
</div>
<Footer />
</main>
);
}

View File

@@ -1,140 +1,10 @@
"use client";
import { api } from "@better-t-stack/backend/convex/_generated/api";
import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks";
import { Terminal } from "lucide-react";
import Footer from "../_components/footer";
import ShowcaseItem from "./_components/ShowcaseItem";
import { preloadQuery } from "convex/nextjs";
import ShowcasePage from "./_components/showcase-page";
export default function ShowcasePage() {
const showcaseQuery = useQueryWithStatus(api.showcase.getShowcaseProjects);
if (showcaseQuery.isPending) {
return (
<main className="mx-auto min-h-svh max-w-[1280px]">
<div className="container mx-auto space-y-8 px-4 py-8 pt-16">
<div className="mb-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary" />
<span className="font-bold text-lg sm:text-xl">
PROJECT_SHOWCASE.SH
</span>
</div>
<div className="h-px flex-1 bg-border" />
<span className="text-muted-foreground text-xs">
[LOADING... PROJECTS]
</span>
</div>
</div>
<div className="rounded border border-border p-8">
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-muted-foreground">LOADING_SHOWCASE.SH</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
</div>
<Footer />
</main>
);
}
if (showcaseQuery.isError) {
return (
<main className="mx-auto min-h-svh max-w-[1280px]">
<div className="container mx-auto space-y-8 px-4 py-8 pt-16">
<div className="mb-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary" />
<span className="font-bold text-lg sm:text-xl">
PROJECT_SHOWCASE.SH
</span>
</div>
<div className="h-px flex-1 bg-border" />
<span className="text-muted-foreground text-xs">
[ERROR PROJECTS]
</span>
</div>
</div>
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-destructive">
ERROR_LOADING_SHOWCASE.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Please try again later!
</span>
</div>
</div>
</div>
</div>
<Footer />
</main>
);
}
const showcaseProjects = showcaseQuery.data || [];
return (
<main className="mx-auto min-h-svh max-w-[1280px]">
<div className="container mx-auto space-y-8 px-4 py-8 pt-16">
<div className="mb-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary" />
<span className="font-bold text-lg sm:text-xl">
PROJECT_SHOWCASE.SH
</span>
</div>
<div className="h-px flex-1 bg-border" />
<span className="text-muted-foreground text-xs">
[{showcaseProjects.length} PROJECTS FOUND]
</span>
</div>
</div>
{showcaseProjects.length === 0 ? (
<div className="rounded border border-border p-8">
<div className="text-center">
<div className="mb-4 flex items-center justify-center gap-2">
<span className="text-muted-foreground">
NO_SHOWCASE_PROJECTS_FOUND.NULL
</span>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Be the first to showcase your project!
</span>
</div>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{showcaseProjects.map((project, index) => (
<ShowcaseItem key={project._id} {...project} index={index} />
))}
</div>
)}
<div className="mt-8">
<div className="rounded border border-border p-4">
<div className="flex items-center gap-2 text-sm">
<span className="text-primary">$</span>
<span className="text-muted-foreground">
Want to showcase your project? Submit via GitHub issues
</span>
</div>
</div>
</div>
</div>
<Footer />
</main>
export default async function Showcase() {
const preloadedShowcase = await preloadQuery(
api.showcase.getShowcaseProjects,
);
return <ShowcasePage preloadedShowcase={preloadedShowcase} />;
}

View File

@@ -396,15 +396,23 @@ export const TECH_OPTIONS: Record<
],
auth: [
{
id: "true",
name: "Better Auth",
description: "Simple authentication",
id: "better-auth",
name: "Better-Auth",
description:
"The most comprehensive authentication framework for TypeScript",
icon: `${ICON_BASE_URL}/better-auth.svg`,
color: "from-green-400 to-green-600",
default: true,
},
{
id: "false",
id: "clerk",
name: "Clerk",
description: "More than authentication, Complete User Management",
icon: `${ICON_BASE_URL}/clerk.svg`,
color: "from-blue-400 to-blue-600",
},
{
id: "none",
name: "No Auth",
description: "Skip authentication",
icon: "",
@@ -587,7 +595,7 @@ export const PRESET_TEMPLATES = [
database: "sqlite",
orm: "drizzle",
dbSetup: "none",
auth: "true",
auth: "better-auth",
packageManager: "bun",
addons: ["turborepo"],
examples: [],
@@ -609,7 +617,7 @@ export const PRESET_TEMPLATES = [
database: "none",
orm: "none",
dbSetup: "none",
auth: "false",
auth: "none",
packageManager: "bun",
addons: ["turborepo"],
examples: ["todo"],
@@ -631,7 +639,7 @@ export const PRESET_TEMPLATES = [
database: "sqlite",
orm: "drizzle",
dbSetup: "none",
auth: "true",
auth: "better-auth",
packageManager: "bun",
addons: ["turborepo"],
examples: [],
@@ -653,7 +661,7 @@ export const PRESET_TEMPLATES = [
database: "sqlite",
orm: "drizzle",
dbSetup: "none",
auth: "true",
auth: "better-auth",
packageManager: "bun",
addons: ["turborepo"],
examples: [],
@@ -675,7 +683,7 @@ export const PRESET_TEMPLATES = [
database: "sqlite",
orm: "drizzle",
dbSetup: "turso",
auth: "true",
auth: "better-auth",
packageManager: "bun",
addons: ["pwa", "biome", "husky", "tauri", "starlight", "turborepo"],
examples: ["todo", "ai"],
@@ -715,7 +723,7 @@ export const DEFAULT_STACK: StackState = {
database: "sqlite",
orm: "drizzle",
dbSetup: "none",
auth: "true",
auth: "better-auth",
packageManager: "bun",
addons: ["turborepo"],
examples: [],
@@ -738,7 +746,7 @@ export const isStackDefault = <K extends keyof StackState>(
if (key === "database" && value === "none") return true;
if (key === "orm" && value === "none") return true;
if (key === "api" && value === "none") return true;
if (key === "auth" && value === "false") return true;
if (key === "auth" && value === "none") return true;
if (key === "dbSetup" && value === "none") return true;
if (
key === "examples" &&

View File

@@ -36,7 +36,7 @@ export const stackParsers = {
dbSetup: parseAsStringEnum<StackState["dbSetup"]>(
getValidIds("dbSetup"),
).withDefault(DEFAULT_STACK.dbSetup),
auth: parseAsStringEnum<StackState["auth"]>(["true", "false"]).withDefault(
auth: parseAsStringEnum<StackState["auth"]>(getValidIds("auth")).withDefault(
DEFAULT_STACK.auth,
),
packageManager: parseAsStringEnum<StackState["packageManager"]>(