create-nextblock 0.9.92 → 0.9.98

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 (43) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +9 -2
  3. package/templates/nextblock-template/app/actions/package-actions.ts +9 -4
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3 -2
  5. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +5 -1
  6. package/templates/nextblock-template/app/api/draft/route.ts +5 -1
  7. package/templates/nextblock-template/app/api/media/library/route.ts +6 -2
  8. package/templates/nextblock-template/app/api/process-image/route.ts +58 -43
  9. package/templates/nextblock-template/app/api/revalidate/route.ts +21 -18
  10. package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +20 -8
  11. package/templates/nextblock-template/app/api/upload/proxy/route.ts +34 -28
  12. package/templates/nextblock-template/app/article/[slug]/page.tsx +4 -13
  13. package/templates/nextblock-template/app/checkout/success/actions.ts +3 -2
  14. package/templates/nextblock-template/app/cms/media/actions.ts +47 -31
  15. package/templates/nextblock-template/app/cms/orders/actions.ts +29 -29
  16. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -28
  17. package/templates/nextblock-template/app/cms/users/actions.ts +119 -118
  18. package/templates/nextblock-template/app/cms/users/page.tsx +3 -3
  19. package/templates/nextblock-template/app/layout.tsx +10 -7
  20. package/templates/nextblock-template/app/lib/site-settings.ts +7 -4
  21. package/templates/nextblock-template/app/page.tsx +2 -1
  22. package/templates/nextblock-template/app/product/[slug]/page.tsx +9 -2
  23. package/templates/nextblock-template/app/robots.txt/route.ts +6 -3
  24. package/templates/nextblock-template/app/setup/SetupWizard.tsx +55 -8
  25. package/templates/nextblock-template/app/setup/page.tsx +17 -1
  26. package/templates/nextblock-template/app/sitemap.ts +5 -3
  27. package/templates/nextblock-template/context/language-rest-client.ts +3 -2
  28. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +125 -32
  29. package/templates/nextblock-template/lib/app-secrets.ts +39 -0
  30. package/templates/nextblock-template/lib/auth/crypto.ts +4 -1
  31. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +23 -3
  32. package/templates/nextblock-template/lib/setup/actions.ts +16 -0
  33. package/templates/nextblock-template/lib/setup/env-status.ts +44 -5
  34. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +172 -0
  35. package/templates/nextblock-template/lib/setup/schema-apply.ts +23 -7
  36. package/templates/nextblock-template/lib/site-url.ts +48 -0
  37. package/templates/nextblock-template/lib/storage/provider.ts +66 -0
  38. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -0
  39. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +1 -1
  40. package/templates/nextblock-template/next-env.d.ts +1 -1
  41. package/templates/nextblock-template/next.config.js +37 -2
  42. package/templates/nextblock-template/package.json +3 -3
  43. package/templates/nextblock-template/proxy.ts +39 -4
@@ -2,6 +2,11 @@ import 'server-only';
2
2
  import { unstable_cache } from 'next/cache';
3
3
  import { createClient as createSupabaseJsClient } from '@supabase/supabase-js';
4
4
  import type { Database } from '@nextblock-cms/db';
5
+ import {
6
+ resolveSupabaseAnonKey,
7
+ resolveSupabaseServiceKey,
8
+ resolveSupabaseUrl,
9
+ } from '../../lib/setup/env-status';
5
10
  import {
6
11
  DEFAULT_SITE_TITLE,
7
12
  DEFAULT_SITE_DESCRIPTION,
@@ -28,11 +33,9 @@ export function createStaticSupabaseClient() {
28
33
  // (getSiteSettings + the getCached* helpers) already swallow failures and use
29
34
  // fallbacks, and loadLayoutData short-circuits before reaching here when there is
30
35
  // no Supabase env at all.
31
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co';
36
+ const supabaseUrl = resolveSupabaseUrl() || 'https://dummy.supabase.co';
32
37
  const supabaseKey =
33
- process.env.SUPABASE_SERVICE_ROLE_KEY ||
34
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
35
- 'dummy-anon-key';
38
+ resolveSupabaseServiceKey() || resolveSupabaseAnonKey() || 'dummy-anon-key';
36
39
 
37
40
  return createSupabaseJsClient<Database>(supabaseUrl, supabaseKey, {
38
41
  auth: {
@@ -15,6 +15,7 @@ import {
15
15
  } from './lib/seo';
16
16
  import { getSiteSettings } from './lib/site-settings';
17
17
  import { getRequestOrigin } from '../lib/visual-editing/edit-info';
18
+ import { isSupabaseConfigured } from '../lib/setup/env-status';
18
19
 
19
20
  const DEFAULT_LOCALE = 'en';
20
21
  const LANGUAGE_COOKIE_KEY = 'NEXT_USER_LOCALE';
@@ -65,7 +66,7 @@ async function getPreferredLocale() {
65
66
  export async function generateMetadata(): Promise<Metadata> {
66
67
  // Unconfigured instance (pre-/setup): skip all DB work so metadata generation
67
68
  // can't crash the boot. The proxy redirects unconfigured traffic to /setup anyway.
68
- if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
69
+ if (!isSupabaseConfigured()) {
69
70
  return { title: 'NextBlock' };
70
71
  }
71
72
 
@@ -74,8 +74,15 @@ function resolveCategoryName(category: any, languageCode?: string | null) {
74
74
  }
75
75
 
76
76
  export async function generateStaticParams() {
77
- // Unconfigured instance (pre-/setup): no DB to read product slugs from.
78
- if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
77
+ // Unconfigured instance (pre-/setup): no DB to read product slugs from. Accept every
78
+ // Supabase key alias the Vercel integration may inject (incl. the new publishable key).
79
+ const hasSupabaseEnv =
80
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
81
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
82
+ process.env.SUPABASE_ANON_KEY ||
83
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
84
+ process.env.SUPABASE_PUBLISHABLE_KEY);
85
+ if (!hasSupabaseEnv) {
79
86
  return [];
80
87
  }
81
88
 
@@ -1,9 +1,12 @@
1
+ import { resolveSiteUrl, hasResolvedSiteUrl } from '../../lib/site-url';
2
+
1
3
  export async function GET() {
2
- const siteUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000';
4
+ // Explicit NEXT_PUBLIC_URL Vercel production URL → local-dev fallback.
5
+ const siteUrl = resolveSiteUrl();
3
6
 
4
- if (!process.env.NEXT_PUBLIC_URL) {
7
+ if (!hasResolvedSiteUrl()) {
5
8
  console.warn(
6
- 'Warning: NEXT_PUBLIC_URL environment variable is not set for robots.txt. Defaulting to http://localhost:3000. Ensure this is set for production.'
9
+ 'Warning: no site URL is set for robots.txt (NEXT_PUBLIC_URL / Vercel production URL). Defaulting to http://localhost:3000. Set NEXT_PUBLIC_URL for production.'
7
10
  );
8
11
  }
9
12
 
@@ -43,6 +43,15 @@ interface SmtpPrefill {
43
43
  fromName: string;
44
44
  }
45
45
 
46
+ export interface SupabaseEnvDetected {
47
+ NEXT_PUBLIC_SUPABASE_URL: boolean;
48
+ SUPABASE_URL: boolean;
49
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: boolean;
50
+ SUPABASE_ANON_KEY: boolean;
51
+ SUPABASE_SERVICE_ROLE_KEY: boolean;
52
+ POSTGRES_URL: boolean;
53
+ }
54
+
46
55
  interface Props {
47
56
  channel: DeployChannel;
48
57
  configured: boolean;
@@ -51,6 +60,8 @@ interface Props {
51
60
  storagePrefill: StoragePrefill;
52
61
  smtpPrefill: SmtpPrefill;
53
62
  turnstilePrefill: { siteKey: string };
63
+ /** Which Supabase env vars the running deployment can actually see (read-only channels). */
64
+ supabaseEnvDetected: SupabaseEnvDetected;
54
65
  }
55
66
 
56
67
  type StepId = 'connection' | 'storage' | 'email' | 'bot' | 'signups' | 'admin';
@@ -80,6 +91,7 @@ export default function SetupWizard({
80
91
  storagePrefill,
81
92
  smtpPrefill,
82
93
  turnstilePrefill,
94
+ supabaseEnvDetected,
83
95
  }: Props) {
84
96
  const [isPending, startTransition] = useTransition();
85
97
  const [message, setMessage] = useState<Msg>(null);
@@ -114,9 +126,14 @@ export default function SetupWizard({
114
126
  if (!configured) {
115
127
  list.push('connection');
116
128
  }
117
- list.push('storage', 'email', 'bot', 'signups', 'admin');
129
+ // One-click Vercel deploys use the connected Supabase project for media storage with
130
+ // zero keys (native Supabase Storage), so there's nothing to configure — skip the step.
131
+ if (channel !== 'vercel') {
132
+ list.push('storage');
133
+ }
134
+ list.push('email', 'bot', 'signups', 'admin');
118
135
  return list;
119
- }, [configured]);
136
+ }, [configured, channel]);
120
137
 
121
138
  const [stepIndex, setStepIndex] = useState(0);
122
139
  const current = steps[stepIndex];
@@ -339,12 +356,42 @@ export default function SetupWizard({
339
356
  />
340
357
  </Field>
341
358
  {!writable && (
342
- <Alert variant="destructive" className="py-2 px-4">
343
- <AlertDescription>
344
- This environment is read-only. Set the Supabase variables on your hosting
345
- platform; this step only works in local development.
346
- </AlertDescription>
347
- </Alert>
359
+ <div className="space-y-3 rounded-md border p-4">
360
+ <p className="text-sm font-medium">
361
+ On {CHANNEL_LABEL[channel]} you don&apos;t fill this in the database is
362
+ connected through your platform&apos;s environment variables (e.g. Vercel&apos;s
363
+ Supabase integration).
364
+ </p>
365
+ <div className="text-xs">
366
+ <p className="mb-1 text-muted-foreground">
367
+ Supabase variables this deployment can currently see:
368
+ </p>
369
+ <ul className="space-y-0.5 font-mono">
370
+ {(
371
+ [
372
+ 'NEXT_PUBLIC_SUPABASE_URL',
373
+ 'SUPABASE_URL',
374
+ 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
375
+ 'SUPABASE_ANON_KEY',
376
+ 'SUPABASE_SERVICE_ROLE_KEY',
377
+ 'POSTGRES_URL',
378
+ ] as const
379
+ ).map((k) => (
380
+ <li key={k}>
381
+ {supabaseEnvDetected[k] ? '✅' : '❌'} {k}
382
+ </li>
383
+ ))}
384
+ </ul>
385
+ </div>
386
+ <Alert className="py-2 px-4">
387
+ <AlertDescription className="text-xs">
388
+ {supabaseEnvDetected.NEXT_PUBLIC_SUPABASE_URL ||
389
+ supabaseEnvDetected.SUPABASE_URL
390
+ ? 'Keys detected — reload this page and the database step will be skipped.'
391
+ : 'No Supabase keys are visible to this deployment yet. If you just created the database via the Vercel integration, the keys were added AFTER this build — open Vercel → Deployments → ⋯ → Redeploy once, then reload. Env vars only bind on a new deployment.'}
392
+ </AlertDescription>
393
+ </Alert>
394
+ </div>
348
395
  )}
349
396
  </div>
350
397
  )}
@@ -81,7 +81,8 @@ export default async function SetupPage() {
81
81
  }
82
82
 
83
83
  const channel = detectChannel();
84
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '';
84
+ const supabaseUrl =
85
+ process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || '';
85
86
 
86
87
  return (
87
88
  <SetupWizard
@@ -98,6 +99,21 @@ export default async function SetupPage() {
98
99
  fromName: process.env.SMTP_FROM_NAME ?? '',
99
100
  }}
100
101
  turnstilePrefill={{ siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? '' }}
102
+ supabaseEnvDetected={{
103
+ NEXT_PUBLIC_SUPABASE_URL: Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL),
104
+ SUPABASE_URL: Boolean(process.env.SUPABASE_URL),
105
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: Boolean(
106
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
107
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
108
+ ),
109
+ SUPABASE_ANON_KEY: Boolean(
110
+ process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_PUBLISHABLE_KEY,
111
+ ),
112
+ SUPABASE_SERVICE_ROLE_KEY: Boolean(
113
+ process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY,
114
+ ),
115
+ POSTGRES_URL: Boolean(process.env.POSTGRES_URL),
116
+ }}
101
117
  />
102
118
  );
103
119
  }
@@ -6,6 +6,7 @@ import {
6
6
  fetchAllPublishedPosts,
7
7
  type SitemapEntry,
8
8
  } from './lib/sitemap-utils';
9
+ import { resolveSiteUrl, hasResolvedSiteUrl } from '../lib/site-url';
9
10
 
10
11
  /**
11
12
  * Cache the generated sitemap and rebuild it at most once an hour. Crawlers get
@@ -23,7 +24,8 @@ export const revalidate = 3600;
23
24
  type SitemapItem = MetadataRoute.Sitemap[number];
24
25
  type ChangeFrequency = NonNullable<SitemapItem['changeFrequency']>;
25
26
 
26
- const BASE_URL = (process.env.NEXT_PUBLIC_URL || 'http://localhost:3000').replace(/\/+$/, '');
27
+ // Explicit NEXT_PUBLIC_URL Vercel production URL → local-dev fallback.
28
+ const BASE_URL = resolveSiteUrl();
27
29
 
28
30
  function toAbsolute(path: string): string {
29
31
  return `${BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
@@ -73,9 +75,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
73
75
  return [];
74
76
  }
75
77
 
76
- if (!process.env.NEXT_PUBLIC_URL) {
78
+ if (!hasResolvedSiteUrl()) {
77
79
  console.warn(
78
- 'Warning: NEXT_PUBLIC_URL is not set for the sitemap. Defaulting to http://localhost:3000 — set this in production.',
80
+ 'Warning: no site URL is set for the sitemap (NEXT_PUBLIC_URL / Vercel production URL). Defaulting to http://localhost:3000 — set NEXT_PUBLIC_URL in production.',
79
81
  );
80
82
  }
81
83
 
@@ -1,10 +1,11 @@
1
1
  import type { Database } from '@nextblock-cms/db';
2
+ import { resolveSupabaseAnonKey, resolveSupabaseUrl } from '../lib/setup/env-status';
2
3
 
3
4
  type Language = Database['public']['Tables']['languages']['Row'];
4
5
 
5
6
  export async function fetchActiveLanguagesFromRest(): Promise<Language[]> {
6
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
7
- const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
7
+ const supabaseUrl = resolveSupabaseUrl();
8
+ const supabaseAnonKey = resolveSupabaseAnonKey();
8
9
 
9
10
  if (!supabaseUrl || !supabaseAnonKey) {
10
11
  return [];
@@ -12,32 +12,127 @@ The badge links to `https://vercel.com/new/clone` with these query parameters:
12
12
  | Parameter | Purpose |
13
13
  | :--- | :--- |
14
14
  | `repository-url` | The NextBlock repo to clone into the user's Git provider. |
15
- | `integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6` | Vercel's **Supabase integration**. During import, Vercel provisions (or links) a Supabase project and injects its environment variables automatically. |
16
- | `env=NEXT_PUBLIC_URL,CRON_SECRET,DRAFT_MODE_SECRET,REVALIDATE_SECRET_TOKEN` | The remaining variables Vercel prompts for. Only variable **names** are listed never secret values. |
17
- | `envDescription` / `envLink` | Help text + a link back to this doc. |
15
+ | `project-name` / `repository-name` | Pre-fill the new Vercel project and Git repo names. |
16
+ | `stores=[{"type":"integration","integrationSlug":"supabase","productSlug":"supabase"}]` | Vercel's **native Supabase Marketplace integration**. During import you're prompted to **create a Supabase database** (name + region); Vercel **provisions it, connects it to the project, and injects the env vars before the first build**. |
18
17
 
19
- The Supabase integration injects the keys the app needs to boot:
20
- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`,
21
- `SUPABASE_SERVICE_ROLE_KEY`, and `POSTGRES_URL`. Because those are present on first
22
- boot, the instance is **Profile A (pre-configured)**: the wizard skips the connection
23
- step and goes straight to creating the first administrator.
18
+ There is deliberately **no `env=` parameter** the deploy prompts for **zero** values you
19
+ have to type (see "No environment variables required" below). The only interaction is the
20
+ Supabase integration's "create database" step, which can't be skipped because provisioning
21
+ a Postgres DB requires choosing a region/plan.
22
+
23
+ > **Why `stores`, not `integration-ids`?** The legacy `integration-ids=oac_…` parameter
24
+ > triggers an OAuth integration that *creates* a Supabase project but leaves it
25
+ > **disconnected** — it injects **no** environment variables into the Vercel project, so
26
+ > the app boots unconfigured and you have to wire the keys up by hand. The modern `stores`
27
+ > parameter is the native Marketplace path that actually **connects the store and injects
28
+ > credentials before the build**. ([Vercel Deploy Button docs](https://vercel.com/docs/deploy-button/source))
29
+
30
+ ## Connect the database (Supabase integration)
31
+
32
+ The `stores` parameter makes Vercel prompt you to create a Supabase database during import.
33
+ Pick a **name** and **region**, and Vercel **automatically injects** the integration's
34
+ environment variables into the project — you never copy a value. The native integration
35
+ injects the **new-style** Supabase key names, e.g.:
36
+
37
+ `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` /
38
+ `SUPABASE_PUBLISHABLE_KEY` (the anon-equivalent), `SUPABASE_SECRET_KEY` (the
39
+ service-role-equivalent), and `POSTGRES_URL` (+ the other `POSTGRES_*` parts).
40
+
41
+ NextBlock reads **all** of these aliases (see `apps/nextblock/lib/setup/env-status.ts`):
42
+ it accepts `NEXT_PUBLIC_SUPABASE_URL` *or* `SUPABASE_URL`; anon *or* `PUBLISHABLE_KEY`;
43
+ `SUPABASE_SERVICE_ROLE_KEY` *or* `SUPABASE_SECRET_KEY`. So the wizard's connection step
44
+ auto-skips no matter which copy the integration set, and the browser client gets the
45
+ publishable key bridged in as the anon key at build time.
46
+
47
+ > If the database step still appears after a deploy that connected the store, the env vars
48
+ > landed **after** that build — **Redeploy** once (Vercel binds env vars at deploy time).
49
+ > With the `stores` flow they're injected *before* the first build, so this is rarely needed.
50
+
51
+ On that deploy the wizard skips the database-connection step, **auto-applies the schema**
52
+ (the migrations are embedded in the build — see `lib/setup/migrations-bundle.ts` — and run
53
+ via the injected `POSTGRES_URL`), uses the connected Supabase project for media storage,
54
+ and goes straight to creating the first administrator.
55
+
56
+ > Until the schema is applied and the first admin exists, **every route redirects to
57
+ > `/setup`** (the homepage does *not* 404). The middleware treats a "schema not applied yet"
58
+ > database error as *unprovisioned* and funnels traffic to the wizard.
59
+
60
+ ## Build configuration (Nx monorepo)
61
+
62
+ NextBlock is an Nx monorepo — the Next.js app lives at `apps/nextblock`, not the repo
63
+ root — so a bare `next build` at the root fails with *"Couldn't find any `pages` or
64
+ `app` directory."* The root [`vercel.json`](../vercel.json) pins the correct build, so
65
+ the one-click deploy needs **no manual dashboard configuration**:
66
+
67
+ ```json
68
+ {
69
+ "buildCommand": "npx nx build nextblock --prod",
70
+ "outputDirectory": "apps/nextblock/.next",
71
+ "framework": "nextjs"
72
+ }
73
+ ```
74
+
75
+ - **`buildCommand`** runs the Nx target from the repo root, which resolves the
76
+ workspace libraries (`@nextblock-cms/*` via the TS path aliases) and builds the app.
77
+ - **`outputDirectory`** points at the app's `.next`. This `@nx/next` version emits it
78
+ to `apps/nextblock/.next` — **not** `dist/apps/nextblock/.next` (which only receives
79
+ the deploy wrapper: `package.json`, `next.config.js`, `public/`). Verify with
80
+ `npx nx build nextblock --prod` then check `apps/nextblock/.next/BUILD_ID`.
81
+ - **`framework: nextjs`** keeps Vercel's first-class Next.js runtime (SSR/ISR
82
+ functions, image optimization, the `proxy.ts` middleware).
83
+
84
+ Leave the Vercel project's **Root Directory unset** (the repo root) — the build command
85
+ already targets the app. Do **not** set Root Directory to `apps/nextblock`: the app
86
+ imports the workspace libraries one level up, which a custom Root Directory would hide
87
+ (Vercel forbids `..` above a custom root).
88
+
89
+ ## No environment variables required
90
+
91
+ A Deploy-Button URL can only carry variable **names**, never values — so secrets can
92
+ never be pre-filled through it. Rather than make you paste random strings, NextBlock
93
+ resolves everything in-app, and the button prompts for nothing:
94
+
95
+ - **`NEXT_PUBLIC_URL`** — optional. When unset the app falls back to Vercel's
96
+ production URL (`VERCEL_PROJECT_PRODUCTION_URL` server-side /
97
+ `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` in the browser), i.e. your
98
+ `*.vercel.app` domain. Sitemap, robots, and canonical links use it automatically.
99
+ Add a custom domain later, set `NEXT_PUBLIC_URL=https://yourdomain.com` in the
100
+ Vercel project, and redeploy (it is inlined at build time).
101
+ - **`DRAFT_MODE_SECRET`** and **`REVALIDATE_SECRET_TOKEN`** — optional. When unset they
102
+ are derived deterministically from the Supabase service-role key (HMAC-SHA256), so
103
+ Draft Mode and on-demand revalidation work out of the box. Setting either env var
104
+ overrides the derived value — do this if you want a fixed `REVALIDATE_SECRET_TOKEN`
105
+ to paste into a Supabase revalidation webhook. (See `apps/nextblock/lib/app-secrets.ts`.)
106
+ - **`CRON_SECRET`** — optional. The cron endpoints enforce the `Authorization: Bearer`
107
+ header **only when it is set**. Leave it unset for a frictionless deploy, or set it
108
+ in the Vercel project to lock the cron endpoints down. The destructive
109
+ `/api/cron/reset-sandbox` job is independently gated to sandbox-mode only (404
110
+ otherwise), so it never runs on a normal deploy.
24
111
 
25
112
  ## What the wizard does on Vercel
26
113
 
27
114
  1. **Database** — already connected (integration-injected). The wizard verifies a
28
- first admin doesn't exist yet, otherwise it redirects to `/cms/dashboard`.
29
- 2. **Schema** apply the migrations to the managed Supabase project. The Supabase
30
- integration creates the project but does **not** run NextBlock's migrations, so run
31
- them once after the first deploy (locally against the project, or via the Supabase
32
- dashboard SQL editor) see [docs/04](./04-DATABASE-AND-AUTH.md) and
33
- [docs/05](./05-DEVELOPER-GUIDE.md).
34
- 3. **Storage** — pre-filled for **Supabase Storage** (S3-compatible). The wizard's
35
- storage step shows the endpoint derived from your Supabase URL
36
- (`<project>/storage/v1/s3`). Create an S3 access key in the Supabase dashboard
37
- (Storage S3 connection) and set `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` in
38
- your Vercel project environment. (Cloudflare R2 remains the default for non-Vercel
39
- installs it has a more generous free storage tier; only the one-click Vercel
40
- path defaults to Supabase Storage.)
115
+ first admin doesn't exist yet, otherwise it redirects to `/cms/dashboard`. The
116
+ connection step is **auto-skipped**.
117
+ 2. **Schema** applied **automatically**. The Supabase integration creates the project
118
+ but does **not** run NextBlock's migrations, so the wizard applies them itself on the
119
+ final step: it runs the build-embedded migration bundle over the injected
120
+ `POSTGRES_URL` (no CLI, no manual SQL). Idempotent — a re-run is a no-op. See
121
+ [docs/04](./04-DATABASE-AND-AUTH.md) and [docs/05](./05-DEVELOPER-GUIDE.md).
122
+ 3. **Storage** **skipped on Vercel; nothing to configure.** Media uses **native
123
+ Supabase Storage** through the already-connected project: the injected
124
+ `SUPABASE_SECRET_KEY` authenticates uploads/downloads/deletes via the Supabase client
125
+ (no S3 access keys required), a public `media` bucket is auto-created, and images are
126
+ served from `<project>/storage/v1/object/public/media/…`. The storage backend is
127
+ selected automatically (`apps/nextblock/lib/storage/provider.ts`): if S3/R2 keys are
128
+ present it uses them; otherwise it falls back to native Supabase Storage. (Cloudflare
129
+ R2 remains the default for non-Vercel installs — it has a more generous free storage
130
+ tier; set the `R2_*` env vars to use it on Vercel instead.)
131
+
132
+ > **Upload size:** the CMS media uploader proxies the file through a serverless
133
+ > function (`/api/upload/proxy`), which is subject to Vercel's ~4.5 MB request-body
134
+ > limit — larger single images fail on the Hobby/Pro serverless runtime regardless of
135
+ > storage backend. This is a platform constraint, not a NextBlock one.
41
136
  4. **Email / Bot protection / Sign-ups** — optional steps; bot-protection and the
42
137
  sign-up policy persist to the database and work immediately. SMTP, if used, is set
43
138
  as Vercel environment variables.
@@ -47,19 +142,17 @@ step and goes straight to creating the first administrator.
47
142
  > Filesystem is read-only on Vercel, so the wizard never writes `.env.local` there —
48
143
  > all configuration is environment variables (platform-managed) plus the database.
49
144
 
50
- ## Cron jobs and the free tier
51
-
52
- `vercel.json` declares two daily crons (`/api/cron/reset-sandbox` and
53
- `/api/cron/sync-currencies`). Vercel's **Hobby (free) tier allows one cron per day**.
54
- For a free-tier production deploy, either:
145
+ ## Cron jobs and the Hobby plan
55
146
 
56
- - Upgrade to a paid plan (both crons run as declared), **or**
57
- - Keep only the cron you need (most production sites don't need `reset-sandbox`, which
58
- exists for the public demo sandbox), **or**
59
- - Consolidate both jobs into a single cron handler.
147
+ `vercel.json` declares two crons (`/api/cron/reset-sandbox` at 03:00 and
148
+ `/api/cron/sync-currencies` at 18:00). Vercel's **Hobby (free) tier allows up to 100
149
+ cron jobs, each running at most once per day** — both jobs are daily, so they deploy
150
+ fine on the free tier. (Hobby timing is approximate, ±59 min, which is irrelevant for
151
+ daily jobs; only sub-daily schedules like `0 * * * *` are rejected on Hobby.)
60
152
 
61
- This is intentionally left as a deploy-time decision rather than changed in the repo,
62
- since the sandbox/demo deploy relies on both crons.
153
+ `reset-sandbox` only does work in sandbox mode it returns 404 otherwise so on a
154
+ normal deploy it is a harmless no-op. Delete it from `vercel.json` if you'd rather not
155
+ see it scheduled.
63
156
 
64
157
  ## After deploy
65
158
 
@@ -0,0 +1,39 @@
1
+ import 'server-only';
2
+ import { createHmac } from 'node:crypto';
3
+
4
+ // Resolve the app's internal shared secrets WITHOUT requiring the operator to paste
5
+ // random strings at deploy time. When an explicit env var is set it always wins;
6
+ // otherwise the secret is derived deterministically from the always-present Supabase
7
+ // service-role key. This is what lets a one-click Vercel deploy work with zero env
8
+ // input while Draft Mode and on-demand revalidation still function out of the box.
9
+ //
10
+ // HMAC-SHA256 is one-way, so a leaked derived secret never exposes the service-role
11
+ // key. The derived value is stable for a given Supabase project; rotating the
12
+ // service-role key rotates these secrets too (set the explicit env var to pin one
13
+ // independently — e.g. to configure a Supabase revalidation webhook with a fixed
14
+ // token). `npm run setup` still writes explicit values locally, so dev is unchanged.
15
+ //
16
+ // CRON_SECRET is intentionally NOT derived here: Vercel Cron injects
17
+ // `Authorization: Bearer ${process.env.CRON_SECRET}` read from the env at invocation,
18
+ // so a derived value could never match it. The cron routes instead treat CRON_SECRET
19
+ // as optional (enforced only when set) — see app/api/cron/*/route.ts.
20
+
21
+ function deriveFromServiceRole(label: string): string {
22
+ // Accept the Marketplace integration's SUPABASE_SECRET_KEY alias too, so derivation
23
+ // still works on a one-click deploy that only injected the new key name.
24
+ const master = (
25
+ process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY
26
+ )?.trim();
27
+ if (!master) return '';
28
+ return createHmac('sha256', master).update(`nextblock:${label}`).digest('hex');
29
+ }
30
+
31
+ /** Secret for the programmatic Draft Mode preview entry (`/api/draft?secret=`). */
32
+ export function resolveDraftModeSecret(): string {
33
+ return process.env.DRAFT_MODE_SECRET?.trim() || deriveFromServiceRole('draft-mode');
34
+ }
35
+
36
+ /** Shared secret a Supabase webhook sends to `/api/revalidate` (`x-revalidate-secret`). */
37
+ export function resolveRevalidateSecret(): string {
38
+ return process.env.REVALIDATE_SECRET_TOKEN?.trim() || deriveFromServiceRole('revalidate');
39
+ }
@@ -21,7 +21,10 @@ export function generateNumericCode(digits = 6): string {
21
21
  function getSecret(): string {
22
22
  // Dedicated secret if provided, otherwise fall back to the service-role key
23
23
  // (server-only, never shipped to the client).
24
- const secret = process.env.NB_2FA_SECRET || process.env.SUPABASE_SERVICE_ROLE_KEY;
24
+ const secret =
25
+ process.env.NB_2FA_SECRET ||
26
+ process.env.SUPABASE_SERVICE_ROLE_KEY ||
27
+ process.env.SUPABASE_SECRET_KEY;
25
28
  if (!secret) {
26
29
  throw new Error('Missing NB_2FA_SECRET / SUPABASE_SERVICE_ROLE_KEY for 2FA signing.');
27
30
  }
@@ -5,6 +5,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
5
5
  import { getS3PresignClient } from '@nextblock-cms/utils/server';
6
6
 
7
7
  import { resolveMediaUrl } from './media/resolveMediaUrl';
8
+ import { getStorageBackend, getStorageBucket } from './storage/provider';
9
+ import { supabaseCreateSignedUpload } from './storage/supabase-storage';
8
10
  import {
9
11
  buildR2UploadObjectKey,
10
12
  R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
@@ -30,17 +32,36 @@ export async function createR2PresignedUpload(
30
32
  payload: ValidR2PresignedUploadPayload,
31
33
  options: { now?: Date; nonce?: string; userId: string }
32
34
  ): Promise<R2PresignedUploadResult> {
33
- const bucketName = process.env.R2_BUCKET_NAME;
35
+ const backend = getStorageBackend();
36
+ const bucketName = getStorageBucket();
34
37
  if (!bucketName) {
35
38
  throw new R2PresignedUploadError('File uploads are not configured on this server.', 500);
36
39
  }
37
40
 
41
+ const objectKey = buildR2UploadObjectKey(payload, options);
42
+ const publicUrl = resolveMediaUrl(objectKey) ?? `/${objectKey}`;
43
+
44
+ if (backend === 'supabase') {
45
+ // Native Supabase Storage: a signed upload URL the browser PUTs to directly.
46
+ const { signedUrl } = await supabaseCreateSignedUpload(objectKey);
47
+ return {
48
+ expiresIn: R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
49
+ headers: {
50
+ 'Content-Type': payload.contentType,
51
+ },
52
+ method: 'PUT',
53
+ objectKey,
54
+ presignedUrl: signedUrl,
55
+ publicUrl,
56
+ uploadUrl: signedUrl,
57
+ };
58
+ }
59
+
38
60
  const s3Client = await getS3PresignClient();
39
61
  if (!s3Client) {
40
62
  throw new R2PresignedUploadError('File uploads are not configured on this server.', 500);
41
63
  }
42
64
 
43
- const objectKey = buildR2UploadObjectKey(payload, options);
44
65
  const command = new PutObjectCommand({
45
66
  Bucket: bucketName,
46
67
  ContentType: payload.contentType,
@@ -52,7 +73,6 @@ export async function createR2PresignedUpload(
52
73
  const presignedUrl = await getSignedUrl(s3Client, command, {
53
74
  expiresIn: R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
54
75
  });
55
- const publicUrl = resolveMediaUrl(objectKey) ?? `/${objectKey}`;
56
76
 
57
77
  return {
58
78
  expiresIn: R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
@@ -13,6 +13,8 @@ import {
13
13
  } from './provisioning';
14
14
  import { applyMigrations, resetDatabase } from './schema-apply';
15
15
  import { setSystemConfigurationServiceRole } from './system-config';
16
+ import { getStorageBackend } from '../storage/provider';
17
+ import { ensureStorageBucket } from '../storage/supabase-storage';
16
18
 
17
19
  export interface ActionResult {
18
20
  ok: boolean;
@@ -360,6 +362,20 @@ export async function completeSetup(input: CompleteSetupInput): Promise<ActionRe
360
362
  // fresh apply AND a cold cache over a pre-existing schema (Docker boot race / re-run).
361
363
  await waitForSchemaCache(admin);
362
364
 
365
+ // On the native Supabase Storage backend (zero-key Vercel deploy), provision the public
366
+ // media bucket now so the first upload doesn't have to. Best-effort — the upload path
367
+ // also ensures it lazily, so a transient failure here never blocks finishing setup.
368
+ if (getStorageBackend() === 'supabase') {
369
+ try {
370
+ await ensureStorageBucket();
371
+ } catch (caught) {
372
+ console.warn(
373
+ 'Could not pre-create the Supabase Storage media bucket (will retry on first upload):',
374
+ caught instanceof Error ? caught.message : caught,
375
+ );
376
+ }
377
+ }
378
+
363
379
  // 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
364
380
  try {
365
381
  if (input.turnstile && input.turnstile.provider !== 'none') {