From e9228d1e5ff10ba4ba510382cc382fc89c920079 Mon Sep 17 00:00:00 2001 From: SeolJaeHyeok Date: Sat, 28 Feb 2026 19:35:27 +0900 Subject: [PATCH 1/3] fix: extract webpack resolve.alias from next.config plugins (#177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins like next-intl's createNextIntlPlugin / withNextIntl add module aliases via webpack.resolve.alias (e.g. "next-intl/config" → the user's i18n/request.ts). Vite never saw these aliases, so next-intl/config resolved to a stub that throws "Couldn't find next-intl config file". Fix: probe config.webpack() with the real project root to extract string-valued aliases, store them in ResolvedNextConfig.webpackAliases, and merge them into Vite's resolve.alias (vinext's own shims take precedence). Also suppresses the spurious "webpack not yet supported" warning when the webpack function was useful for alias extraction.# --- packages/vinext/src/config/next-config.ts | 83 ++++++++++++++++++++++- packages/vinext/src/index.ts | 13 +++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 15ad699e..82191950 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -139,6 +139,13 @@ export interface ResolvedNextConfig { mdx: MdxOptions | null; /** Extra allowed origins for server action CSRF validation (from experimental.serverActions.allowedOrigins). */ serverActionsAllowedOrigins: string[]; + /** + * Module aliases extracted from next.config webpack function. + * Plugins like next-intl's createNextIntlPlugin set webpack.resolve.alias + * to redirect module imports (e.g. "next-intl/config" → user's i18n/request.ts). + * These are forwarded to Vite's resolve.alias so the same redirections apply. + */ + webpackAliases: Record; } const CONFIG_FILES = [ @@ -228,9 +235,14 @@ export async function loadNextConfig(root: string): Promise { /** * Resolve a NextConfig into a fully-resolved ResolvedNextConfig. * Awaits async functions for redirects/rewrites/headers. + * + * @param config The raw next.config value (or null if no config file found) + * @param root The project root directory, used to extract webpack aliases from + * next.config plugins (e.g. next-intl's createNextIntlPlugin / withNextIntl). */ export async function resolveNextConfig( config: NextConfig | null, + root?: string, ): Promise { if (!config) { return { @@ -246,6 +258,7 @@ export async function resolveNextConfig( i18n: null, mdx: null, serverActionsAllowedOrigins: [], + webpackAliases: {}, }; } @@ -280,6 +293,12 @@ export async function resolveNextConfig( // Extract MDX remark/rehype plugins from @next/mdx's webpack wrapper const mdx = extractMdxOptions(config); + // Extract module aliases from next.config webpack function. + // Plugins like next-intl's createNextIntlPlugin add webpack.resolve.alias + // entries (e.g. "next-intl/config" → user's i18n/request.ts) that Vite + // must also apply for correct module resolution. + const webpackAliases = root ? extractWebpackAliases(config, root) : {}; + // Resolve serverActions.allowedOrigins from experimental config const experimental = config.experimental as Record | undefined; const serverActionsConfig = experimental?.serverActions as Record | undefined; @@ -287,8 +306,10 @@ export async function resolveNextConfig( ? (serverActionsConfig.allowedOrigins as string[]) : []; - // Warn about unsupported options (skip webpack if we extracted MDX from it) - const unsupported = mdx ? [] : ["webpack"]; + // Warn about unsupported options (skip webpack if we extracted MDX or + // plugin aliases from it — in those cases the webpack function was useful). + const webpackHandled = mdx !== null || Object.keys(webpackAliases).length > 0; + const unsupported = webpackHandled ? [] : ["webpack"]; for (const key of unsupported) { if (config[key] !== undefined) { console.warn( @@ -326,9 +347,67 @@ export async function resolveNextConfig( i18n, mdx, serverActionsAllowedOrigins, + webpackAliases, }; } +/** + * Extract module aliases from a next.config webpack function. + * + * Plugins like next-intl's createNextIntlPlugin / withNextIntl use + * webpack.resolve.alias to redirect module imports — for example: + * "next-intl/config" → "/i18n/request.ts" + * + * Vite needs these same aliases for correct module resolution. This function + * probes the webpack function with a mock config (using the real project root + * so path resolution works) and extracts the resulting alias map. + * + * @param config The next.config object (must have a webpack function) + * @param root The project root directory (used to resolve alias target paths) + * @returns A Record of alias name → absolute path (string-valued entries only) + */ +export function extractWebpackAliases( + config: NextConfig, + root: string, +): Record { + if (typeof config.webpack !== "function") return {}; + + const mockModuleRules: any[] = []; + const mockConfig = { + resolve: { alias: {} as Record }, + module: { rules: mockModuleRules }, + plugins: [] as any[], + context: root, + }; + const mockOptions = { + defaultLoaders: { babel: { loader: "next-babel-loader" } }, + isServer: false, + dev: false, + dir: root, + }; + + try { + const result = (config.webpack as Function)(mockConfig, mockOptions); + const finalConfig = result ?? mockConfig; + const alias = finalConfig.resolve?.alias ?? mockConfig.resolve.alias; + + // Only collect string-valued aliases (skip regex patterns and arrays, + // which are webpack-specific and don't translate to Vite aliases). + const aliases: Record = {}; + for (const [key, value] of Object.entries(alias)) { + if (typeof value === "string") { + aliases[key] = value; + } + } + return aliases; + } catch { + // If the webpack function throws (e.g. missing i18n/request.ts or other + // webpack internals required), silently return empty — the caller handles + // missing aliases gracefully. + return {}; + } +} + /** * Extract MDX compilation options (remark/rehype/recma plugins) from * a Next.js config that uses @next/mdx. diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2f15a6ee..89118535 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1687,7 +1687,7 @@ hydrate(); // Load next.config.js if present (always from project root, not src/) const rawConfig = await loadNextConfig(root); - nextConfig = await resolveNextConfig(rawConfig); + nextConfig = await resolveNextConfig(rawConfig, root); // Merge env from next.config.js with NEXT_PUBLIC_* env vars const defines = getNextPublicEnvDefines(); @@ -1781,6 +1781,17 @@ hydrate(); "vinext/html": path.resolve(__dirname, "server", "html"), }; + // Merge module aliases from next.config webpack plugins (e.g. next-intl's + // createNextIntlPlugin / withNextIntl). These map package names to the + // user's project files — for example, "next-intl/config" → i18n/request.ts. + // Vinext's own shims always take precedence, so only add entries that are + // not already in nextShimMap. + for (const [key, value] of Object.entries(nextConfig.webpackAliases)) { + if (!nextShimMap[key]) { + nextShimMap[key] = value; + } + } + // Detect if Cloudflare's vite plugin is present — if so, skip // SSR externals (Workers bundle everything, can't have Node.js externals). const pluginsFlat: any[] = []; From 9fef35f435cab91c68761ab628a5f83b6cd9e325 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Sat, 28 Feb 2026 16:01:43 -0600 Subject: [PATCH 2/3] test: add next-intl ecosystem tests and extractWebpackAliases unit tests - Add ecosystem E2E tests verifying SSR works for English and German locales - Add next.config.ts with webpack alias (simulating createNextIntlPlugin) - Update fixture to use 'use client' page component with NextIntlClientProvider - Add 8 unit tests for extractWebpackAliases covering aliases, non-string values, error handling, and resolveNextConfig integration --- tests/ecosystem.test.ts | 46 +++++++ .../next-intl/app/[locale]/layout.tsx | 4 +- .../ecosystem/next-intl/app/[locale]/page.tsx | 2 + .../ecosystem/next-intl/i18n/request.ts | 8 +- .../ecosystem/next-intl/next.config.ts | 24 ++++ .../ecosystem/next-intl/vite.config.ts | 3 + tests/shims.test.ts | 122 ++++++++++++++++++ 7 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/ecosystem/next-intl/next.config.ts diff --git a/tests/ecosystem.test.ts b/tests/ecosystem.test.ts index 1496075d..9cf7cfa4 100644 --- a/tests/ecosystem.test.ts +++ b/tests/ecosystem.test.ts @@ -203,6 +203,52 @@ describe("nuqs", () => { }); }); +// ─── next-intl ──────────────────────────────────────────────────────────────── +describe("next-intl", () => { + let proc: ChildProcess | null = null; + let fetchPage: (path: string) => Promise<{ html: string; status: number }>; + + beforeAll(async () => { + const fixture = await startFixture("next-intl", 4404); + proc = fixture.process; + fetchPage = fixture.fetchPage; + }, 30000); + + afterAll(() => killProcess(proc)); + + it("renders English translations at /en", async () => { + const { html, status } = await fetchPage("/en"); + expect(status).toBe(200); + expect(html).toContain("Hello World"); + expect(html).toContain( + "This page uses next-intl for internationalization.", + ); + }); + + it("renders German translations at /de", async () => { + const { html, status } = await fetchPage("/de"); + expect(status).toBe(200); + expect(html).toContain("Hallo Welt"); + expect(html).toContain( + "Diese Seite verwendet next-intl zur Internationalisierung.", + ); + }); + + it("sets html lang attribute per locale", async () => { + const { html: enHtml } = await fetchPage("/en"); + expect(enHtml).toContain(' { + const { html } = await fetchPage("/en"); + expect(html).toContain('data-testid="title"'); + expect(html).toContain('data-testid="description"'); + }); +}); + // ─── better-auth ────────────────────────────────────────────────────────────── describe("better-auth", () => { let proc: ChildProcess | null = null; diff --git a/tests/fixtures/ecosystem/next-intl/app/[locale]/layout.tsx b/tests/fixtures/ecosystem/next-intl/app/[locale]/layout.tsx index 74f24546..11f568e0 100644 --- a/tests/fixtures/ecosystem/next-intl/app/[locale]/layout.tsx +++ b/tests/fixtures/ecosystem/next-intl/app/[locale]/layout.tsx @@ -9,12 +9,12 @@ export default async function LocaleLayout({ params: Promise<{ locale: string }>; }) { const { locale } = await params; - const messages = await getMessages(); + const messages = await getMessages({ locale }); return ( - + {children} diff --git a/tests/fixtures/ecosystem/next-intl/app/[locale]/page.tsx b/tests/fixtures/ecosystem/next-intl/app/[locale]/page.tsx index e8d5be80..d7665662 100644 --- a/tests/fixtures/ecosystem/next-intl/app/[locale]/page.tsx +++ b/tests/fixtures/ecosystem/next-intl/app/[locale]/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useTranslations } from "next-intl"; export default function HomePage() { diff --git a/tests/fixtures/ecosystem/next-intl/i18n/request.ts b/tests/fixtures/ecosystem/next-intl/i18n/request.ts index c06a9737..c932bed5 100644 --- a/tests/fixtures/ecosystem/next-intl/i18n/request.ts +++ b/tests/fixtures/ecosystem/next-intl/i18n/request.ts @@ -1,9 +1,9 @@ import { getRequestConfig } from "next-intl/server"; -export default getRequestConfig(async ({ locale }) => { - const resolvedLocale = locale ?? "en"; +export default getRequestConfig(async ({ requestLocale }) => { + const locale = (await requestLocale) ?? "en"; return { - locale: resolvedLocale, - messages: (await import(`../messages/${resolvedLocale}.json`)).default, + locale, + messages: (await import(`../messages/${locale}.json`)).default, }; }); diff --git a/tests/fixtures/ecosystem/next-intl/next.config.ts b/tests/fixtures/ecosystem/next-intl/next.config.ts new file mode 100644 index 00000000..8491ac8c --- /dev/null +++ b/tests/fixtures/ecosystem/next-intl/next.config.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Simulates what next-intl's createNextIntlPlugin / withNextIntl does: + * it registers a webpack.resolve.alias mapping "next-intl/config" to the + * user's i18n/request.ts file. We can't use createNextIntlPlugin directly + * because it tries to detect the Next.js version (which isn't installed in + * vinext projects). This config exercises vinext's extractWebpackAliases + * just like the real plugin would. + */ +export default { + webpack: (config: any) => { + if (!config.resolve) config.resolve = {}; + if (!config.resolve.alias) config.resolve.alias = {}; + config.resolve.alias["next-intl/config"] = path.resolve( + __dirname, + "i18n/request.ts", + ); + return config; + }, +}; diff --git a/tests/fixtures/ecosystem/next-intl/vite.config.ts b/tests/fixtures/ecosystem/next-intl/vite.config.ts index a6529598..420ae075 100644 --- a/tests/fixtures/ecosystem/next-intl/vite.config.ts +++ b/tests/fixtures/ecosystem/next-intl/vite.config.ts @@ -3,4 +3,7 @@ import vinext from "vinext"; export default defineConfig({ plugins: [vinext()], + ssr: { + noExternal: ["next-intl"], + }, }); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1a907f26..94f4ed76 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4885,6 +4885,128 @@ describe("extractMdxOptions", () => { }); }); +describe("extractWebpackAliases", () => { + it("returns empty when no webpack function", async () => { + const { extractWebpackAliases } = await import( + "../packages/vinext/src/config/next-config.js" + ); + expect(extractWebpackAliases({}, "/tmp")).toEqual({}); + expect(extractWebpackAliases({ webpack: "not a function" }, "/tmp")).toEqual({}); + }); + + it("extracts string-valued aliases from webpack resolve.alias", async () => { + const { extractWebpackAliases } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = { + webpack: (webpackConfig: any) => { + webpackConfig.resolve.alias["next-intl/config"] = "/app/i18n/request.ts"; + webpackConfig.resolve.alias["my-lib/settings"] = "/app/settings.ts"; + return webpackConfig; + }, + }; + const result = extractWebpackAliases(config, "/app"); + expect(result).toEqual({ + "next-intl/config": "/app/i18n/request.ts", + "my-lib/settings": "/app/settings.ts", + }); + }); + + it("skips non-string alias values (regex, arrays)", async () => { + const { extractWebpackAliases } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = { + webpack: (webpackConfig: any) => { + webpackConfig.resolve.alias["valid"] = "/app/valid.ts"; + webpackConfig.resolve.alias["regex-alias"] = /some-regex/; + webpackConfig.resolve.alias["array-alias"] = ["/a", "/b"]; + webpackConfig.resolve.alias["false-alias"] = false; + return webpackConfig; + }, + }; + const result = extractWebpackAliases(config, "/app"); + expect(result).toEqual({ valid: "/app/valid.ts" }); + }); + + it("returns empty when webpack throws", async () => { + const { extractWebpackAliases } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = { + webpack: () => { + throw new Error("plugin requires real webpack"); + }, + }; + expect(extractWebpackAliases(config, "/tmp")).toEqual({}); + }); + + it("handles webpack function that mutates config without returning", async () => { + const { extractWebpackAliases } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = { + webpack: (webpackConfig: any) => { + webpackConfig.resolve.alias["next-intl/config"] = "/app/i18n/request.ts"; + // No return statement (some plugins mutate in-place) + }, + }; + const result = extractWebpackAliases(config, "/app"); + expect(result).toEqual({ + "next-intl/config": "/app/i18n/request.ts", + }); + }); + + it("passes root as context and dir to the webpack function", async () => { + const { extractWebpackAliases } = await import( + "../packages/vinext/src/config/next-config.js" + ); + let capturedContext: string | undefined; + let capturedDir: string | undefined; + const config = { + webpack: (webpackConfig: any, options: any) => { + capturedContext = webpackConfig.context; + capturedDir = options.dir; + return webpackConfig; + }, + }; + extractWebpackAliases(config, "/my/project"); + expect(capturedContext).toBe("/my/project"); + expect(capturedDir).toBe("/my/project"); + }); + + it("resolveNextConfig populates webpackAliases when root is provided", async () => { + const { resolveNextConfig } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = await resolveNextConfig( + { + webpack: (webpackConfig: any) => { + webpackConfig.resolve.alias["next-intl/config"] = "/app/i18n/request.ts"; + return webpackConfig; + }, + }, + "/app", + ); + expect(config.webpackAliases).toEqual({ + "next-intl/config": "/app/i18n/request.ts", + }); + }); + + it("resolveNextConfig returns empty webpackAliases when root is not provided", async () => { + const { resolveNextConfig } = await import( + "../packages/vinext/src/config/next-config.js" + ); + const config = await resolveNextConfig({ + webpack: (webpackConfig: any) => { + webpackConfig.resolve.alias["next-intl/config"] = "/app/i18n/request.ts"; + return webpackConfig; + }, + }); + expect(config.webpackAliases).toEqual({}); + }); +}); + describe("next/web-vitals shim", () => { it("exports useReportWebVitals as a no-op function", async () => { const { useReportWebVitals } = await import( From 6c0c0e785d8090cde4462663c49d01a5b3fca416 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Sat, 28 Feb 2026 16:07:02 -0600 Subject: [PATCH 3/3] docs: add ecosystem library compatibility section to README - Add table of tested libraries with status and notes to README - Update next-intl status in check.ts to reflect current state - Update migration skill compatibility reference - Reference issue #202 for createNextIntlPlugin workaround --- .../references/compatibility.md | 2 +- README.md | 21 +++++++++++++++++++ packages/vinext/src/check.ts | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.agents/skills/migrate-to-vinext/references/compatibility.md b/.agents/skills/migrate-to-vinext/references/compatibility.md index 944ebfdd..868fffa3 100644 --- a/.agents/skills/migrate-to-vinext/references/compatibility.md +++ b/.agents/skills/migrate-to-vinext/references/compatibility.md @@ -91,7 +91,7 @@ Tested and working: - next-themes - nuqs - next-view-transitions -- next-intl +- next-intl (partial: `createNextIntlPlugin` requires manual webpack alias workaround, see #202) - better-auth - @vercel/analytics - tailwindcss diff --git a/README.md b/README.md index 14ba39a4..a82d62e0 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,27 @@ These are intentional exclusions: - **Node.js production server (`vinext start`)** works for testing but is less complete than Workers deployment. Cloudflare Workers is the primary target. - **Native Node modules (sharp, resvg, satori, lightningcss, @napi-rs/canvas)** crash Vite's RSC dev environment. Dynamic OG image/icon routes using these work in production builds but not in dev mode. These are auto-stubbed during `vinext deploy`. +## Ecosystem library compatibility + +Popular Next.js libraries tested with vinext. Run `vinext check` in your project to scan for known compatibility issues. + +| Library | Status | Notes | +|---------|--------|-------| +| next-themes | ✅ | Works out of the box | +| nuqs | ✅ | URL search param state management. Requires `ssr.noExternal: ["nuqs"]` in Vite config | +| next-view-transitions | ✅ | Works out of the box | +| better-auth | ✅ | Uses only public `next/*` APIs (headers, cookies, NextRequest/NextResponse) | +| @vercel/analytics | ✅ | Analytics script injected client-side | +| tailwindcss | ✅ | Works out of the box | +| framer-motion | ✅ | Works out of the box | +| shadcn-ui | ✅ | Works out of the box | +| next-intl | 🟡 | Webpack alias extraction works (PR #196), but `createNextIntlPlugin` fails without `next` installed. Manual alias workaround available. See [#202](https://github.com/cloudflare/vinext/issues/202) | +| @sentry/nextjs | 🟡 | Client-side works, server integration needs manual setup | +| @clerk/nextjs | ❌ | Deep Next.js middleware integration not compatible | +| next-auth / @auth/nextjs | ❌ | Relies on Next.js API route internals. Consider [better-auth](https://www.better-auth.com/) | + +Libraries that only import from `next/*` public APIs generally work. Libraries that depend on Next.js build plugins or internal webpack configuration need custom shimming or manual workarounds. + ## Benchmarks > **Caveat:** Benchmarks are hard to get right and these are early results. Take them as directional, not definitive. diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index f5db48f4..c8899e18 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -89,7 +89,7 @@ const LIBRARY_SUPPORT: Record = { nuqs: { status: "supported" }, "next-view-transitions": { status: "supported" }, "@vercel/analytics": { status: "supported", detail: "analytics script injected client-side" }, - "next-intl": { status: "partial", detail: "works with middleware-based setup, some server component features may differ" }, + "next-intl": { status: "partial", detail: "webpack alias extraction works, but createNextIntlPlugin requires manual alias workaround (see #202). requestLocale not populated without middleware" }, "@clerk/nextjs": { status: "unsupported", detail: "deep Next.js middleware integration not compatible" }, "@auth/nextjs": { status: "unsupported", detail: "relies on Next.js internal auth handlers; consider migrating to better-auth" }, "next-auth": { status: "unsupported", detail: "relies on Next.js API route internals; consider migrating to better-auth (see https://authjs.dev/getting-started/migrate-to-better-auth)" },