Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/c3-next-vinext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"create-cloudflare": minor
---

feat: make vinext the default Next.js scaffold, keep OpenNext as a variant

`create-cloudflare --framework=next` now prompts for a Next.js adapter:

- **vinext** (default / recommended) — scaffolds via `create-vinext-app` (`vinext dev` / `vinext build` / `vinext-cloudflare deploy`)
- **opennext** — keeps the previous OpenNext remote template for projects that need standard `next build` output or a capability vinext does not support yet

Non-interactive usage:

```sh
npm create cloudflare@latest my-app -- --framework=next --variant=vinext
npm create cloudflare@latest my-app -- --framework=next --variant=opennext
```

`-y` / `--accept-defaults` selects vinext. This aligns C3 with the recommended Next.js-on-Workers path in the Cloudflare docs while preserving an opt-in OpenNext path.
21 changes: 18 additions & 3 deletions packages/create-cloudflare/e2e/helpers/framework-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { ExpectStatic } from "vitest";
export type FrameworkTestConfig = RunnerConfig & {
testCommitMessage: boolean;
nodeCompat: boolean;
typesPath?: string;
unsupportedPms?: string[];
unsupportedOSs?: string[];
/**
Expand Down Expand Up @@ -326,10 +327,14 @@ export async function verifyPreviewScript(

export async function verifyTypes(
expect: ExpectStatic,
{ nodeCompat, verifyTypes: verify }: FrameworkTestConfig,
{
nodeCompat,
typesPath: configuredTypesPath,
verifyTypes: verify,
}: FrameworkTestConfig,
{
workersTypes,
typesPath = "./worker-configuration.d.ts",
typesPath: templateTypesPath = "./worker-configuration.d.ts",
envInterfaceName = "Env",
}: TemplateConfig,
projectPath: string
Expand All @@ -338,6 +343,7 @@ export async function verifyTypes(
return;
}

const typesPath = configuredTypesPath ?? templateTypesPath;
const outputFileContent = readFile(join(projectPath, typesPath)).split("\n");

const hasEnvInterface = outputFileContent.some(
Expand Down Expand Up @@ -456,7 +462,16 @@ export function getFrameworkConfig(frameworkKey: string) {
const frameworkMap = getFrameworkMap({
experimental: isExperimental,
});
const [frameworkId, platformVariant] = frameworkKey.split(":");
// Test keys may include optional labels after the framework id, e.g.
// "next" | "next:vinext" | "nuxt:pages" | "nuxt:pages:minimal"
// Only "pages" / "workers" are treated as platform variants; any other
// trailing segment is a disambiguating label ignored here.
const [frameworkId, second, third] = frameworkKey.split(":");
const platformVariant =
second === "pages" || second === "workers" ? second : undefined;
const _label = platformVariant ? third : second; // reserved for callers
void _label;

if ("platformVariants" in frameworkMap[frameworkId]) {
assert(
platformVariant === "pages" || platformVariant === "workers",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,16 @@ describe
envInterfaceName: "Env",
...getFrameworkConfig(testConfig.name),
};
// A third `:`-segment in the test name (e.g. `nuxt:pages:minimal`)
// is a variant label that disambiguates tests sharing the same
// framework id + platform. getFrameworkConfig ignores it.
const variantLabel = testConfig.name.split(":")[2];
// Trailing `:`-segments disambiguate tests that share a framework
// id (and optional platform). Examples:
// "nuxt:pages:minimal" → label "minimal"
// "next:opennext" → label "opennext"
// getFrameworkConfig ignores non-platform labels.
const nameParts = testConfig.name.split(":");
const variantLabel =
nameParts[1] === "pages" || nameParts[1] === "workers"
? nameParts[2]
: nameParts[1];
test.runIf(shouldRunTest(testConfig))(
`${frameworkConfig.id} (${frameworkConfig.platform ?? "pages"})${
variantLabel ? ` [${variantLabel}]` : ""
Expand Down
51 changes: 48 additions & 3 deletions packages/create-cloudflare/e2e/tests/frameworks/test-config.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,30 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] {
},
{
name: "next",
// Default Next.js path: vinext via create-vinext-app.
argv: ["--variant", "vinext"],
timeout: LONG_TIMEOUT,
testCommitMessage: true,
// preview script is `build && start` (wrangler dev on build output).
verifyPreview: {
previewArgs: ["--inspector-port=0"],
route: "/",
expectedText: "vinext + Cloudflare Workers",
},
verifyDeploy: {
route: "/",
expectedText: "vinext + Cloudflare Workers",
},
nodeCompat: true,
unsupportedOSs: ["win32"],
},
{
name: "next:opennext",
// Opt-in OpenNext adapter path (previous C3 default).
argv: ["--variant", "opennext"],
timeout: LONG_TIMEOUT,
testCommitMessage: true,
typesPath: "./cloudflare-env.d.ts",
verifyPreview: {
previewArgs: ["--", "--inspector-port=0"],
route: "/",
Expand Down Expand Up @@ -1098,13 +1120,36 @@ function getExperimentalFrameworkTestConfig(
},
{
name: "next",
argv: ["--platform", "workers"],
// Default experimental Next.js path: vinext.
argv: ["--platform", "workers", "--variant", "vinext"],
flags: ["--yes"],
testCommitMessage: true,
unsupportedOSs: ["win32"],
quarantine: !CLOUDFLARE_API_TOKEN,
timeout: LONG_TIMEOUT,
verifyDeploy: {
route: "/",
expectedText: "vinext + Cloudflare Workers",
},
verifyPreview: {
previewArgs: ["--inspector-port=0"],
route: "/",
expectedText: "vinext + Cloudflare Workers",
},
nodeCompat: true,
// vinext does not ship a pre-baked cloudflare-env.d.ts the way the
// OpenNext template does.
verifyTypes: false,
},
{
name: "next:opennext",
// Opt-in OpenNext adapter path under --experimental.
argv: ["--platform", "workers", "--variant", "opennext"],
flags: ["--yes"],
testCommitMessage: true,
unsupportedOSs: ["win32"],
unsupportedPms: ["npm", "yarn"],
// this test creates an R2 bucket, so it requires a Cloudflare API token
// and needs to be skipped on forks
// OpenNext template may create an R2 bucket; requires a CF API token.
quarantine: !CLOUDFLARE_API_TOKEN,
timeout: LONG_TIMEOUT,
verifyDeploy: {
Expand Down
1 change: 1 addition & 0 deletions packages/create-cloudflare/src/frameworks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"create-docusaurus": "3.10.2",
"create-hono": "0.19.4",
"create-next-app": "16.2.11",
"create-vinext-app": "1.0.0-beta.1",
"create-qwik": "1.20.0",
"create-react-router": "8.3.0",
"create-rwsdk": "3.1.3",
Expand Down
128 changes: 124 additions & 4 deletions packages/create-cloudflare/templates/next/c3.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,96 @@
import assert from "node:assert";
import { logRaw } from "@cloudflare/cli-shared-helpers";
import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive";
import { runFrameworkGenerator } from "frameworks/index";
import { detectPackageManager } from "helpers/packageManagers";
import { downloadRemoteTemplate, updatePackageName } from "../../src/templates";
import type { TemplateConfig } from "../../src/templates";
import type { C3Context } from "types";

const generate = async (ctx: C3Context) => {
const { npm } = detectPackageManager();

type NextVariantValue = "vinext" | "opennext";

const VINEXT_TYPES_PATH = "./worker-configuration.d.ts";
const OPENNEXT_TYPES_PATH = "./cloudflare-env.d.ts";

type NextVariant = {
value: NextVariantValue;
label: string;
};

const NEXT_VARIANTS: NextVariant[] = [
{
value: "vinext",
label: "vinext (recommended)",
},
{
value: "opennext",
label: "OpenNext adapter",
},
];

async function getNextVariant(ctx: C3Context): Promise<NextVariant> {
if (ctx.args.variant) {
const selected = NEXT_VARIANTS.find(
(variant) => variant.value === ctx.args.variant
);
if (!selected) {
throw new Error(
`Unknown Next.js variant "${
ctx.args.variant
}". Valid variants are: ${NEXT_VARIANTS.map((v) => v.value).join(", ")}`
);
}
return selected;
}

const value = await inputPrompt({
type: "select",
question: "Which Next.js adapter do you want to use?",
label: "variant",
options: NEXT_VARIANTS,
defaultValue: NEXT_VARIANTS[0].value,
// Honour -y / --accept-defaults by taking the recommended vinext path.
acceptDefault: Boolean(ctx.args.acceptDefaults),
});

const selected = NEXT_VARIANTS.find((variant) => variant.value === value);
assert(selected, "Expected a Next.js variant to be selected");
return selected;
}

async function generateVinext(ctx: C3Context) {
// Delegate to create-vinext-app, which scaffolds a Next.js App Router project
// already configured for vinext + Cloudflare Workers.
const pmFlag =
npm === "pnpm"
? "--use-pnpm"
: npm === "yarn"
? "--use-yarn"
: npm === "bun"
? "--use-bun"
: "--use-npm";

await runFrameworkGenerator(ctx, [
ctx.project.name,
"--platform",
"cloudflare",
// Avoid a placeholder KV namespace id that would block `wrangler deploy`
// until the user manually provisions one. Caching can be added later
// via vinext's Cloudflare cache adapters.
"--data-cache",
"none",
"--yes",
"--skip-install",
"--disable-git",
pmFlag,
]);

logRaw("");
}

async function generateOpenNext(ctx: C3Context) {
// Easy way to switch branch for local testing
const branch = "main";

Expand All @@ -13,20 +101,52 @@ const generate = async (ctx: C3Context) => {
});

await updatePackageName(ctx);
}

const generate = async (ctx: C3Context) => {
const variant = await getNextVariant(ctx);
// Stash on args so transformPackageJson can branch without re-prompting.
ctx.args.variant = variant.value;
ctx.template.typesPath =
variant.value === "opennext" ? OPENNEXT_TYPES_PATH : VINEXT_TYPES_PATH;

if (variant.value === "opennext") {
await generateOpenNext(ctx);
return;
}

await generateVinext(ctx);
};

const envInterfaceName = "CloudflareEnv";
const typesPath = "./cloudflare-env.d.ts";

export default {
configVersion: 1,
id: "next",
frameworkCli: "create-next-app",
// Used by runFrameworkGenerator for the vinext path. OpenNext downloads a
// remote template and does not invoke this CLI.
frameworkCli: "create-vinext-app",
platform: "workers",
displayName: "Next.js",
generate,
transformPackageJson: async (_pkgJson, ctx) => {
// OpenNext's remote template already has deploy/preview/cf-typegen.
if (ctx.args.variant === "opennext") {
return {};
}

return {
scripts: {
// Align with OpenNext so the shared previewScript: "preview" works
// for both variants (vinext only ships dev/build/start/deploy).
preview: `${npm} run build && ${npm} run start`,
"cf-typegen": `wrangler types --env-interface ${envInterfaceName} ${VINEXT_TYPES_PATH}`,
},
};
},
devScript: "dev",
previewScript: "preview",
deployScript: "deploy",
typesPath,
typesPath: VINEXT_TYPES_PATH,
envInterfaceName,
} as TemplateConfig;
30 changes: 4 additions & 26 deletions packages/create-cloudflare/templates/next/experimental_c3.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,4 @@
import { runFrameworkGenerator } from "frameworks/index";
import type { TemplateConfig } from "../../src/templates";
import type { C3Context } from "types";

const generate = async (ctx: C3Context) => {
await runFrameworkGenerator(ctx, [
ctx.project.name,
"--skip-install",
]);
};

const envInterfaceName = "CloudflareEnv";
const typesPath = "./cloudflare-env.d.ts";
export default {
configVersion: 1,
id: "next",
frameworkCli: "create-next-app",
platform: "workers",
displayName: "Next.js",
generate,
devScript: "dev",
previewScript: "preview",
deployScript: "deploy",
typesPath,
envInterfaceName,
} as TemplateConfig;
// Experimental Next.js path shares the stable vinext/OpenNext variant flow.
// create-vinext-app (default) and the OpenNext remote template are both already
// Workers-ready, so no separate experimental generator is needed.
export { default } from "./c3";