Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/tangy-sides-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Updates middleware location check to account for proxy.ts in next 16+ applications.
38 changes: 29 additions & 9 deletions integration/tests/middleware-placement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,16 @@ test.describe('next start - missing middleware @quickstart', () => {
});

test('Display error for missing middleware', async ({ page, context }) => {
const { version } = await detectNext(app);
const major = parseSemverMajor(version) ?? 0;
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();

expect(app.serveOutput).toContain('Your Middleware exists at ./src/middleware.(ts|js)');
const expectedMessage =
major >= 16
? 'Your Middleware exists at ./src/middleware.(ts|js) or proxy.(ts|js)'
: 'Your Middleware exists at ./src/middleware.(ts|js)';
expect(app.serveOutput).toContain(expectedMessage);
});
});

Expand Down Expand Up @@ -105,10 +111,16 @@ test.describe('next start - invalid middleware at root on src/ @quickstart', ()
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();

expect(app.serveOutput).not.toContain('Your Middleware exists at ./src/middleware.(ts|js)');
expect(app.serveOutput).toContain(
'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./middleware.ts',
);
const expectedMessage =
major >= 16
? 'Your Middleware exists at ./src/middleware.(ts|js) or proxy.(ts|js)'
: 'Your Middleware exists at ./src/middleware.(ts|js)';
expect(app.serveOutput).not.toContain(expectedMessage);
const expectedError =
major >= 16
? 'Clerk: clerkMiddleware() was not run, your middleware or proxy file might be misplaced. Move your middleware or proxy file to ./src/middleware.ts. Currently located at ./middleware.ts'
: 'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./middleware.ts';
expect(app.serveOutput).toContain(expectedError);
});

test('Does not display misplaced middleware error on Next 16+', async ({ page, context }) => {
Expand Down Expand Up @@ -142,11 +154,19 @@ test.describe('next start - invalid middleware inside app on src/ @quickstart',
page,
context,
}) => {
const { version } = await detectNext(app);
const major = parseSemverMajor(version) ?? 0;
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();
expect(app.serveOutput).not.toContain('Your Middleware exists at ./src/middleware.(ts|js)');
expect(app.serveOutput).toContain(
'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./src/app/middleware.ts',
);
const expectedMessage =
major >= 16
? 'Your Middleware exists at ./src/middleware.(ts|js) or proxy.(ts|js)'
: 'Your Middleware exists at ./src/middleware.(ts|js)';
expect(app.serveOutput).not.toContain(expectedMessage);
const expectedError =
major >= 16
? 'Clerk: clerkMiddleware() was not run, your middleware or proxy file might be misplaced. Move your middleware or proxy file to ./src/middleware.ts. Currently located at ./src/app/middleware.ts'
: 'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./src/app/middleware.ts';
expect(app.serveOutput).toContain(expectedError);
});
});
5 changes: 3 additions & 2 deletions packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { unauthorized } from '../../server/nextErrors';
import type { AuthProtect } from '../../server/protect';
import { createProtect } from '../../server/protect';
import { decryptClerkRequestData } from '../../server/utils';
import { isNextWithUnstableServerActions } from '../../utils/sdk-versions';
import { isNext16OrHigher, isNextWithUnstableServerActions } from '../../utils/sdk-versions';
import { buildRequestLike } from './utils';

/**
Expand Down Expand Up @@ -81,7 +81,8 @@ export const auth: AuthFn = (async (options?: AuthOptions) => {

try {
const isSrcAppDir = await import('../../server/fs/middleware-location.js').then(m => m.hasSrcAppDir());
return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}middleware.(ts|js)`];
const fileName = isNext16OrHigher ? 'middleware.(ts|js) or proxy.(ts|js)' : 'middleware.(ts|js)';
return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}${fileName}`];
} catch {
return [];
}
Expand Down
16 changes: 12 additions & 4 deletions packages/nextjs/src/server/fs/middleware-location.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isNext16OrHigher } from '../../utils/sdk-versions';
import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './utils';

function hasSrcAppDir() {
Expand All @@ -12,12 +13,17 @@ function hasSrcAppDir() {

function suggestMiddlewareLocation() {
const fileExtensions = ['ts', 'js'] as const;
// Next.js 16+ supports both middleware.ts (Edge runtime) and proxy.ts (Node.js runtime)
const fileNames = isNext16OrHigher ? ['middleware', 'proxy'] : ['middleware'];
const fileNameDisplay = isNext16OrHigher ? 'middleware or proxy' : 'middleware';

const suggestionMessage = (
fileName: string,
extension: (typeof fileExtensions)[number],
to: 'src/' | '',
from: 'src/app/' | 'app/' | '',
) =>
`Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./${to}middleware.${extension}. Currently located at ./${from}middleware.${extension}`;
`Clerk: clerkMiddleware() was not run, your ${fileNameDisplay} file might be misplaced. Move your ${fileNameDisplay} file to ./${to}${fileName}.${extension}. Currently located at ./${from}${fileName}.${extension}`;

const { existsSync } = nodeFsOrThrow();
const path = nodePathOrThrow();
Expand All @@ -31,9 +37,11 @@ function suggestMiddlewareLocation() {
to: 'src/' | '',
from: 'src/app/' | 'app/' | '',
): string | undefined => {
for (const fileExtension of fileExtensions) {
if (existsSync(path.join(basePath, `middleware.${fileExtension}`))) {
return suggestionMessage(fileExtension, to, from);
for (const fileName of fileNames) {
for (const fileExtension of fileExtensions) {
if (existsSync(path.join(basePath, `${fileName}.${fileExtension}`))) {
return suggestionMessage(fileName, fileExtension, to, from);
}
}
}
return undefined;
Expand Down
165 changes: 165 additions & 0 deletions packages/nextjs/src/utils/__tests__/sdk-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

describe('sdk-versions', () => {
beforeEach(() => {
// Clear module cache to allow re-importing with different mocks
vi.resetModules();
});

describe('meetsNextMinimumVersion', () => {
it('should return true when version meets minimum major version', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '16.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});

it('should return true when version exceeds minimum major version', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '17.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});

it('should return false when version is below minimum major version', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '15.9.9' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should return false when version is exactly one below minimum', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '15.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should handle patch versions correctly', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '16.5.3' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});

it('should handle beta/prerelease versions correctly', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '16.0.0-beta.1' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});

it('should return false when version is missing', async () => {
vi.doMock('next/package.json', () => ({
default: {},
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should return false when version is null', async () => {
vi.doMock('next/package.json', () => ({
default: { version: null },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should return false when version is undefined', async () => {
vi.doMock('next/package.json', () => ({
default: { version: undefined },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should return false when version is an empty string', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should return false when version cannot be parsed as a number', async () => {
vi.doMock('next/package.json', () => ({
default: { version: 'invalid-version' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should handle single-digit major versions', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '9.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});

it('should handle double-digit major versions', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '20.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});

it('should handle version strings with leading zeros', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '016.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});
});

describe('isNext16OrHigher', () => {
it('should be a boolean value', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '16.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(typeof isNext16OrHigher).toBe('boolean');
});

it('should correctly identify Next.js 16', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '16.0.0' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(true);
});

it('should correctly identify Next.js 15 as not 16+', async () => {
vi.doMock('next/package.json', () => ({
default: { version: '15.2.3' },
}));

const { isNext16OrHigher } = await import('../sdk-versions.js');
expect(isNext16OrHigher).toBe(false);
});
});
});
20 changes: 17 additions & 3 deletions packages/nextjs/src/utils/sdk-versions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import nextPkg from 'next/package.json';

const isNext13 = nextPkg.version.startsWith('13.');
function meetsNextMinimumVersion(minimumMajorVersion: number) {
if (!nextPkg?.version) {
return false;
}

const majorVersion = parseInt(nextPkg.version.split('.')[0], 10);
return !isNaN(majorVersion) && majorVersion >= minimumMajorVersion;
}

const isNext13 = nextPkg?.version?.startsWith('13.') ?? false;

/**
* Those versions are affected by a bundling issue that will break the application if `node:fs` is used inside a server function.
* The affected versions are >=next@13.5.4 and <=next@14.0.4
*/
const isNextWithUnstableServerActions = isNext13 || nextPkg.version.startsWith('14.0');
const isNextWithUnstableServerActions = isNext13 || (nextPkg?.version?.startsWith('14.0') ?? false);

/**
* Next.js 16+ renamed middleware.ts to proxy.ts
*/
const isNext16OrHigher = meetsNextMinimumVersion(16);

export { isNext13, isNextWithUnstableServerActions };
export { isNext13, isNextWithUnstableServerActions, isNext16OrHigher };
Loading