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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const LIBRARY_SUPPORT: Record<string, { status: Status; detail?: string }> = {
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)" },
Expand Down
83 changes: 81 additions & 2 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

const CONFIG_FILES = [
Expand Down Expand Up @@ -228,9 +235,14 @@ export async function loadNextConfig(root: string): Promise<NextConfig | null> {
/**
* 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<ResolvedNextConfig> {
if (!config) {
return {
Expand All @@ -246,6 +258,7 @@ export async function resolveNextConfig(
i18n: null,
mdx: null,
serverActionsAllowedOrigins: [],
webpackAliases: {},
};
}

Expand Down Expand Up @@ -280,15 +293,23 @@ 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<string, unknown> | undefined;
const serverActionsConfig = experimental?.serverActions as Record<string, unknown> | undefined;
const serverActionsAllowedOrigins = Array.isArray(serverActionsConfig?.allowedOrigins)
? (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(
Expand Down Expand Up @@ -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" → "<root>/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<string, string> {
if (typeof config.webpack !== "function") return {};

const mockModuleRules: any[] = [];
const mockConfig = {
resolve: { alias: {} as Record<string, string> },
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<string, string> = {};
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.
Expand Down
13 changes: 12 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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[] = [];
Expand Down
46 changes: 46 additions & 0 deletions tests/ecosystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html lang="en"');

const { html: deHtml } = await fetchPage("/de");
expect(deHtml).toContain('<html lang="de"');
});

it("renders data-testid attributes for translated content", async () => {
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;
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/ecosystem/next-intl/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/ecosystem/next-intl/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useTranslations } from "next-intl";

export default function HomePage() {
Expand Down
8 changes: 4 additions & 4 deletions tests/fixtures/ecosystem/next-intl/i18n/request.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
24 changes: 24 additions & 0 deletions tests/fixtures/ecosystem/next-intl/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
3 changes: 3 additions & 0 deletions tests/fixtures/ecosystem/next-intl/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ import vinext from "vinext";

export default defineConfig({
plugins: [vinext()],
ssr: {
noExternal: ["next-intl"],
},
});
Loading
Loading