diff --git a/.changeset/upgrade-codemod-sdk-filtering.md b/.changeset/upgrade-codemod-sdk-filtering.md
new file mode 100644
index 00000000000..88e6fdad9d6
--- /dev/null
+++ b/.changeset/upgrade-codemod-sdk-filtering.md
@@ -0,0 +1,5 @@
+---
+'@clerk/upgrade': minor
+---
+
+Add `transform-clerk-provider-inside-body` codemod for Next.js 16 cache components support
diff --git a/packages/upgrade/src/__tests__/integration/runner.test.js b/packages/upgrade/src/__tests__/integration/runner.test.js
index e3e5b2fcc72..957d6b0f477 100644
--- a/packages/upgrade/src/__tests__/integration/runner.test.js
+++ b/packages/upgrade/src/__tests__/integration/runner.test.js
@@ -1,9 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadConfig } from '../../config.js';
-import { runScans } from '../../runner.js';
+import { runCodemods, runScans } from '../../runner.js';
import { createTempFixture } from '../helpers/create-fixture.js';
+const mockRunCodemod = vi.fn(() => Promise.resolve({ stats: {} }));
+
+vi.mock('../../codemods/index.js', () => ({
+ runCodemod: (...args) => mockRunCodemod(...args),
+ getCodemodConfig: vi.fn(() => null),
+}));
+
vi.mock('../../render.js', () => ({
colors: { reset: '', bold: '', yellow: '', gray: '' },
createSpinner: vi.fn(() => ({
@@ -17,6 +24,122 @@ vi.mock('../../render.js', () => ({
renderText: vi.fn(),
}));
+describe('runCodemods', () => {
+ beforeEach(() => {
+ mockRunCodemod.mockClear();
+ });
+
+ it('runs all codemods when no packages filter is specified', async () => {
+ const config = {
+ codemods: ['transform-a', 'transform-b'],
+ };
+
+ await runCodemods(config, 'nextjs', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).toHaveBeenCalledTimes(2);
+ expect(mockRunCodemod).toHaveBeenCalledWith('transform-a', ['**/*.tsx'], { glob: '**/*.tsx' });
+ expect(mockRunCodemod).toHaveBeenCalledWith('transform-b', ['**/*.tsx'], { glob: '**/*.tsx' });
+ });
+
+ it('skips codemods that do not match the current SDK', async () => {
+ const config = {
+ codemods: [
+ 'transform-all', // runs for all
+ { name: 'transform-nextjs-only', packages: ['nextjs'] },
+ { name: 'transform-react-only', packages: ['react'] },
+ ],
+ };
+
+ await runCodemods(config, 'nextjs', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).toHaveBeenCalledTimes(2);
+ expect(mockRunCodemod).toHaveBeenCalledWith('transform-all', ['**/*.tsx'], { glob: '**/*.tsx' });
+ expect(mockRunCodemod).toHaveBeenCalledWith('transform-nextjs-only', ['**/*.tsx'], { glob: '**/*.tsx' });
+ });
+
+ it('runs codemods with wildcard packages for any SDK', async () => {
+ const config = {
+ codemods: [{ name: 'transform-universal', packages: ['*'] }],
+ };
+
+ await runCodemods(config, 'expo', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).toHaveBeenCalledTimes(1);
+ expect(mockRunCodemod).toHaveBeenCalledWith('transform-universal', ['**/*.tsx'], { glob: '**/*.tsx' });
+ });
+
+ it('runs codemods when SDK is in the packages array', async () => {
+ const config = {
+ codemods: [{ name: 'transform-multi', packages: ['nextjs', 'react', 'expo'] }],
+ };
+
+ await runCodemods(config, 'react', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips all codemods when SDK does not match any', async () => {
+ const config = {
+ codemods: [
+ { name: 'transform-nextjs-only', packages: ['nextjs'] },
+ { name: 'transform-react-only', packages: ['react'] },
+ ],
+ };
+
+ await runCodemods(config, 'expo', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).not.toHaveBeenCalled();
+ });
+
+ it('handles empty codemods array', async () => {
+ const config = {
+ codemods: [],
+ };
+
+ await runCodemods(config, 'nextjs', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).not.toHaveBeenCalled();
+ });
+
+ it('handles undefined codemods', async () => {
+ const config = {};
+
+ await runCodemods(config, 'nextjs', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).not.toHaveBeenCalled();
+ });
+
+ it('treats empty packages array as matching no SDKs', async () => {
+ const config = {
+ codemods: [{ name: 'transform-none', packages: [] }],
+ };
+
+ await runCodemods(config, 'nextjs', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).not.toHaveBeenCalled();
+ });
+
+ it('treats undefined packages as matching all SDKs', async () => {
+ const config = {
+ codemods: [{ name: 'transform-all', packages: undefined }],
+ };
+
+ await runCodemods(config, 'expo', { glob: '**/*.tsx' });
+
+ expect(mockRunCodemod).toHaveBeenCalledTimes(1);
+ });
+
+ it('propagates errors from codemod execution', async () => {
+ mockRunCodemod.mockRejectedValueOnce(new Error('Codemod failed'));
+
+ const config = {
+ codemods: ['transform-broken'],
+ };
+
+ await expect(runCodemods(config, 'nextjs', { glob: '**/*.tsx' })).rejects.toThrow('Codemod failed');
+ });
+});
+
describe('runScans', () => {
let fixture;
diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-clerk-provider-inside-body.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-clerk-provider-inside-body.fixtures.js
new file mode 100644
index 00000000000..43806155cff
--- /dev/null
+++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-clerk-provider-inside-body.fixtures.js
@@ -0,0 +1,267 @@
+export const fixtures = [
+ {
+ name: 'Moves ClerkProvider from wrapping html to inside body',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+ `,
+ output: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
{children}
+
+ );
+}
+ `,
+ },
+ {
+ name: 'Preserves ClerkProvider props when moving',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+ `,
+ output: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+ `,
+ },
+ {
+ name: 'Does not transform if ClerkProvider is already inside body',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+ `,
+ output: '',
+ },
+ {
+ name: 'Does not transform if ClerkProvider does not wrap html',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function Page() {
+ return (
+
+ {children}
+
+ );
+}
+ `,
+ output: '',
+ },
+ {
+ name: 'Does not transform if not from @clerk/nextjs',
+ source: `
+import { ClerkProvider } from 'some-other-package'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+ `,
+ output: '',
+ },
+ {
+ name: 'Handles body with multiple children',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+ `,
+ output: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+ `,
+ },
+ {
+ name: 'Handles html with head element',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+ My App
+
+ {children}
+
+
+ );
+}
+ `,
+ output: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ My App
+
+ {children}
+
+ );
+}
+ `,
+ },
+ {
+ name: 'Handles import alias (ClerkProvider as CP)',
+ source: `
+import { ClerkProvider as CP } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+ `,
+ output: `
+import { ClerkProvider as CP } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+ `,
+ },
+ {
+ name: 'Does not transform unrelated component when ClerkProvider is aliased but not used to wrap html',
+ source: `
+import { ClerkProvider as CP } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+ `,
+ output: '',
+ },
+ {
+ name: 'Does not transform when only other specifiers are imported (no ClerkProvider)',
+ source: `
+import { SignIn, SignUp } from '@clerk/nextjs'
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
+ `,
+ output: '',
+ },
+ {
+ name: 'Handles multiple ClerkProviders - only transforms those wrapping html',
+ source: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+ `,
+ output: `
+import { ClerkProvider } from '@clerk/nextjs'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+ `,
+ },
+];
diff --git a/packages/upgrade/src/codemods/__tests__/transform-clerk-provider-inside-body.test.js b/packages/upgrade/src/codemods/__tests__/transform-clerk-provider-inside-body.test.js
new file mode 100644
index 00000000000..29174b9b0a2
--- /dev/null
+++ b/packages/upgrade/src/codemods/__tests__/transform-clerk-provider-inside-body.test.js
@@ -0,0 +1,13 @@
+import { applyTransform } from 'jscodeshift/dist/testUtils';
+import { describe, expect, it } from 'vitest';
+
+import transformer from '../transform-clerk-provider-inside-body.cjs';
+import { fixtures } from './__fixtures__/transform-clerk-provider-inside-body.fixtures';
+
+describe('transform-clerk-provider-inside-body', () => {
+ it.each(fixtures)(`$name`, ({ source, output }) => {
+ const result = applyTransform(transformer, {}, { source });
+
+ expect(result).toEqual(output.trim());
+ });
+});
diff --git a/packages/upgrade/src/codemods/transform-clerk-provider-inside-body.cjs b/packages/upgrade/src/codemods/transform-clerk-provider-inside-body.cjs
new file mode 100644
index 00000000000..9209d94a89a
--- /dev/null
+++ b/packages/upgrade/src/codemods/transform-clerk-provider-inside-body.cjs
@@ -0,0 +1,130 @@
+/**
+ * Transforms ClerkProvider from wrapping to being inside .
+ *
+ * This codemod is needed for Next.js 16 cache components support.
+ * When cacheComponents is enabled, ClerkProvider must be positioned inside
+ * to avoid "Uncached data was accessed outside of " errors.
+ *
+ * Before:
+ *
+ *
+ * {children}
+ *
+ *
+ *
+ * After:
+ *
+ *
+ *
+ * {children}
+ *
+ *
+ *
+ *
+ * @param {import('jscodeshift').FileInfo} fileInfo - The file information
+ * @param {import('jscodeshift').API} api - The API object provided by jscodeshift
+ * @returns {string|undefined} - The transformed source code if modifications were made
+ */
+module.exports = function transformClerkProviderInsideBody({ source }, { jscodeshift: j }) {
+ const root = j(source);
+ let dirtyFlag = false;
+
+ // Find the import from '@clerk/nextjs' and get the local name for ClerkProvider
+ const clerkNextjsImport = root.find(j.ImportDeclaration, {
+ source: { value: '@clerk/nextjs' },
+ });
+
+ // Short-circuit if the import from '@clerk/nextjs' is not found
+ if (clerkNextjsImport.size() === 0) {
+ return undefined;
+ }
+
+ // Find the local name for ClerkProvider (handles aliases like `import { ClerkProvider as CP }`)
+ let clerkProviderLocalName = null;
+ clerkNextjsImport.forEach(importPath => {
+ const specifiers = importPath.node.specifiers || [];
+ for (const specifier of specifiers) {
+ if (j.ImportSpecifier.check(specifier) && specifier.imported && specifier.imported.name === 'ClerkProvider') {
+ // Use the local name (will be different if aliased)
+ clerkProviderLocalName = specifier.local.name;
+ break;
+ }
+ }
+ });
+
+ // Short-circuit if ClerkProvider is not imported
+ if (!clerkProviderLocalName) {
+ return undefined;
+ }
+
+ // Find all JSXElements with the name ClerkProvider (using local name to handle aliases)
+ root
+ .find(j.JSXElement, {
+ openingElement: { name: { name: clerkProviderLocalName } },
+ })
+ .forEach(path => {
+ const clerkProvider = path.node;
+
+ // Find if ClerkProvider directly wraps an element
+ const htmlElement = findDirectChildElement(j, clerkProvider, 'html');
+ if (!htmlElement) {
+ return;
+ }
+
+ // Find the element inside
+ const bodyElement = findDirectChildElement(j, htmlElement, 'body');
+ if (!bodyElement) {
+ return;
+ }
+
+ // Get ClerkProvider's attributes (props)
+ const clerkProviderAttributes = [...clerkProvider.openingElement.attributes];
+
+ // Get body's original children
+ const bodyChildren = [...bodyElement.children];
+
+ // Create new ClerkProvider that will go inside body (using local name to preserve alias)
+ const newClerkProvider = j.jsxElement(
+ j.jsxOpeningElement(j.jsxIdentifier(clerkProviderLocalName), clerkProviderAttributes, false),
+ j.jsxClosingElement(j.jsxIdentifier(clerkProviderLocalName)),
+ bodyChildren,
+ );
+
+ // Replace body's children with the new ClerkProvider
+ // Note: We don't worry about whitespace/formatting - most projects use formatters like Prettier
+ bodyElement.children = [newClerkProvider];
+
+ // Replace the outer ClerkProvider with just the html element
+ j(path).replaceWith(htmlElement);
+ dirtyFlag = true;
+ });
+
+ return dirtyFlag ? root.toSource() : undefined;
+};
+
+/**
+ * Finds a direct child JSX element with the specified name.
+ * Skips over whitespace text nodes.
+ */
+function findDirectChildElement(j, parentElement, elementName) {
+ const children = parentElement.children || [];
+
+ for (const child of children) {
+ // Skip whitespace text nodes
+ if (j.JSXText.check(child) && /^\s*$/.test(child.value)) {
+ continue;
+ }
+
+ if (
+ j.JSXElement.check(child) &&
+ j.JSXIdentifier.check(child.openingElement.name) &&
+ child.openingElement.name.name === elementName
+ ) {
+ return child;
+ }
+ }
+
+ return null;
+}
+
+module.exports.parser = 'tsx';
diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js
index 8d4134465eb..52db2c41d54 100644
--- a/packages/upgrade/src/runner.js
+++ b/packages/upgrade/src/runner.js
@@ -39,7 +39,14 @@ export async function runCodemods(config, sdk, options) {
const patterns = typeof options.glob === 'string' ? options.glob.split(/[ ,]/).filter(Boolean) : options.glob;
- for (const transform of codemods) {
+ for (const codemod of codemods) {
+ const transform = typeof codemod === 'string' ? codemod : codemod.name;
+ const packages = typeof codemod === 'string' ? ['*'] : codemod.packages || ['*'];
+
+ if (!packages.includes('*') && !packages.includes(sdk)) {
+ continue;
+ }
+
const spinner = createSpinner(`Running codemod: ${transform}`);
try {
diff --git a/packages/upgrade/src/versions/core-3/changes/clerk-provider-inside-body.md b/packages/upgrade/src/versions/core-3/changes/clerk-provider-inside-body.md
new file mode 100644
index 00000000000..f44ca12e986
--- /dev/null
+++ b/packages/upgrade/src/versions/core-3/changes/clerk-provider-inside-body.md
@@ -0,0 +1,33 @@
+---
+title: 'ClerkProvider should be inside `` for Next.js cache components'
+matcher: ']*>\\s*` rather than wrapping ``. This prevents "Uncached data was accessed outside of ``" errors.
+
+```diff
+-
+-
+- {children}
+-
+-
++
++
++
++ {children}
++
++
++
+```
+
+This change is automatically applied by the upgrade CLI:
+
+```bash
+npx @clerk/upgrade --release core-3
+```
+
+If you're using Next.js 16 with `cacheComponents: true`, you may also need to wrap `ClerkProvider` in a `` boundary.
diff --git a/packages/upgrade/src/versions/core-3/index.js b/packages/upgrade/src/versions/core-3/index.js
index 0b8a410ba74..a781774db1e 100644
--- a/packages/upgrade/src/versions/core-3/index.js
+++ b/packages/upgrade/src/versions/core-3/index.js
@@ -20,5 +20,6 @@ export default {
'transform-themes-to-ui-themes',
'transform-align-experimental-unstable-prefixes',
'transform-protect-to-show',
+ { name: 'transform-clerk-provider-inside-body', packages: ['nextjs'] },
],
};