create-apollo-monorepo 0.1.0 → 0.3.1

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 (3) hide show
  1. package/README.md +35 -1
  2. package/index.mjs +322 -32
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -33,6 +33,34 @@ thamc-new/
33
33
  └── .gitignore
34
34
  ```
35
35
 
36
+ ## Routing modes
37
+
38
+ ### Single-origin (default)
39
+
40
+ Both apps share one public origin (the frontend). The frontend's
41
+ `next.config.ts` rewrites these paths to the backend so `/_next/*` doesn't
42
+ collide:
43
+
44
+ | Browser path | Goes to |
45
+ | ------------------------------------ | -------------------- |
46
+ | `/`, your custom routes | `apps/frontend` |
47
+ | `/admin/*` | `apps/backend` |
48
+ | `/api/auth/*`, `/api/v1/*`, `/api/admin/*`, `/api/email/*`, `/api/cron`, `/api/health`, `/api/mcp`, `/api/editing-presence/*` | `apps/backend` |
49
+ | `/uploads/*` | `apps/backend` (media) |
50
+ | `/cms-assets/*` | `apps/backend` chunks (via `APOLLO_ASSET_PREFIX`) |
51
+ | `/monitoring` | `apps/backend` (Sentry tunnel, no-op without DSN) |
52
+
53
+ Apollo CMS picks up `APOLLO_ASSET_PREFIX=/cms-assets` and serves its built JS
54
+ under that namespace, sidestepping the `/_next/*` collision. The frontend
55
+ **must not** define routes at `/admin`, `/api/auth`, `/api/v1`, etc.
56
+
57
+ ### Separate origins (fallback)
58
+
59
+ Pass `--asset-prefix none` (or `off`/`false`/`disabled`) to skip the rewrite
60
+ wiring. The backend runs at `http://localhost:3000` and the frontend at
61
+ `http://localhost:3001`. Useful when you'd rather deploy them on separate
62
+ subdomains (e.g. `cms.example.com` + `example.com`).
63
+
36
64
  ## Flags
37
65
 
38
66
  | Flag | Default | Description |
@@ -41,8 +69,9 @@ thamc-new/
41
69
  | `--backend-url <url>` | `https://github.com/5Lab-Group-Co-Ltd/apollo-cms.git` | Submodule git URL |
42
70
  | `--backend-branch <name>` | `main` | Submodule branch to track |
43
71
  | `-d, --db <url>` | _(prompted)_ | `DATABASE_URL` for backend |
44
- | `-u, --url <url>` | `http://localhost:3000` | `NEXT_PUBLIC_SITE_URL` |
72
+ | `-u, --url <url>` | `:3001` single-origin / `:3000` separate | `NEXT_PUBLIC_SITE_URL` |
45
73
  | `-l, --locale <code>` | `en` | `NEXT_PUBLIC_DEFAULT_LOCALE` |
74
+ | `--asset-prefix <path>` | `/cms-assets` | Single-origin asset namespace; `none` to disable |
46
75
  | `--skip-install` | off | Don't run `pnpm install` |
47
76
  | `--skip-submodule` | off | Don't add the git submodule |
48
77
  | `-h, --help` | — | Show help |
@@ -55,6 +84,11 @@ pnpm backend:setup # push schema + seed apollo-cms
55
84
  pnpm dev # frontend :3001 + backend :3000 in parallel
56
85
  ```
57
86
 
87
+ In single-origin mode open `http://localhost:3001/admin` (the frontend port);
88
+ the rewrite proxies to the backend and Better Auth's cookies/origins line up
89
+ because `NEXT_PUBLIC_SITE_URL` and the backend's `trustedProxyHeaders` are wired
90
+ to the public origin.
91
+
58
92
  ## Updating the backend
59
93
 
60
94
  ```bash
package/index.mjs CHANGED
@@ -54,11 +54,21 @@ ${COLORS.bold}Flags:${COLORS.reset}
54
54
  --backend-url <url> Backend git URL (default: ${BACKEND_REPO_URL})
55
55
  --backend-branch <name> Backend branch to track (default: ${BACKEND_BRANCH})
56
56
  -d, --db <url> DATABASE_URL for backend
57
- -u, --url <url> NEXT_PUBLIC_SITE_URL (default: http://localhost:3000)
57
+ -u, --url <url> NEXT_PUBLIC_SITE_URL (default: http://localhost:3001 single-origin / 3000 separate)
58
58
  -l, --locale <code> NEXT_PUBLIC_DEFAULT_LOCALE (default: en)
59
+ --asset-prefix <path> Single-origin asset namespace (default: /cms-assets, or "none" to disable)
59
60
  --skip-install Skip dependency installation
60
61
  --skip-submodule Skip git submodule add (you'll add it later)
61
62
  -h, --help Show this help message
63
+
64
+ ${COLORS.bold}Single-origin model:${COLORS.reset}
65
+ The frontend is the public entry point. It rewrites these paths to the
66
+ backend so both apps live under one domain without /_next/* collisions:
67
+ /admin/* → backend
68
+ /api/* → backend (REST API + auth)
69
+ /uploads/* → backend (media)
70
+ <asset-prefix>/* → backend (Next.js chunks via APOLLO_ASSET_PREFIX)
71
+ Frontend MUST NOT define routes at /admin or /api/auth or /api/v1.
62
72
  `;
63
73
 
64
74
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -112,6 +122,7 @@ function parseArgs(argv) {
112
122
  db: null,
113
123
  url: null,
114
124
  locale: null,
125
+ assetPrefix: "/cms-assets",
115
126
  skipInstall: false,
116
127
  skipSubmodule: false,
117
128
  help: false,
@@ -146,6 +157,9 @@ function parseArgs(argv) {
146
157
  case "--locale":
147
158
  flags.locale = args[++i];
148
159
  break;
160
+ case "--asset-prefix":
161
+ flags.assetPrefix = args[++i];
162
+ break;
149
163
  case "--skip-install":
150
164
  flags.skipInstall = true;
151
165
  break;
@@ -181,6 +195,23 @@ function isValidLocale(code) {
181
195
  return /^[a-z]{2,5}$/i.test(code);
182
196
  }
183
197
 
198
+ // Normalize an asset prefix:
199
+ // - empty string / "none" / "off" / "false" → "" (single-origin disabled, fallback to separate origins)
200
+ // - "/foo" → "/foo"
201
+ // - "foo" → "/foo"
202
+ // - "/foo/" → "/foo"
203
+ function normalizeAssetPrefix(value) {
204
+ if (!value) return "";
205
+ const trimmed = String(value).trim();
206
+ if (trimmed === "" || /^(none|off|false|disabled)$/i.test(trimmed)) return "";
207
+ let p = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
208
+ p = p.replace(/\/+$/, "");
209
+ if (!/^\/[a-z0-9._-]+(\/[a-z0-9._-]+)*$/i.test(p)) {
210
+ fatal(`Invalid --asset-prefix: ${value}\n Use a path like /cms-assets, or "none" to disable single-origin.`);
211
+ }
212
+ return p;
213
+ }
214
+
184
215
  // ─── Pre-flight ──────────────────────────────────────────────────────────────
185
216
 
186
217
  function preflight(flags) {
@@ -223,9 +254,12 @@ function createPrompt() {
223
254
  return { ask, close };
224
255
  }
225
256
 
226
- async function gatherEnv(flags) {
257
+ async function gatherEnv(flags, { singleOrigin }) {
227
258
  let dbUrl = flags.db;
228
- let siteUrl = flags.url ?? "http://localhost:3000";
259
+ // In single-origin mode the frontend is the public entry → default :3001.
260
+ // In separate-origins mode the backend is the public CMS URL → default :3000.
261
+ const defaultSiteUrl = singleOrigin ? "http://localhost:3001" : "http://localhost:3000";
262
+ let siteUrl = flags.url ?? defaultSiteUrl;
229
263
  let locale = flags.locale ?? "en";
230
264
 
231
265
  if (flags.db && !isValidDbUrl(flags.db)) fatal("--db must start with postgresql:// or postgres://");
@@ -291,9 +325,47 @@ function writeRootPackageJson(targetDir, dirName) {
291
325
  "backend:setup": "pnpm --filter ./apps/backend setup",
292
326
  },
293
327
  engines: { node: ">=20", pnpm: ">=9" },
294
- packageManager: "pnpm@9.0.0",
328
+ pnpm: {
329
+ // apollo-cms transitively pulls multiple esbuild versions (0.18, 0.25, 0.27).
330
+ // pnpm's binary symlink can pick the wrong platform binary for esbuild's
331
+ // postinstall version check, failing with "Expected X.Y.Z but got A.B.C".
332
+ // Allow listed packages to run their build/postinstall scripts; the rest
333
+ // are skipped (pnpm 10 default is empty allow-list).
334
+ onlyBuiltDependencies: [
335
+ "@swc/core",
336
+ "@swc/core-darwin-arm64",
337
+ "@swc/core-darwin-x64",
338
+ "@swc/core-linux-arm64-gnu",
339
+ "@swc/core-linux-x64-gnu",
340
+ "esbuild",
341
+ "msw",
342
+ "sharp",
343
+ "unrs-resolver",
344
+ "@rolldown/binding-darwin-arm64",
345
+ "@rolldown/binding-linux-x64-gnu",
346
+ "@rolldown/binding-linux-arm64-gnu",
347
+ "better-sqlite3",
348
+ "core-js",
349
+ "core-js-pure",
350
+ ],
351
+ },
295
352
  };
296
353
  writeFileSync(resolve(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
354
+
355
+ // .npmrc — keep peer-deps lenient and hoist esbuild's platform binaries so
356
+ // its postinstall version check resolves the correct one across nested
357
+ // dependency trees.
358
+ writeFileSync(
359
+ resolve(targetDir, ".npmrc"),
360
+ [
361
+ "auto-install-peers=true",
362
+ "strict-peer-dependencies=false",
363
+ "public-hoist-pattern[]=*esbuild*",
364
+ "public-hoist-pattern[]=*types*",
365
+ "shell-emulator=true",
366
+ "",
367
+ ].join("\n"),
368
+ );
297
369
  }
298
370
 
299
371
  function writePnpmWorkspace(targetDir) {
@@ -321,23 +393,53 @@ function writeRootGitignore(targetDir) {
321
393
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
322
394
  }
323
395
 
324
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret }) {
396
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl }) {
397
+ const header = assetPrefix
398
+ ? [
399
+ "# Single-origin monorepo dev env",
400
+ "# Public origin = frontend (apps/frontend on :3001). Frontend rewrites /admin/*,",
401
+ "# /api/*, /uploads/*, and the asset prefix to the backend (apps/backend on :3000).",
402
+ ]
403
+ : [
404
+ "# Separate-origins monorepo dev env",
405
+ "# Backend runs at http://localhost:3000, frontend at http://localhost:3001.",
406
+ "# To switch to single-origin: set APOLLO_ASSET_PREFIX, BACKEND_INTERNAL_URL,",
407
+ "# NEXT_PUBLIC_APOLLO_ASSET_PREFIX, and add rewrites() to apps/frontend/next.config.ts.",
408
+ ];
409
+
325
410
  const lines = [
326
- "# Shared dev env — backend reads these via apps/backend/.env.local symlink/copy",
411
+ ...header,
412
+ "",
413
+ "# ── Shared (consumed by both apps) ───────────────────────────────",
327
414
  `DATABASE_URL=${dbUrl}`,
328
415
  `APOLLO_SECRET=${authSecret}`,
329
- `BETTER_AUTH_SECRET=${authSecret}`,
330
416
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
331
417
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
332
418
  "",
333
- "# Frontend",
334
- `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
335
- "",
336
- ].join("\n");
337
- writeFileSync(resolve(targetDir, ".env.local"), lines);
419
+ ];
420
+
421
+ if (assetPrefix) {
422
+ lines.push(
423
+ "# ── Backend (apps/backend) ───────────────────────────────────────",
424
+ `APOLLO_ASSET_PREFIX=${assetPrefix}`,
425
+ "",
426
+ "# ── Frontend (apps/frontend) ─────────────────────────────────────",
427
+ `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
428
+ `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
429
+ "",
430
+ );
431
+ } else {
432
+ lines.push(
433
+ "# ── Frontend (apps/frontend) ─────────────────────────────────────",
434
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
435
+ "",
436
+ );
437
+ }
438
+
439
+ writeFileSync(resolve(targetDir, ".env.local"), lines.join("\n"));
338
440
  }
339
441
 
340
- function writeFrontendApp(targetDir, frontendName, siteUrl) {
442
+ function writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl) {
341
443
  const dir = resolve(targetDir, FRONTEND_PATH);
342
444
  mkdirSync(dir, { recursive: true });
343
445
  mkdirSync(resolve(dir, "src/app"), { recursive: true });
@@ -397,15 +499,70 @@ function writeFrontendApp(targetDir, frontendName, siteUrl) {
397
499
  ) + "\n",
398
500
  );
399
501
 
400
- writeFileSync(
401
- resolve(dir, "next.config.ts"),
402
- `import type { NextConfig } from "next";\n\nconst config: NextConfig = {\n reactStrictMode: true,\n};\n\nexport default config;\n`,
403
- );
502
+ // Frontend Next.js config:
503
+ // - Single-origin mode (assetPrefix set): rewrite all backend paths.
504
+ // - Separate-origins mode (assetPrefix empty): no rewrites; the two apps
505
+ // are reachable on their own ports/subdomains.
506
+ const nextConfigContent = assetPrefix
507
+ ? `import type { NextConfig } from "next";
508
+
509
+ const BACKEND = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
510
+ const ASSET_PREFIX = process.env.NEXT_PUBLIC_APOLLO_ASSET_PREFIX ?? "${assetPrefix}";
511
+
512
+ const config: NextConfig = {
513
+ reactStrictMode: true,
514
+ async rewrites() {
515
+ return [
516
+ // Apollo CMS admin UI
517
+ { source: "/admin/:path*", destination: \`\${BACKEND}/admin/:path*\` },
518
+ // Apollo CMS APIs (REST + auth + cron + email + health + mcp + …)
519
+ { source: "/api/auth/:path*", destination: \`\${BACKEND}/api/auth/:path*\` },
520
+ { source: "/api/v1/:path*", destination: \`\${BACKEND}/api/v1/:path*\` },
521
+ { source: "/api/cron", destination: \`\${BACKEND}/api/cron\` },
522
+ { source: "/api/email/:path*", destination: \`\${BACKEND}/api/email/:path*\` },
523
+ { source: "/api/health", destination: \`\${BACKEND}/api/health\` },
524
+ { source: "/api/mcp", destination: \`\${BACKEND}/api/mcp\` },
525
+ { source: "/api/editing-presence/:path*", destination: \`\${BACKEND}/api/editing-presence/:path*\` },
526
+ { source: "/api/admin/:path*", destination: \`\${BACKEND}/api/admin/:path*\` },
527
+ // Media uploads
528
+ { source: "/uploads/:path*", destination: \`\${BACKEND}/uploads/:path*\` },
529
+ // Sentry tunnel (no-op if SENTRY_DSN isn't set on the backend)
530
+ { source: "/monitoring", destination: \`\${BACKEND}/monitoring\` },
531
+ // Backend's namespaced Next.js chunks (set via APOLLO_ASSET_PREFIX)
532
+ { source: \`\${ASSET_PREFIX}/:path*\`, destination: \`\${BACKEND}\${ASSET_PREFIX}/:path*\` },
533
+ ];
534
+ },
535
+ };
404
536
 
405
- writeFileSync(
406
- resolve(dir, ".env.local.example"),
407
- `NEXT_PUBLIC_BACKEND_URL=${siteUrl}\n`,
408
- );
537
+ export default config;
538
+ `
539
+ : `import type { NextConfig } from "next";
540
+
541
+ // Separate-origins mode: backend runs at http://localhost:3000, frontend here.
542
+ // Switch to single-origin by setting APOLLO_ASSET_PREFIX in apps/backend/.env.local
543
+ // and adding rewrites() — see this monorepo's README for the recipe.
544
+ const config: NextConfig = {
545
+ reactStrictMode: true,
546
+ };
547
+
548
+ export default config;
549
+ `;
550
+ writeFileSync(resolve(dir, "next.config.ts"), nextConfigContent);
551
+
552
+ const envLocalLines = assetPrefix
553
+ ? [
554
+ `# Internal backend URL (used by Next.js rewrites — not exposed to browser)`,
555
+ `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
556
+ `# Mirror of backend's APOLLO_ASSET_PREFIX`,
557
+ `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
558
+ "",
559
+ ]
560
+ : [
561
+ `# Public URL of the backend (used by your client code)`,
562
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
563
+ "",
564
+ ];
565
+ writeFileSync(resolve(dir, ".env.local.example"), envLocalLines.join("\n"));
409
566
 
410
567
  writeFileSync(
411
568
  resolve(dir, "src/app/layout.tsx"),
@@ -414,16 +571,77 @@ function writeFrontendApp(targetDir, frontendName, siteUrl) {
414
571
 
415
572
  writeFileSync(
416
573
  resolve(dir, "src/app/page.tsx"),
417
- `export default function Page() {\n return (\n <main style={{ padding: 40, fontFamily: "system-ui" }}>\n <h1>Frontend</h1>\n <p>Backend: <code>{process.env.NEXT_PUBLIC_BACKEND_URL}</code></p>\n </main>\n );\n}\n`,
574
+ `export default function Page() {\n return (\n <main style={{ padding: 40, fontFamily: "system-ui" }}>\n <h1>Frontend</h1>\n <p>\n Admin: <a href="/admin">/admin</a>\n </p>\n </main>\n );\n}\n`,
418
575
  );
419
576
 
420
577
  writeFileSync(
421
578
  resolve(dir, ".gitignore"),
422
579
  ["node_modules", ".next", "dist", ".env.local", ""].join("\n"),
423
580
  );
581
+
582
+ // Vercel project config for the frontend. Skip cron + region pinning is
583
+ // optional; configure Root Directory + "Include all submodules" in the
584
+ // Vercel UI when linking the project.
585
+ const vercelJson = {
586
+ $schema: "https://openapi.vercel.sh/vercel.json",
587
+ regions: ["sin1"],
588
+ };
589
+ writeFileSync(resolve(dir, "vercel.json"), JSON.stringify(vercelJson, null, 2) + "\n");
590
+
591
+ writeFileSync(
592
+ resolve(dir, ".vercelignore"),
593
+ ["node_modules", ".next", ".env.local", ""].join("\n"),
594
+ );
424
595
  }
425
596
 
426
- function writeReadme(targetDir, dirName, frontendName) {
597
+ function writeReadme(targetDir, dirName, frontendName, assetPrefix) {
598
+ const singleOrigin = !!assetPrefix;
599
+
600
+ const originSection = singleOrigin
601
+ ? `## Routing model — single origin
602
+
603
+ Both apps share one public origin (the frontend). The frontend rewrites these
604
+ paths to the backend so /_next/* doesn't collide:
605
+
606
+ | Path | Goes to |
607
+ | --------------------------- | ------------------ |
608
+ | \`/\` and other frontend routes | \`apps/frontend\` |
609
+ | \`/admin/*\` | \`apps/backend\` |
610
+ | \`/api/auth/*\`, \`/api/v1/*\`, \`/api/email/*\`, \`/api/cron\`, \`/api/health\`, \`/api/mcp\`, \`/api/admin/*\`, \`/api/editing-presence/*\` | \`apps/backend\` |
611
+ | \`/uploads/*\` | \`apps/backend\` (media) |
612
+ | \`${assetPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
613
+
614
+ The frontend MUST NOT define routes at \`/admin\`, \`/api/auth\`, \`/api/v1\`, etc.
615
+ If you need your own API, namespace it under \`/api/internal/*\` or similar.
616
+
617
+ To **disable** single-origin and run the backend on its own subdomain,
618
+ delete \`APOLLO_ASSET_PREFIX\` from \`apps/backend/.env.local\` and remove the
619
+ \`rewrites()\` block from \`apps/frontend/next.config.ts\`.
620
+
621
+ ### Known limitations
622
+
623
+ - The backend's \`/_next/image\` (Next.js Image optimization endpoint) is **not**
624
+ rewritten — only \`/_next/static\` moves under \`APOLLO_ASSET_PREFIX\`. If your
625
+ backend admin uses \`<Image>\` to render \`/uploads/*\` media, those will fall
626
+ back to the frontend's image optimizer and 404. Apollo CMS's stock admin uses
627
+ plain \`<img>\` for uploaded media so this rarely matters; if you customize the
628
+ admin and need optimized images, switch to separate-origins mode or set
629
+ \`unoptimized\` on those \`<Image>\` instances.
630
+ `
631
+ : `## Routing model — separate origins
632
+
633
+ The two apps run on separate origins. Default ports:
634
+
635
+ - Backend (apps/backend): http://localhost:3000
636
+ - Frontend (apps/frontend): http://localhost:3001
637
+
638
+ \`NEXT_PUBLIC_SITE_URL\` should point at whichever origin you treat as the
639
+ public CMS URL (typically the backend). To switch to **single-origin** later,
640
+ re-run the installer with \`--asset-prefix /cms-assets\` or set
641
+ \`APOLLO_ASSET_PREFIX\` in \`apps/backend/.env.local\` and add the matching
642
+ \`rewrites()\` block to \`apps/frontend/next.config.ts\`.
643
+ `;
644
+
427
645
  const readme = `# ${dirName}
428
646
 
429
647
  Monorepo with a custom frontend and Apollo CMS as a git submodule backend.
@@ -447,6 +665,7 @@ pnpm backend:setup # push schema + seed apollo-cms
447
665
  pnpm dev # runs frontend (3001) + backend (3000) in parallel
448
666
  \`\`\`
449
667
 
668
+ ${originSection}
450
669
  ## Updating the backend
451
670
 
452
671
  Apollo CMS is tracked as a git submodule. Pull the latest:
@@ -462,6 +681,71 @@ Do **not** edit files inside \`apps/backend\`. Open issues / PRs in the
462
681
 
463
682
  Shared dev env lives in the root \`.env.local\`. The backend reads its own
464
683
  \`apps/backend/.env.local\` (already populated by the installer).
684
+
685
+ ## Deploy on Vercel
686
+
687
+ Two Vercel projects, one repo. Each project picks up its own Root Directory.
688
+
689
+ ### 1) Backend project
690
+
691
+ - **Import** this repo into Vercel as a new project.
692
+ - **Root Directory**: \`apps/backend\`
693
+ - **Build Command**: \`cd ../.. && pnpm install --frozen-lockfile && pnpm --filter ./apps/backend build\`
694
+ - **Install Command**: leave empty (handled in build)
695
+ - **Settings → Git → Include all submodules: ON** (Vercel checks out an empty \`apps/backend\` otherwise)
696
+ - **Environment variables**:
697
+ \`\`\`
698
+ DATABASE_URL=postgresql://…
699
+ APOLLO_SECRET=<openssl rand -hex 32>
700
+ NEXT_PUBLIC_SITE_URL=https://yourdomain.com # the PUBLIC origin
701
+ NEXT_PUBLIC_DEFAULT_LOCALE=en
702
+ ${singleOrigin ? `APOLLO_ASSET_PREFIX=${assetPrefix}` : "# APOLLO_ASSET_PREFIX=/cms-assets # only when single-origin"}
703
+ CRON_SECRET=<random> # protects /api/cron
704
+ # Storage on Vercel cannot use local FS — pick one:
705
+ # Vercel Blob: BLOB_READ_WRITE_TOKEN=…
706
+ # S3 / R2 / Spaces: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_REGION, S3_ENDPOINT
707
+ APOLLO_DISABLE_LOCAL_STORAGE=1
708
+ \`\`\`
709
+ - **Cron**: \`apps/backend/vercel.json\` declares \`/api/cron\` on a 5-minute schedule (from apollo-cms upstream).
710
+
711
+ ### 2) Frontend project
712
+
713
+ - **Import the same repo** as a separate Vercel project.
714
+ - **Root Directory**: \`apps/frontend\`
715
+ - **Build Command**: \`cd ../.. && pnpm install --frozen-lockfile && pnpm --filter ./apps/frontend build\`
716
+ - **Install Command**: leave empty
717
+ - **Settings → Git → Include all submodules: ON**
718
+ - **Environment variables**:
719
+ \`\`\`
720
+ NEXT_PUBLIC_SITE_URL=https://yourdomain.com
721
+ ${singleOrigin
722
+ ? ` BACKEND_INTERNAL_URL=https://<your-backend>.vercel.app\n NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`
723
+ : ` NEXT_PUBLIC_BACKEND_URL=https://<your-backend>.vercel.app`
724
+ }
725
+ \`\`\`
726
+
727
+ ### 3) Custom domain
728
+
729
+ Attach \`yourdomain.com\` to the **frontend** project${singleOrigin ? " (in single-origin mode it's the public entry point)" : ""}. The backend stays on its \`*.vercel.app\` URL${singleOrigin ? " — that's what the rewrite proxies to" : ""}.
730
+
731
+ ### 4) Skip duplicate builds (optional)
732
+
733
+ Each push triggers both projects to rebuild. Add an **Ignored Build Step** in
734
+ each project's Settings → Git:
735
+
736
+ - **Backend**: \`git diff HEAD^ HEAD --quiet -- apps/backend\` (exits 0 → skip build)
737
+ - **Frontend**: \`git diff HEAD^ HEAD --quiet -- apps/frontend\`
738
+
739
+ ### Gotchas
740
+
741
+ - **Submodule must be initialized** on Vercel — the "Include all submodules"
742
+ toggle is the most common reason builds fail with a missing \`apps/backend\`.
743
+ - **OAuth callbacks** for email providers must use the public domain:
744
+ \`https://yourdomain.com/api/email/oauth/callback\`.${singleOrigin ? " The frontend rewrite forwards it to the backend." : ""}
745
+ - **Cron** runs on the backend project only. Vercel sends \`Authorization:
746
+ Bearer $CRON_SECRET\` automatically when \`CRON_SECRET\` is set.${singleOrigin ? `
747
+ - **Better Auth** uses \`trustedProxyHeaders: true\` so the rewrite proxy's
748
+ \`x-forwarded-host\` lands cookies at the public origin without extra config.` : ""}
465
749
  `;
466
750
  writeFileSync(resolve(targetDir, "README.md"), readme);
467
751
  }
@@ -490,9 +774,13 @@ async function main() {
490
774
 
491
775
  // ── Step 2: Gather env ──
492
776
  step(2, "Configuring environment");
493
- const { dbUrl, siteUrl, locale } = await gatherEnv(flags);
777
+ const assetPrefix = normalizeAssetPrefix(flags.assetPrefix);
778
+ const singleOrigin = !!assetPrefix;
779
+ const { dbUrl, siteUrl, locale } = await gatherEnv(flags, { singleOrigin });
494
780
  const authSecret = randomBytes(48).toString("base64");
781
+ const backendInternalUrl = "http://localhost:3000";
495
782
  success(`Frontend pkg name: ${frontendName}`);
783
+ success(`Asset prefix: ${assetPrefix || "(disabled — separate origins)"}`);
496
784
 
497
785
  // ── Step 3: Scaffold root ──
498
786
  step(3, "Scaffolding monorepo root");
@@ -501,8 +789,8 @@ async function main() {
501
789
  writeRootPackageJson(targetDir, dirName);
502
790
  writePnpmWorkspace(targetDir);
503
791
  writeRootGitignore(targetDir);
504
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret });
505
- writeReadme(targetDir, dirName, frontendName);
792
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl });
793
+ writeReadme(targetDir, dirName, frontendName, assetPrefix);
506
794
  success("package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md");
507
795
 
508
796
  // ── Step 4: git init ──
@@ -512,7 +800,7 @@ async function main() {
512
800
 
513
801
  // ── Step 5: Frontend skeleton ──
514
802
  step(5, "Creating frontend app");
515
- writeFrontendApp(targetDir, frontendName, siteUrl);
803
+ writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl);
516
804
  success(`apps/frontend (${frontendName})`);
517
805
 
518
806
  // ── Step 6: Backend submodule ──
@@ -537,15 +825,17 @@ async function main() {
537
825
  }
538
826
 
539
827
  // Mirror env to backend
540
- const backendEnv = [
828
+ const backendEnvLines = [
541
829
  `DATABASE_URL=${dbUrl}`,
542
830
  `APOLLO_SECRET=${authSecret}`,
543
- `BETTER_AUTH_SECRET=${authSecret}`,
544
831
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
545
832
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
546
- "",
547
- ].join("\n");
548
- writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnv);
833
+ ];
834
+ if (assetPrefix) {
835
+ backendEnvLines.push(`APOLLO_ASSET_PREFIX=${assetPrefix}`);
836
+ }
837
+ backendEnvLines.push("");
838
+ writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnvLines.join("\n"));
549
839
  success("apps/backend/.env.local");
550
840
  }
551
841
 
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "create-apollo-monorepo",
3
- "version": "0.1.0",
4
- "description": "Scaffold a monorepo with a frontend app and Apollo CMS as a git submodule backend",
5
- "bin": "./index.mjs",
3
+ "version": "0.3.1",
4
+ "description": "Scaffold a monorepo with a frontend app and Apollo CMS as a git submodule backend (single-origin via Next.js rewrites + assetPrefix)",
5
+ "bin": {
6
+ "create-apollo-monorepo": "index.mjs"
7
+ },
6
8
  "type": "module",
7
9
  "engines": {
8
10
  "node": ">=20"