create-loumi-app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.js +27 -0
  2. package/package.json +40 -0
  3. package/template/addons/better-auth/convex/auth.config.ts +6 -0
  4. package/template/addons/better-auth/convex/auth.ts +34 -0
  5. package/template/addons/better-auth/convex/convex.config.ts +6 -0
  6. package/template/addons/better-auth/convex/http.ts +6 -0
  7. package/template/addons/better-auth/src/lib/auth-client.ts +6 -0
  8. package/template/addons/better-auth/src/lib/auth-server.ts +12 -0
  9. package/template/addons/better-auth/src/router.tsx +39 -0
  10. package/template/addons/better-auth/src/routes/__root.tsx +79 -0
  11. package/template/addons/better-auth/src/routes/api.auth.$.ts +7 -0
  12. package/template/addons/convex/convex/schema.ts +9 -0
  13. package/template/addons/convex/convex/tasks.ts +28 -0
  14. package/template/addons/convex/src/router.tsx +37 -0
  15. package/template/addons/convex/src/routes/__root.tsx +61 -0
  16. package/template/addons/convex/src/routes/index.tsx +53 -0
  17. package/template/addons/resend/src/lib/email.ts +33 -0
  18. package/template/addons/sanity/sanity/schemaTypes/documents/post.ts +44 -0
  19. package/template/addons/sanity/sanity/schemaTypes/index.ts +4 -0
  20. package/template/addons/sanity/sanity/schemaTypes/objects/seo.ts +27 -0
  21. package/template/addons/sanity/src/lib/sanity.ts +16 -0
  22. package/template/addons/sanity/src/routes/blog.$slug.tsx +53 -0
  23. package/template/addons/sanity/src/routes/blog.index.tsx +52 -0
  24. package/template/addons/sanity/src/routes/blog.tsx +23 -0
  25. package/template/addons/seo/public/llms.txt +13 -0
  26. package/template/addons/seo/public/robots.txt +4 -0
  27. package/template/addons/seo/src/lib/schema-org.ts +90 -0
  28. package/template/addons/seo/src/lib/seo.ts +56 -0
  29. package/template/base/CLAUDE.md +41 -0
  30. package/template/base/_gitignore +14 -0
  31. package/template/base/app.config.ts +8 -0
  32. package/template/base/public/favicon.ico +0 -0
  33. package/template/base/src/components/DefaultCatchBoundary.tsx +47 -0
  34. package/template/base/src/components/NotFound.tsx +18 -0
  35. package/template/base/src/router.tsx +16 -0
  36. package/template/base/src/routes/__root.tsx +49 -0
  37. package/template/base/src/routes/index.tsx +28 -0
  38. package/template/base/src/styles/app.css +7 -0
  39. package/template/base/tsconfig.json +17 -0
  40. package/template/base/wrangler.jsonc +8 -0
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import {Command}from'commander';import*as a from'@clack/prompts';import m from'chalk';import h from'path';import g from'fs-extra';import f from'ora';import {execa}from'execa';import {fileURLToPath}from'url';import z from'sort-package-json';async function E(e){a.intro(m.bgBlue.white(" create-loumi-app "));let t=await a.text({message:"Projektname?",placeholder:"my-loumi-app",initialValue:e,validate:i=>{if(!i)return "Projektname ist erforderlich";if(!/^[a-z0-9-_]+$/i.test(i))return "Nur Buchstaben, Zahlen, Bindestriche und Unterstriche erlaubt"}});if(a.isCancel(t))return a.cancel("Abgebrochen."),null;let s=await a.multiselect({message:"Welche Add-ons m\xF6chtest du? (Space zum Ausw\xE4hlen)",options:[{value:"convex",label:"Convex",hint:"Backend, DB, Real-time + TanStack Query"},{value:"better-auth",label:"Better Auth",hint:"Authentication (Email/Password, OAuth) via Convex"},{value:"sanity",label:"Sanity",hint:"Headless CMS (Portable Text) + Blog-Routes"},{value:"resend",label:"Resend",hint:"E-Mail Versand (Transactional Emails)"},{value:"seo",label:"Advanced SEO",hint:"JSON-LD, Schema.org, robots.txt, llms.txt"}],required:false});if(a.isCancel(s))return a.cancel("Abgebrochen."),null;let o=await a.confirm({message:"Dependencies installieren?",initialValue:true});if(a.isCancel(o))return a.cancel("Abgebrochen."),null;let n=await a.confirm({message:"Git Repository initialisieren?",initialValue:true});return a.isCancel(n)?(a.cancel("Abgebrochen."),null):{projectName:t,addons:s||[],installDeps:o,initGit:n}}var Q=fileURLToPath(import.meta.url),M=h.dirname(Q);function Y(e){return h.join(M,"..","template",e)}async function l(e,t){let s=Y(e);await g.copy(s,t,{overwrite:true});}async function T(e,t,s){let o=h.join(e,t),n=h.join(e,s);await g.pathExists(o)&&await g.move(o,n,{overwrite:true});}async function A(e,t,s){if(!await g.pathExists(e))return;let n=(await g.readFile(e,"utf-8")).replaceAll(t,s);await g.writeFile(e,n);}async function c(e,t){await g.pathExists(e)?await g.appendFile(e,t):await g.writeFile(e,t);}var C={react:"^19.0.0","react-dom":"^19.0.0","@tanstack/react-router":"^1.120.0","@tanstack/react-start":"^1.120.0",tailwindcss:"^4.0.0","@tailwindcss/vite":"^4.0.0",typescript:"^5.7.0",vite:"^6.0.0",wrangler:"^4.0.0","@cloudflare/workers-types":"^4.20250109.0","unenv-preset-cloudflare":"^2.0.0",convex:"^1.17.0","@convex-dev/react-query":"^0.0.7","@tanstack/react-query":"^5.62.0","@tanstack/react-router-with-query":"^1.120.0","@sanity/client":"^7.3.0","@sanity/image-url":"^1.1.0","@portabletext/react":"^3.2.0","better-auth":"1.4.9","@convex-dev/better-auth":"^0.10.10",resend:"^4.1.0","@types/react":"^19.0.0","@types/react-dom":"^19.0.0"};function K(e){return C[e]}function p(e){let t={};for(let s of e)t[s]=K(s);return t}async function u(e,{dependencies:t={},devDependencies:s={},scripts:o={}}){let n=h.join(e,"package.json"),i=await g.readJson(n);i.dependencies={...i.dependencies,...t},i.devDependencies={...i.devDependencies,...s},i.scripts={...i.scripts,...o};let d=z(JSON.stringify(i,null,2));await g.writeFile(n,d+`
3
+ `);}var X=["react","react-dom","@tanstack/react-router","@tanstack/react-start","tailwindcss","@tailwindcss/vite"],Z=["typescript","vite","wrangler","@cloudflare/workers-types","unenv-preset-cloudflare","@types/react","@types/react-dom"];async function P(e,t){await l("base",e),await T(e,"_gitignore",".gitignore");let s={name:t,version:"0.1.0",private:true,type:"module",scripts:{dev:"vinxi dev",build:"vinxi build",start:"vinxi start",preview:"wrangler dev",deploy:"vinxi build && wrangler deploy"},dependencies:{},devDependencies:{}};await g.writeJson(h.join(e,"package.json"),s,{spaces:2}),await u(e,{dependencies:p(X),devDependencies:p(Z)}),await A(h.join(e,"wrangler.jsonc"),"PROJECT_NAME",t);}var ee=["convex","@convex-dev/react-query","@tanstack/react-query","@tanstack/react-router-with-query"],te=`
4
+ # Convex
5
+ # Run "npx convex dev" to generate these values
6
+ CONVEX_DEPLOYMENT=
7
+ VITE_CONVEX_URL=
8
+ `,ne="\n\n## Convex (Backend / Database)\n\n- **Real-time database** with automatic WebSocket subscriptions\n- Convex functions live in `convex/` directory (schema, queries, mutations)\n- Schema defined in `convex/schema.ts` using `defineSchema`, `defineTable`, `v` validators\n- Queries/mutations exported from `convex/*.ts` using `query()` and `mutation()`\n- Client integration via `@convex-dev/react-query` \u2014 bridges Convex into TanStack Query cache\n- Router uses `routerWithQueryClient` from `@tanstack/react-router-with-query`\n- SSR data loading: `context.queryClient.ensureQueryData(convexQuery(api.module.fn, args))`\n- Client-side reading: `useSuspenseQuery(convexQuery(api.module.fn, args))`\n- Mutations: `useConvexMutation(api.module.fn)` or `useMutation` from react-query\n- `_generated/` folder is auto-generated \u2014 never edit manually\n- Run `npx convex dev` to start Convex dev server and generate types\n";async function I(e){await l("addons/convex",e),await u(e,{dependencies:p(ee)}),await c(h.join(e,".env.local"),te),await c(h.join(e,"CLAUDE.md"),ne);}var se=["better-auth","@convex-dev/better-auth"],ae=`
9
+ # Better Auth
10
+ # Set BETTER_AUTH_SECRET on Convex: npx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
11
+ # Set SITE_URL on Convex: npx convex env set SITE_URL=http://localhost:3000
12
+ VITE_CONVEX_SITE_URL=
13
+ VITE_SITE_URL=http://localhost:3000
14
+ `,oe='\n\n## Better Auth (Authentication)\n\n- Uses `@convex-dev/better-auth` \u2014 Better Auth runs INSIDE Convex (single user table, no external DB)\n- Auth tables (user, session, account) managed by the Convex component \u2014 NOT in your schema\n- Server config: `convex/auth.ts` \u2014 `authComponent.adapter(ctx)` bridges Better Auth to Convex DB\n- Client: `src/lib/auth-client.ts` \u2014 `createAuthClient` with `convexClient()` plugin\n- Server utils: `src/lib/auth-server.ts` \u2014 `getToken`, `fetchAuthQuery`, etc.\n- API route: `src/routes/api.auth.$.ts` \u2014 catch-all handler for Better Auth endpoints\n- Root route uses `beforeLoad` to inject `isAuthenticated` + `token` into route context\n- Route protection: `if (!context.isAuthenticated) throw redirect({ to: "/login" })`\n- Client-side auth state: use `Authenticated`, `Unauthenticated`, `useConvexAuth()` from `convex/react`\n- Sign-in/up: `authClient.signIn.email({ email, password })` (client-side only)\n- Sign-out: use `location.reload()` in onSuccess callback (required with `expectAuth: true`)\n- Convex env vars (set via CLI, not .env.local): `BETTER_AUTH_SECRET`, `SITE_URL`\n- Do NOT create a separate user table in your Convex schema \u2014 use triggers for app-specific user data\n';async function O(e,t){t.includes("convex")||console.log(m.yellow(`
15
+ \u26A0 Better Auth ben\xF6tigt Convex. Convex wird automatisch mit installiert.
16
+ `)),await l("addons/better-auth",e),await u(e,{dependencies:p(se)}),await c(h.join(e,".env.local"),ae),await c(h.join(e,"CLAUDE.md"),oe);}var re=["@sanity/client","@sanity/image-url","@portabletext/react"],ce=`
17
+ # Sanity
18
+ VITE_SANITY_PROJECT_ID=
19
+ VITE_SANITY_DATASET=production
20
+ VITE_SANITY_API_VERSION=2025-01-01
21
+ `,le='\n\n## Sanity (Headless CMS)\n\n- Client configured in `src/lib/sanity.ts` using `@sanity/client`\n- Content fetched via GROQ queries in route loaders: `sanityClient.fetch(QUERY, params)`\n- Blog routes: `/blog` (layout), `/blog/` (list), `/blog/$slug` (detail)\n- Rich text rendered with `<PortableText value={body} />` from `@portabletext/react`\n- Images: use `urlFor(source).width(x).url()` helper from `src/lib/sanity.ts`\n- Sanity schemas in `sanity/schemaTypes/` \u2014 documents and objects\n- SEO object type available on documents (`metaTitle`, `metaDescription`, `ogImage`)\n- GROQ cheat sheet: `*[_type == "post"]{title, slug}`, `| order(publishedAt desc)`\n- Set `VITE_SANITY_PROJECT_ID` in `.env.local` before running\n';async function V(e){await l("addons/sanity",e),await u(e,{dependencies:p(re)}),await c(h.join(e,".env.local"),ce),await c(h.join(e,"CLAUDE.md"),le);}var de=["resend"],pe=`
22
+ # Resend
23
+ RESEND_API_KEY=
24
+ `,ue="\n\n## Resend (Email)\n\n- Client configured in `src/lib/email.ts` using the `resend` SDK\n- `sendEmail({ to, subject, html })` helper \u2014 call from server-side code only (loaders, API routes)\n- `RESEND_API_KEY` must be set in `.env.local` (not `VITE_` prefixed \u2014 server-only)\n- Default `from` is `onboarding@resend.dev` \u2014 change to your verified domain\n- For React email templates, consider adding `@react-email/components`\n";async function L(e){await l("addons/resend",e),await u(e,{dependencies:p(de)}),await c(h.join(e,".env.local"),pe),await c(h.join(e,"CLAUDE.md"),ue);}var ge='\n\n## Advanced SEO\n\n- `seo()` helper in `src/lib/seo.ts` \u2014 generates meta + links arrays for `head()`\n Usage: `head: () => ({ ...seo({ title: "Page", description: "..." }) })`\n- JSON-LD helpers in `src/lib/schema-org.ts`: `organizationSchema`, `webSiteSchema`, `articleSchema`, `breadcrumbSchema`\n- `public/robots.txt` \u2014 update Sitemap URL before deploying\n- `public/llms.txt` \u2014 LLM-readable site description, update with actual content\n- Always set `title` and `description` on every route via `head()`\n- Use `canonicalUrl` for pages accessible at multiple URLs\n- Use `noIndex: true` for pages that should not be indexed\n';async function j(e){await l("addons/seo",e),await c(h.join(e,"CLAUDE.md"),ge);}async function F(e){try{return await execa("git",["init"],{cwd:e}),await execa("git",["add","-A"],{cwd:e}),await execa("git",["commit","-m","Initial commit from create-loumi-app"],{cwd:e}),!0}catch{return false}}var he=[{repo:"vercel-labs/agent-skills",skill:"vercel-react-best-practices"},{repo:"vercel-labs/agent-skills",skill:"web-design-guidelines"},{repo:"anthropics/skills",skill:"frontend-design"},{repo:"sickn33/antigravity-awesome-skills",skill:"clean-code"},{repo:"deckardger/tanstack-agent-skills",skill:"tanstack-start-best-practices"}],ve={convex:[{repo:"jezweb/claude-skills",skill:"tanstack-query"},{repo:"waynesutton/convexskills"}],sanity:[{repo:"sanity-io/agent-toolkit",skill:"sanity-best-practices"}],seo:[{repo:"coreyhaines31/marketingskills",skill:"seo-audit"}],resend:[{repo:"resend/email-best-practices",skill:"email-best-practices"}],"better-auth":[{repo:"better-auth/skills",skill:"better-auth-best-practices"}]};async function ye(e,t){try{let s=["skills","add",e.repo,"-y"];return e.skill&&s.push("-s",e.skill),await execa("npx",s,{cwd:t,stdio:"ignore"}),!0}catch{return false}}async function J(e,t){let s=[...he];for(let i of t){let d=ve[i];d&&s.push(...d);}let o=0,n=0;for(let i of s)await ye(i,e)?o++:n++;return {installed:o,failed:n}}async function b(e){let{projectName:t,installDeps:s,initGit:o}=e,n=[...e.addons],i=h.resolve(process.cwd(),t);await g.pathExists(i)&&(await g.readdir(i)).length>0&&(console.log(m.red(`
25
+ Error: Verzeichnis "${t}" ist nicht leer.
26
+ `)),process.exit(1)),await g.ensureDir(i),n.includes("better-auth")&&!n.includes("convex")&&n.push("convex");let d=f("Base Template kopieren...").start();if(await P(i,t),d.succeed("Base Template erstellt"),n.includes("convex")){let r=f("Convex einrichten...").start();await I(i),r.succeed("Convex eingerichtet");}if(n.includes("better-auth")){let r=f("Better Auth einrichten...").start();await O(i,n),r.succeed("Better Auth eingerichtet");}if(n.includes("sanity")){let r=f("Sanity einrichten...").start();await V(i),r.succeed("Sanity eingerichtet");}if(n.includes("resend")){let r=f("Resend einrichten...").start();await L(i),r.succeed("Resend eingerichtet");}if(n.includes("seo")){let r=f("Advanced SEO einrichten...").start();await j(i),r.succeed("Advanced SEO eingerichtet");}let w=f("Claude Skills installieren...").start(),{installed:k,failed:S}=await J(i,n);if(S===0?w.succeed(`${k} Claude Skills installiert`):w.warn(`${k} Skills installiert, ${S} fehlgeschlagen`),s){let r=f("Dependencies installieren...").start();try{let v=we();await execa(v,["install"],{cwd:i}),r.succeed(`Dependencies installiert (${v})`);}catch{r.fail("Dependencies konnten nicht installiert werden"),console.log(m.yellow(` F\xFChre manuell 'npm install' aus.
27
+ `));}}if(o){let r=f("Git initialisieren...").start();await F(i)?r.succeed("Git Repository initialisiert"):r.fail("Git konnte nicht initialisiert werden");}console.log(),console.log(m.green("\u2714 Projekt erstellt!")),console.log(),console.log(" N\xE4chste Schritte:"),console.log(m.cyan(` cd ${t}`)),s||console.log(m.cyan(" npm install")),n.includes("convex")&&console.log(m.cyan(" npx convex dev")),console.log(m.cyan(" npm run dev")),console.log(),n.includes("sanity")&&console.log(m.dim(" Vergiss nicht VITE_SANITY_PROJECT_ID in .env.local zu setzen!")),n.includes("better-auth")&&console.log(m.dim(" Better Auth Setup: npx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)")),n.includes("resend")&&console.log(m.dim(" Vergiss nicht RESEND_API_KEY in .env.local zu setzen!")),console.log();}function we(){let e=process.env.npm_config_user_agent;if(e){if(e.startsWith("pnpm"))return "pnpm";if(e.startsWith("yarn"))return "yarn";if(e.startsWith("bun"))return "bun"}return "npm"}var q=new Command;q.name("create-loumi-app").description("Scaffold a new Loumi web project").version("1.0.0").argument("[project-name]","Name of the project").option("--skip-install","Skip dependency installation").option("--add-ons <addons>","Comma-separated list of add-ons (convex,better-auth,sanity,resend,seo)").action(async(e,t)=>{if(e&&t.addOns!==void 0){let o=t.addOns.split(",").map(d=>d.trim().toLowerCase()).filter(Boolean),n=["convex","better-auth","sanity","resend","seo"],i=o.filter(d=>!n.includes(d));i.length>0&&(console.error(`Unknown add-ons: ${i.join(", ")}`),console.error(`Valid add-ons: ${n.join(", ")}`),process.exit(1)),await b({projectName:e,addons:o,installDeps:!t.skipInstall,initGit:true});return}let s=await E(e);s||process.exit(0),await b(s);});q.parse();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "create-loumi-app",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool to scaffold Loumi web projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-loumi-app": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "template"
12
+ ],
13
+ "keywords": [
14
+ "cli",
15
+ "scaffold",
16
+ "tanstack",
17
+ "cloudflare",
18
+ "loumi"
19
+ ],
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@clack/prompts": "^0.10.0",
23
+ "chalk": "^5.4.1",
24
+ "commander": "^13.1.0",
25
+ "execa": "^9.5.2",
26
+ "fs-extra": "^11.3.0",
27
+ "ora": "^8.2.0",
28
+ "sort-package-json": "^2.14.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/fs-extra": "^11.0.4",
32
+ "@types/node": "^22.13.1",
33
+ "tsup": "^8.3.6",
34
+ "typescript": "^5.7.3"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup",
38
+ "dev": "tsup --watch"
39
+ }
40
+ }
@@ -0,0 +1,6 @@
1
+ import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
2
+ import type { AuthConfig } from "convex/server";
3
+
4
+ export default {
5
+ providers: [getAuthConfigProvider()],
6
+ } satisfies AuthConfig;
@@ -0,0 +1,34 @@
1
+ import { betterAuth } from "better-auth/minimal";
2
+ import { createClient } from "@convex-dev/better-auth";
3
+ import { convex } from "@convex-dev/better-auth/plugins";
4
+ import authConfig from "./auth.config";
5
+ import { components } from "./_generated/api";
6
+ import { query } from "./_generated/server";
7
+ import type { GenericCtx } from "@convex-dev/better-auth";
8
+ import type { DataModel } from "./_generated/dataModel";
9
+
10
+ const siteUrl = process.env.SITE_URL!;
11
+
12
+ // Better Auth component client — uses Convex as the database (single user table)
13
+ export const authComponent = createClient<DataModel>(components.betterAuth);
14
+
15
+ // Create auth instance with Convex adapter — NO external DB needed
16
+ export const createAuth = (ctx: GenericCtx<DataModel>) => {
17
+ return betterAuth({
18
+ baseURL: siteUrl,
19
+ database: authComponent.adapter(ctx),
20
+ emailAndPassword: {
21
+ enabled: true,
22
+ requireEmailVerification: false,
23
+ },
24
+ plugins: [convex({ authConfig })],
25
+ });
26
+ };
27
+
28
+ // Query to get the current authenticated user
29
+ export const getCurrentUser = query({
30
+ args: {},
31
+ handler: async (ctx) => {
32
+ return await authComponent.getAuthUser(ctx);
33
+ },
34
+ });
@@ -0,0 +1,6 @@
1
+ import { defineApp } from "convex/server";
2
+ import betterAuth from "@convex-dev/better-auth/convex.config";
3
+
4
+ const app = defineApp();
5
+ app.use(betterAuth);
6
+ export default app;
@@ -0,0 +1,6 @@
1
+ import { httpRouter } from "convex/server";
2
+ import { authComponent, createAuth } from "./auth";
3
+
4
+ const http = httpRouter();
5
+ authComponent.registerRoutes(http, createAuth);
6
+ export default http;
@@ -0,0 +1,6 @@
1
+ import { createAuthClient } from "better-auth/react";
2
+ import { convexClient } from "@convex-dev/better-auth/client/plugins";
3
+
4
+ export const authClient = createAuthClient({
5
+ plugins: [convexClient()],
6
+ });
@@ -0,0 +1,12 @@
1
+ import { convexBetterAuthReactStart } from "@convex-dev/better-auth/react-start";
2
+
3
+ export const {
4
+ handler,
5
+ getToken,
6
+ fetchAuthQuery,
7
+ fetchAuthMutation,
8
+ fetchAuthAction,
9
+ } = convexBetterAuthReactStart({
10
+ convexUrl: import.meta.env.VITE_CONVEX_URL!,
11
+ convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
12
+ });
@@ -0,0 +1,39 @@
1
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
2
+ import { routerWithQueryClient } from "@tanstack/react-router-with-query";
3
+ import { QueryClient } from "@tanstack/react-query";
4
+ import { ConvexQueryClient } from "@convex-dev/react-query";
5
+ import { routeTree } from "./routeTree.gen";
6
+
7
+ export function createRouter() {
8
+ const CONVEX_URL = import.meta.env.VITE_CONVEX_URL as string;
9
+ if (!CONVEX_URL) throw new Error("VITE_CONVEX_URL is not set");
10
+
11
+ const convexQueryClient = new ConvexQueryClient(CONVEX_URL, {
12
+ expectAuth: true,
13
+ });
14
+ const queryClient = new QueryClient({
15
+ defaultOptions: {
16
+ queries: {
17
+ queryKeyHashFn: convexQueryClient.hashFn(),
18
+ queryFn: convexQueryClient.queryFn(),
19
+ },
20
+ },
21
+ });
22
+ convexQueryClient.connect(queryClient);
23
+
24
+ return routerWithQueryClient(
25
+ createTanStackRouter({
26
+ routeTree,
27
+ defaultPreload: "intent",
28
+ defaultPreloadStaleTime: 0,
29
+ context: { queryClient, convexQueryClient },
30
+ }),
31
+ queryClient,
32
+ );
33
+ }
34
+
35
+ declare module "@tanstack/react-router" {
36
+ interface Register {
37
+ router: ReturnType<typeof createRouter>;
38
+ }
39
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ HeadContent,
3
+ Outlet,
4
+ Scripts,
5
+ createRootRouteWithContext,
6
+ } from "@tanstack/react-router";
7
+ import { createServerFn } from "@tanstack/react-start";
8
+ import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
9
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10
+ import type { ConvexQueryClient } from "@convex-dev/react-query";
11
+ import appCss from "~/styles/app.css?url";
12
+ import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
13
+ import { NotFound } from "~/components/NotFound";
14
+ import { authClient } from "~/lib/auth-client";
15
+ import { getToken } from "~/lib/auth-server";
16
+
17
+ const getAuth = createServerFn({ method: "GET" }).handler(async () => {
18
+ return await getToken();
19
+ });
20
+
21
+ export const Route = createRootRouteWithContext<{
22
+ queryClient: QueryClient;
23
+ convexQueryClient: ConvexQueryClient;
24
+ }>()({
25
+ head: () => ({
26
+ meta: [
27
+ { charSet: "utf-8" },
28
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
29
+ { title: "Loumi App" },
30
+ ],
31
+ links: [{ rel: "stylesheet", href: appCss }],
32
+ }),
33
+ beforeLoad: async ({ context }) => {
34
+ const token = await getAuth();
35
+ if (token) {
36
+ context.convexQueryClient.serverHttpClient?.setAuth(token);
37
+ }
38
+ return { isAuthenticated: !!token, token };
39
+ },
40
+ errorComponent: (props) => (
41
+ <RootDocument>
42
+ <DefaultCatchBoundary {...props} />
43
+ </RootDocument>
44
+ ),
45
+ notFoundComponent: () => <NotFound />,
46
+ component: RootComponent,
47
+ });
48
+
49
+ function RootComponent() {
50
+ const { convexQueryClient, token } = Route.useRouteContext();
51
+
52
+ return (
53
+ <RootDocument>
54
+ <ConvexBetterAuthProvider
55
+ client={convexQueryClient.convexClient}
56
+ authClient={authClient}
57
+ initialToken={token}
58
+ >
59
+ <QueryClientProvider client={Route.useRouteContext().queryClient}>
60
+ <Outlet />
61
+ </QueryClientProvider>
62
+ </ConvexBetterAuthProvider>
63
+ </RootDocument>
64
+ );
65
+ }
66
+
67
+ function RootDocument({ children }: { children: React.ReactNode }) {
68
+ return (
69
+ <html lang="en">
70
+ <head>
71
+ <HeadContent />
72
+ </head>
73
+ <body>
74
+ {children}
75
+ <Scripts />
76
+ </body>
77
+ </html>
78
+ );
79
+ }
@@ -0,0 +1,7 @@
1
+ import { createAPIFileRoute } from "@tanstack/react-start/api";
2
+ import { handler } from "~/lib/auth-server";
3
+
4
+ export const APIRoute = createAPIFileRoute("/api/auth/$")({
5
+ GET: ({ request }) => handler(request),
6
+ POST: ({ request }) => handler(request),
7
+ });
@@ -0,0 +1,9 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ tasks: defineTable({
6
+ text: v.string(),
7
+ completed: v.boolean(),
8
+ }),
9
+ });
@@ -0,0 +1,28 @@
1
+ import { query, mutation } from "./_generated/server";
2
+ import { v } from "convex/values";
3
+
4
+ export const list = query({
5
+ args: {},
6
+ handler: async (ctx) => {
7
+ return await ctx.db.query("tasks").collect();
8
+ },
9
+ });
10
+
11
+ export const create = mutation({
12
+ args: { text: v.string() },
13
+ handler: async (ctx, args) => {
14
+ await ctx.db.insert("tasks", {
15
+ text: args.text,
16
+ completed: false,
17
+ });
18
+ },
19
+ });
20
+
21
+ export const toggle = mutation({
22
+ args: { id: v.id("tasks") },
23
+ handler: async (ctx, args) => {
24
+ const task = await ctx.db.get(args.id);
25
+ if (!task) throw new Error("Task not found");
26
+ await ctx.db.patch(args.id, { completed: !task.completed });
27
+ },
28
+ });
@@ -0,0 +1,37 @@
1
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
2
+ import { routerWithQueryClient } from "@tanstack/react-router-with-query";
3
+ import { QueryClient } from "@tanstack/react-query";
4
+ import { ConvexQueryClient } from "@convex-dev/react-query";
5
+ import { routeTree } from "./routeTree.gen";
6
+
7
+ export function createRouter() {
8
+ const CONVEX_URL = import.meta.env.VITE_CONVEX_URL as string;
9
+ if (!CONVEX_URL) throw new Error("VITE_CONVEX_URL is not set");
10
+
11
+ const convexQueryClient = new ConvexQueryClient(CONVEX_URL);
12
+ const queryClient = new QueryClient({
13
+ defaultOptions: {
14
+ queries: {
15
+ queryKeyHashFn: convexQueryClient.hashFn(),
16
+ queryFn: convexQueryClient.queryFn(),
17
+ },
18
+ },
19
+ });
20
+ convexQueryClient.connect(queryClient);
21
+
22
+ return routerWithQueryClient(
23
+ createTanStackRouter({
24
+ routeTree,
25
+ defaultPreload: "intent",
26
+ defaultPreloadStaleTime: 0,
27
+ context: { queryClient },
28
+ }),
29
+ queryClient,
30
+ );
31
+ }
32
+
33
+ declare module "@tanstack/react-router" {
34
+ interface Register {
35
+ router: ReturnType<typeof createRouter>;
36
+ }
37
+ }
@@ -0,0 +1,61 @@
1
+ import {
2
+ HeadContent,
3
+ Outlet,
4
+ Scripts,
5
+ createRootRouteWithContext,
6
+ } from "@tanstack/react-router";
7
+ import { ConvexProvider } from "convex/react";
8
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
9
+ import type { ConvexReactClient } from "convex/react";
10
+ import appCss from "~/styles/app.css?url";
11
+ import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
12
+ import { NotFound } from "~/components/NotFound";
13
+
14
+ export const Route = createRootRouteWithContext<{
15
+ queryClient: QueryClient;
16
+ }>()({
17
+ head: () => ({
18
+ meta: [
19
+ { charSet: "utf-8" },
20
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
21
+ { title: "Loumi App" },
22
+ ],
23
+ links: [{ rel: "stylesheet", href: appCss }],
24
+ }),
25
+ errorComponent: (props) => (
26
+ <RootDocument>
27
+ <DefaultCatchBoundary {...props} />
28
+ </RootDocument>
29
+ ),
30
+ notFoundComponent: () => <NotFound />,
31
+ component: RootComponent,
32
+ });
33
+
34
+ function RootComponent() {
35
+ return (
36
+ <RootDocument>
37
+ <Outlet />
38
+ </RootDocument>
39
+ );
40
+ }
41
+
42
+ function RootDocument({ children }: { children: React.ReactNode }) {
43
+ const queryClient = Route.useRouteContext({ select: (s) => s.queryClient });
44
+ const convexClient = (queryClient as any).__convexClient as ConvexReactClient;
45
+
46
+ return (
47
+ <html lang="en">
48
+ <head>
49
+ <HeadContent />
50
+ </head>
51
+ <body>
52
+ <ConvexProvider client={convexClient}>
53
+ <QueryClientProvider client={queryClient}>
54
+ {children}
55
+ </QueryClientProvider>
56
+ </ConvexProvider>
57
+ <Scripts />
58
+ </body>
59
+ </html>
60
+ );
61
+ }
@@ -0,0 +1,53 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { convexQuery } from "@convex-dev/react-query";
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { api } from "../../convex/_generated/api";
5
+
6
+ export const Route = createFileRoute("/")({
7
+ loader: async ({ context }) => {
8
+ await context.queryClient.ensureQueryData(
9
+ convexQuery(api.tasks.list, {}),
10
+ );
11
+ },
12
+ component: Home,
13
+ });
14
+
15
+ function Home() {
16
+ const { data: tasks } = useSuspenseQuery(
17
+ convexQuery(api.tasks.list, {}),
18
+ );
19
+
20
+ return (
21
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
22
+ <div className="text-center">
23
+ <h1 className="text-5xl font-bold text-white mb-4">
24
+ Welcome to Loumi
25
+ </h1>
26
+ <p className="text-xl text-gray-300 mb-8">
27
+ Built with TanStack Start, Convex & Cloudflare Workers
28
+ </p>
29
+ <div className="mt-8 text-left max-w-md mx-auto">
30
+ <h2 className="text-2xl font-semibold text-white mb-4">Tasks</h2>
31
+ {tasks.length === 0 ? (
32
+ <p className="text-gray-400">No tasks yet. Add one in your Convex dashboard!</p>
33
+ ) : (
34
+ <ul className="space-y-2">
35
+ {tasks.map((task) => (
36
+ <li
37
+ key={task._id}
38
+ className="flex items-center gap-3 p-3 bg-gray-700/50 rounded-lg"
39
+ >
40
+ <span
41
+ className={`text-white ${task.completed ? "line-through opacity-50" : ""}`}
42
+ >
43
+ {task.text}
44
+ </span>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ )}
49
+ </div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,33 @@
1
+ import { Resend } from "resend";
2
+
3
+ const resend = new Resend(process.env.RESEND_API_KEY);
4
+
5
+ interface SendEmailOptions {
6
+ to: string | string[];
7
+ subject: string;
8
+ html: string;
9
+ from?: string;
10
+ replyTo?: string;
11
+ }
12
+
13
+ export async function sendEmail({
14
+ to,
15
+ subject,
16
+ html,
17
+ from = "onboarding@resend.dev",
18
+ replyTo,
19
+ }: SendEmailOptions) {
20
+ const { data, error } = await resend.emails.send({
21
+ from,
22
+ to: Array.isArray(to) ? to : [to],
23
+ subject,
24
+ html,
25
+ ...(replyTo && { replyTo }),
26
+ });
27
+
28
+ if (error) {
29
+ throw new Error(`Failed to send email: ${error.message}`);
30
+ }
31
+
32
+ return data;
33
+ }
@@ -0,0 +1,44 @@
1
+ import { defineType, defineField } from "sanity";
2
+
3
+ export const post = defineType({
4
+ name: "post",
5
+ title: "Post",
6
+ type: "document",
7
+ fields: [
8
+ defineField({
9
+ name: "title",
10
+ title: "Title",
11
+ type: "string",
12
+ validation: (rule) => rule.required(),
13
+ }),
14
+ defineField({
15
+ name: "slug",
16
+ title: "Slug",
17
+ type: "slug",
18
+ options: { source: "title", maxLength: 96 },
19
+ validation: (rule) => rule.required(),
20
+ }),
21
+ defineField({
22
+ name: "publishedAt",
23
+ title: "Published At",
24
+ type: "datetime",
25
+ }),
26
+ defineField({
27
+ name: "excerpt",
28
+ title: "Excerpt",
29
+ type: "text",
30
+ rows: 3,
31
+ }),
32
+ defineField({
33
+ name: "body",
34
+ title: "Body",
35
+ type: "array",
36
+ of: [{ type: "block" }],
37
+ }),
38
+ defineField({
39
+ name: "seo",
40
+ title: "SEO",
41
+ type: "seo",
42
+ }),
43
+ ],
44
+ });
@@ -0,0 +1,4 @@
1
+ import { post } from "./documents/post";
2
+ import { seo } from "./objects/seo";
3
+
4
+ export const schemaTypes = [post, seo];
@@ -0,0 +1,27 @@
1
+ import { defineType, defineField } from "sanity";
2
+
3
+ export const seo = defineType({
4
+ name: "seo",
5
+ title: "SEO",
6
+ type: "object",
7
+ fields: [
8
+ defineField({
9
+ name: "metaTitle",
10
+ title: "Meta Title",
11
+ type: "string",
12
+ validation: (rule) => rule.max(70).warning("Should be under 70 characters"),
13
+ }),
14
+ defineField({
15
+ name: "metaDescription",
16
+ title: "Meta Description",
17
+ type: "text",
18
+ rows: 3,
19
+ validation: (rule) => rule.max(160).warning("Should be under 160 characters"),
20
+ }),
21
+ defineField({
22
+ name: "ogImage",
23
+ title: "Open Graph Image",
24
+ type: "image",
25
+ }),
26
+ ],
27
+ });
@@ -0,0 +1,16 @@
1
+ import { createClient } from "@sanity/client";
2
+ import imageUrlBuilder from "@sanity/image-url";
3
+ import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
4
+
5
+ export const sanityClient = createClient({
6
+ projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
7
+ dataset: import.meta.env.VITE_SANITY_DATASET || "production",
8
+ apiVersion: import.meta.env.VITE_SANITY_API_VERSION || "2025-01-01",
9
+ useCdn: true,
10
+ });
11
+
12
+ const builder = imageUrlBuilder(sanityClient);
13
+
14
+ export function urlFor(source: SanityImageSource) {
15
+ return builder.image(source);
16
+ }
@@ -0,0 +1,53 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { PortableText } from "@portabletext/react";
3
+ import { sanityClient } from "~/lib/sanity";
4
+
5
+ const POST_QUERY = `*[_type == "post" && slug.current == $slug][0] {
6
+ _id,
7
+ title,
8
+ slug,
9
+ publishedAt,
10
+ body,
11
+ "seo": seo {
12
+ metaTitle,
13
+ metaDescription
14
+ }
15
+ }`;
16
+
17
+ export const Route = createFileRoute("/blog/$slug")({
18
+ loader: async ({ params }) => {
19
+ const post = await sanityClient.fetch(POST_QUERY, { slug: params.slug });
20
+ if (!post) throw new Error("Post not found");
21
+ return { post };
22
+ },
23
+ head: ({ loaderData }) => {
24
+ const { post } = loaderData;
25
+ return {
26
+ meta: [
27
+ { title: post.seo?.metaTitle || post.title },
28
+ ...(post.seo?.metaDescription
29
+ ? [{ name: "description", content: post.seo.metaDescription }]
30
+ : []),
31
+ ],
32
+ };
33
+ },
34
+ component: BlogPost,
35
+ });
36
+
37
+ function BlogPost() {
38
+ const { post } = Route.useLoaderData();
39
+
40
+ return (
41
+ <article className="prose prose-invert max-w-none">
42
+ <h1 className="text-4xl font-bold text-white mb-2">{post.title}</h1>
43
+ {post.publishedAt && (
44
+ <time className="text-sm text-gray-500 block mb-8">
45
+ {new Date(post.publishedAt).toLocaleDateString()}
46
+ </time>
47
+ )}
48
+ <div className="text-gray-300">
49
+ <PortableText value={post.body} />
50
+ </div>
51
+ </article>
52
+ );
53
+ }
@@ -0,0 +1,52 @@
1
+ import { createFileRoute, Link } from "@tanstack/react-router";
2
+ import { sanityClient } from "~/lib/sanity";
3
+
4
+ const POSTS_QUERY = `*[_type == "post"] | order(publishedAt desc) {
5
+ _id,
6
+ title,
7
+ slug,
8
+ publishedAt,
9
+ excerpt
10
+ }`;
11
+
12
+ export const Route = createFileRoute("/blog/")({
13
+ loader: async () => {
14
+ const posts = await sanityClient.fetch(POSTS_QUERY);
15
+ return { posts };
16
+ },
17
+ component: BlogIndex,
18
+ });
19
+
20
+ function BlogIndex() {
21
+ const { posts } = Route.useLoaderData();
22
+
23
+ return (
24
+ <div className="space-y-8">
25
+ {posts.length === 0 ? (
26
+ <p className="text-gray-400">No posts yet. Create one in your Sanity Studio!</p>
27
+ ) : (
28
+ posts.map((post: any) => (
29
+ <article key={post._id} className="border-b border-gray-800 pb-8">
30
+ <Link
31
+ to="/blog/$slug"
32
+ params={{ slug: post.slug.current }}
33
+ className="group"
34
+ >
35
+ <h2 className="text-2xl font-semibold text-white group-hover:text-blue-400 transition-colors">
36
+ {post.title}
37
+ </h2>
38
+ </Link>
39
+ {post.publishedAt && (
40
+ <time className="text-sm text-gray-500 mt-1 block">
41
+ {new Date(post.publishedAt).toLocaleDateString()}
42
+ </time>
43
+ )}
44
+ {post.excerpt && (
45
+ <p className="text-gray-300 mt-3">{post.excerpt}</p>
46
+ )}
47
+ </article>
48
+ ))
49
+ )}
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,23 @@
1
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
2
+
3
+ export const Route = createFileRoute("/blog")({
4
+ component: BlogLayout,
5
+ });
6
+
7
+ function BlogLayout() {
8
+ return (
9
+ <div className="min-h-screen bg-gray-900">
10
+ <header className="border-b border-gray-800">
11
+ <div className="max-w-4xl mx-auto px-4 py-6">
12
+ <a href="/" className="text-gray-400 hover:text-white transition-colors">
13
+ &larr; Home
14
+ </a>
15
+ <h1 className="text-3xl font-bold text-white mt-2">Blog</h1>
16
+ </div>
17
+ </header>
18
+ <main className="max-w-4xl mx-auto px-4 py-8">
19
+ <Outlet />
20
+ </main>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,13 @@
1
+ # PROJECT_NAME
2
+
3
+ > A website built with Loumi — TanStack Start, Tailwind CSS & Cloudflare Workers.
4
+
5
+ ## About
6
+
7
+ This website was scaffolded with create-loumi-app.
8
+ For more information, visit the homepage.
9
+
10
+ ## Links
11
+
12
+ - Homepage: /
13
+ - Blog: /blog
@@ -0,0 +1,4 @@
1
+ User-agent: *
2
+ Allow: /
3
+
4
+ Sitemap: https://example.com/sitemap.xml
@@ -0,0 +1,90 @@
1
+ interface OrganizationSchema {
2
+ name: string;
3
+ url: string;
4
+ logo?: string;
5
+ }
6
+
7
+ interface WebSiteSchema {
8
+ name: string;
9
+ url: string;
10
+ description?: string;
11
+ }
12
+
13
+ interface ArticleSchema {
14
+ title: string;
15
+ description?: string;
16
+ url: string;
17
+ datePublished: string;
18
+ dateModified?: string;
19
+ authorName?: string;
20
+ image?: string;
21
+ }
22
+
23
+ interface BreadcrumbItem {
24
+ name: string;
25
+ url: string;
26
+ }
27
+
28
+ function jsonLdScript(data: Record<string, any>): string {
29
+ return `<script type="application/ld+json">${JSON.stringify(data)}</script>`;
30
+ }
31
+
32
+ export function organizationSchema({ name, url, logo }: OrganizationSchema): string {
33
+ return jsonLdScript({
34
+ "@context": "https://schema.org",
35
+ "@type": "Organization",
36
+ name,
37
+ url,
38
+ ...(logo && { logo }),
39
+ });
40
+ }
41
+
42
+ export function webSiteSchema({ name, url, description }: WebSiteSchema): string {
43
+ return jsonLdScript({
44
+ "@context": "https://schema.org",
45
+ "@type": "WebSite",
46
+ name,
47
+ url,
48
+ ...(description && { description }),
49
+ });
50
+ }
51
+
52
+ export function articleSchema({
53
+ title,
54
+ description,
55
+ url,
56
+ datePublished,
57
+ dateModified,
58
+ authorName,
59
+ image,
60
+ }: ArticleSchema): string {
61
+ return jsonLdScript({
62
+ "@context": "https://schema.org",
63
+ "@type": "Article",
64
+ headline: title,
65
+ url,
66
+ datePublished,
67
+ ...(dateModified && { dateModified }),
68
+ ...(description && { description }),
69
+ ...(image && { image }),
70
+ ...(authorName && {
71
+ author: {
72
+ "@type": "Person",
73
+ name: authorName,
74
+ },
75
+ }),
76
+ });
77
+ }
78
+
79
+ export function breadcrumbSchema(items: BreadcrumbItem[]): string {
80
+ return jsonLdScript({
81
+ "@context": "https://schema.org",
82
+ "@type": "BreadcrumbList",
83
+ itemListElement: items.map((item, index) => ({
84
+ "@type": "ListItem",
85
+ position: index + 1,
86
+ name: item.name,
87
+ item: item.url,
88
+ })),
89
+ });
90
+ }
@@ -0,0 +1,56 @@
1
+ interface SeoOptions {
2
+ title: string;
3
+ description?: string;
4
+ ogImage?: string;
5
+ ogType?: string;
6
+ twitterCard?: "summary" | "summary_large_image";
7
+ canonicalUrl?: string;
8
+ noIndex?: boolean;
9
+ }
10
+
11
+ export function seo({
12
+ title,
13
+ description,
14
+ ogImage,
15
+ ogType = "website",
16
+ twitterCard = "summary_large_image",
17
+ canonicalUrl,
18
+ noIndex = false,
19
+ }: SeoOptions) {
20
+ const meta: Array<Record<string, string>> = [{ title }];
21
+
22
+ if (description) {
23
+ meta.push({ name: "description", content: description });
24
+ }
25
+
26
+ if (noIndex) {
27
+ meta.push({ name: "robots", content: "noindex, nofollow" });
28
+ }
29
+
30
+ // Open Graph
31
+ meta.push({ property: "og:title", content: title });
32
+ if (description) {
33
+ meta.push({ property: "og:description", content: description });
34
+ }
35
+ meta.push({ property: "og:type", content: ogType });
36
+ if (ogImage) {
37
+ meta.push({ property: "og:image", content: ogImage });
38
+ }
39
+
40
+ // Twitter
41
+ meta.push({ name: "twitter:card", content: twitterCard });
42
+ meta.push({ name: "twitter:title", content: title });
43
+ if (description) {
44
+ meta.push({ name: "twitter:description", content: description });
45
+ }
46
+ if (ogImage) {
47
+ meta.push({ name: "twitter:image", content: ogImage });
48
+ }
49
+
50
+ const links: Array<Record<string, string>> = [];
51
+ if (canonicalUrl) {
52
+ links.push({ rel: "canonical", href: canonicalUrl });
53
+ }
54
+
55
+ return { meta, links };
56
+ }
@@ -0,0 +1,41 @@
1
+ # Project Guide
2
+
3
+ ## Stack
4
+
5
+ - **Framework:** TanStack Start (React, file-based routing)
6
+ - **Router:** TanStack Router (`src/routes/` directory, `createFileRoute`)
7
+ - **Styling:** Tailwind CSS v4 (Vite plugin, `@import "tailwindcss"`, `@theme` for tokens)
8
+ - **Hosting:** Cloudflare Workers (Wrangler, `wrangler.jsonc`)
9
+ - **Build:** Vite + Vinxi (`app.config.ts`)
10
+ - **Language:** TypeScript (strict mode)
11
+
12
+ ## Project Structure
13
+
14
+ ```
15
+ src/
16
+ ├── routes/ # File-based routes (TanStack Router)
17
+ │ ├── __root.tsx # Root layout (HTML shell, head, styles)
18
+ │ └── index.tsx # Home page (/)
19
+ ├── components/ # Shared components
20
+ ├── styles/
21
+ │ └── app.css # Tailwind entry point
22
+ └── router.tsx # Router configuration
23
+ ```
24
+
25
+ ## Conventions
26
+
27
+ - Path alias: `~/` maps to `src/`
28
+ - Routes use TanStack Router conventions: `createFileRoute`, `createRootRoute`
29
+ - Route data loading uses `loader` function (server-side)
30
+ - Head/meta tags use the `head()` route option
31
+ - CSS uses Tailwind v4 — no `tailwind.config.js`, use `@theme` in CSS instead
32
+ - Environment variables prefixed with `VITE_` are exposed to the client
33
+
34
+ ## Commands
35
+
36
+ ```bash
37
+ pnpm dev # Start dev server (Vinxi)
38
+ pnpm build # Production build
39
+ pnpm preview # Preview with Wrangler (local Cloudflare)
40
+ pnpm deploy # Build + deploy to Cloudflare Workers
41
+ ```
@@ -0,0 +1,14 @@
1
+ node_modules/
2
+ dist/
3
+ .output/
4
+ .vinxi/
5
+ .wrangler/
6
+ .env
7
+ .env.local
8
+ .env.*.local
9
+ *.log
10
+ .DS_Store
11
+ .convex/
12
+
13
+ # AI
14
+ CLAUDE.md
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "@tanstack/react-start/config";
2
+ import tailwindcss from "@tailwindcss/vite";
3
+
4
+ export default defineConfig({
5
+ vite: {
6
+ plugins: () => [tailwindcss()],
7
+ },
8
+ });
File without changes
@@ -0,0 +1,47 @@
1
+ import {
2
+ ErrorComponent,
3
+ Link,
4
+ rootRouteId,
5
+ useMatch,
6
+ useRouter,
7
+ } from "@tanstack/react-router";
8
+ import type { ErrorComponentProps } from "@tanstack/react-router";
9
+
10
+ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
11
+ const router = useRouter();
12
+ const isRoot = useMatch({
13
+ strict: false,
14
+ select: (state) => state.id === rootRouteId,
15
+ });
16
+
17
+ return (
18
+ <div className="min-h-screen flex items-center justify-center bg-gray-900 p-4">
19
+ <div className="text-center">
20
+ <ErrorComponent error={error} />
21
+ <div className="mt-6 flex gap-4 justify-center">
22
+ <button
23
+ onClick={() => router.invalidate()}
24
+ className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
25
+ >
26
+ Try Again
27
+ </button>
28
+ {isRoot ? (
29
+ <a
30
+ href="/"
31
+ className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
32
+ >
33
+ Home
34
+ </a>
35
+ ) : (
36
+ <Link
37
+ to="/"
38
+ className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
39
+ >
40
+ Home
41
+ </Link>
42
+ )}
43
+ </div>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,18 @@
1
+ import { Link } from "@tanstack/react-router";
2
+
3
+ export function NotFound() {
4
+ return (
5
+ <div className="min-h-screen flex items-center justify-center bg-gray-900">
6
+ <div className="text-center">
7
+ <h1 className="text-6xl font-bold text-white mb-4">404</h1>
8
+ <p className="text-xl text-gray-300 mb-8">Page not found</p>
9
+ <Link
10
+ to="/"
11
+ className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
12
+ >
13
+ Go Home
14
+ </Link>
15
+ </div>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,16 @@
1
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
2
+ import { routeTree } from "./routeTree.gen";
3
+
4
+ export function createRouter() {
5
+ return createTanStackRouter({
6
+ routeTree,
7
+ defaultPreload: "intent",
8
+ defaultPreloadStaleTime: 0,
9
+ });
10
+ }
11
+
12
+ declare module "@tanstack/react-router" {
13
+ interface Register {
14
+ router: ReturnType<typeof createRouter>;
15
+ }
16
+ }
@@ -0,0 +1,49 @@
1
+ import {
2
+ HeadContent,
3
+ Outlet,
4
+ Scripts,
5
+ createRootRoute,
6
+ } from "@tanstack/react-router";
7
+ import appCss from "~/styles/app.css?url";
8
+ import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
9
+ import { NotFound } from "~/components/NotFound";
10
+
11
+ export const Route = createRootRoute({
12
+ head: () => ({
13
+ meta: [
14
+ { charSet: "utf-8" },
15
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
16
+ { title: "Loumi App" },
17
+ ],
18
+ links: [{ rel: "stylesheet", href: appCss }],
19
+ }),
20
+ errorComponent: (props) => (
21
+ <RootDocument>
22
+ <DefaultCatchBoundary {...props} />
23
+ </RootDocument>
24
+ ),
25
+ notFoundComponent: () => <NotFound />,
26
+ component: RootComponent,
27
+ });
28
+
29
+ function RootComponent() {
30
+ return (
31
+ <RootDocument>
32
+ <Outlet />
33
+ </RootDocument>
34
+ );
35
+ }
36
+
37
+ function RootDocument({ children }: { children: React.ReactNode }) {
38
+ return (
39
+ <html lang="en">
40
+ <head>
41
+ <HeadContent />
42
+ </head>
43
+ <body>
44
+ {children}
45
+ <Scripts />
46
+ </body>
47
+ </html>
48
+ );
49
+ }
@@ -0,0 +1,28 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ export const Route = createFileRoute("/")({
4
+ component: Home,
5
+ });
6
+
7
+ function Home() {
8
+ return (
9
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
10
+ <div className="text-center">
11
+ <h1 className="text-5xl font-bold text-white mb-4">
12
+ Welcome to Loumi
13
+ </h1>
14
+ <p className="text-xl text-gray-300 mb-8">
15
+ Built with TanStack Start, Tailwind CSS & Cloudflare Workers
16
+ </p>
17
+ <a
18
+ href="https://tanstack.com/start"
19
+ target="_blank"
20
+ rel="noopener noreferrer"
21
+ className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
22
+ >
23
+ Get Started
24
+ </a>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,7 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
5
+ --color-primary: #3b82f6;
6
+ --color-primary-dark: #2563eb;
7
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "jsx": "react-jsx",
10
+ "paths": {
11
+ "~/*": ["./src/*"]
12
+ },
13
+ "outDir": "dist"
14
+ },
15
+ "include": ["src", "app.config.ts", "**/*.ts", "**/*.tsx"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "PROJECT_NAME",
3
+ "compatibility_date": "2025-01-01",
4
+ "main": ".output/worker/_worker.js",
5
+ "assets": {
6
+ "directory": ".output/worker/assets"
7
+ }
8
+ }