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)" }, 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[] = []; 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(