Skip to content

feat: add validation for root-level files when src directory exists #82514

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: add validation for root-level files when src directory exists
- Add SrcDirectoryError class for consistent error messaging
- Add validateMiddlewareInSrcDir and validateInstrumentationInSrcDir functions
- Integrate validation in both dev bundler and build processes
- Throw error when middleware/instrumentation files are outside src/ when src/ directory exists
- Add test cases to verify validation behavior

Co-Authored-By: Jiwon Choi <devjiwonchoi@gmail.com>
  • Loading branch information
devin-ai-integration[bot] and devjiwonchoi committed Aug 10, 2025
commit ef4bcb1d47bfc3f8902d26a65651a1f157f855ad
16 changes: 16 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ import {
TurborepoAccessTraceResult,
writeTurborepoAccessTraceResult,
} from './turborepo-access-trace'
import {
validateMiddlewareInSrcDir,
validateInstrumentationInSrcDir,
} from './utils'


import {
eventBuildOptimize,
Expand Down Expand Up @@ -1157,6 +1162,17 @@ export default async function build(

NextBuildContext.hasInstrumentationHook = hasInstrumentationHook

if (isSrcDir) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build process fails to detect middleware and instrumentation files placed at the project root when using src directory structure, preventing helpful validation errors from being shown to users.

View Details

Analysis

When using a src directory structure (src/pages or src/app), the build process should detect if middleware or instrumentation files are incorrectly placed at the project root instead of inside the src/ directory, and throw a clear error to guide users.

However, there's a bug in the file detection logic on line 1144. The code calculates:

const rootDir = path.join((pagesDir || appDir)!, '..')

When pagesDir is /project/src/pages, this results in rootDir = /project/src. The subsequent getFilesInDir(rootDir) call only searches within the src directory, completely missing files that are incorrectly placed at the actual project root (/project/).

This means users who place middleware.js or instrumentation.js at the project root while using src directory structure won't receive the helpful build-time validation error. Instead, they'll experience confusing runtime behavior without clear guidance on where to place their files.

The test case "should throw error when middleware is outside src directory" expects this validation to work, confirming this is a bug rather than intended behavior.


Recommendation

Change the rootDir calculation to properly search the project root directory. Replace line 1144 with:

const projectRoot = path.join((pagesDir || appDir)!, '../..')
const srcRoot = path.join((pagesDir || appDir)!, '..')
const rootDir = isSrcDir ? projectRoot : srcRoot

Or alternatively, search both locations when isSrcDir is true:

const searchDirs = isSrcDir ? 
  [path.join((pagesDir || appDir)!, '../..'), path.join((pagesDir || appDir)!, '..')] : 
  [path.join((pagesDir || appDir)!, '..')]

This ensures that when using src directory structure, the validation checks both the project root (to catch incorrectly placed files) and the src directory (to allow correctly placed files).

for (const rootPath of rootPaths) {
if (rootPath.includes(MIDDLEWARE_FILENAME)) {
validateMiddlewareInSrcDir(rootPath, isSrcDir)
}
if (rootPath.includes(INSTRUMENTATION_HOOK_FILENAME)) {
validateInstrumentationInSrcDir(rootPath, isSrcDir)
}
Copy link

@vercel vercel bot Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The src directory validation for middleware and instrumentation files never triggers during builds because the validation functions expect paths without file extensions, but receive paths with extensions.

View Details

Analysis

The build process creates rootPaths that contain file extensions (like /middleware.js, /src/middleware.js) through this processing:

const rootPaths = Array.from(await getFilesInDir(rootDir))
  .filter((file) => includes.some((include) => include.test(file)))
  .map((file) => path.join(rootDir, file).replace(dir, ''))

However, the validation functions validateMiddlewareInSrcDir() and validateInstrumentationInSrcDir() rely on isMiddlewareFile() and isInstrumentationHookFile() which only match exact paths without extensions:

export function isMiddlewareFile(file: string) {
  return (
    file === `/${MIDDLEWARE_FILENAME}` || file === `/src/${MIDDLEWARE_FILENAME}`
  )
}

This means isMiddlewareFile('/middleware.js') returns false, causing the validation condition to never trigger. The validation works correctly in development mode because absolutePathToPage() strips file extensions, but fails during builds.

This bug means the new validation feature is completely non-functional during builds, potentially allowing users to incorrectly place middleware/instrumentation files outside the src directory when they should be required to be inside it.


Recommendation

Strip the file extension from the path before calling the validation functions. Modify the validation calls in lines 1167-1172:

if (rootPath.includes(MIDDLEWARE_FILENAME)) {
  const pathWithoutExtension = rootPath.replace(/\.[^/.]+$/, '')
  validateMiddlewareInSrcDir(pathWithoutExtension, isSrcDir)
}
if (rootPath.includes(INSTRUMENTATION_HOOK_FILENAME)) {
  const pathWithoutExtension = rootPath.replace(/\.[^/.]+$/, '')
  validateInstrumentationInSrcDir(pathWithoutExtension, isSrcDir)
}

Alternatively, update the validation functions to handle paths with extensions by modifying isMiddlewareFile and isInstrumentationHookFile to strip extensions before comparison.

}
}

const previewProps: __ApiPreviewProps = await generatePreviewKeys({
isBuild: true,
distDir,
Expand Down
27 changes: 27 additions & 0 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,33 @@ export class NestedMiddlewareError extends Error {
}
}

export class SrcDirectoryError extends Error {
constructor(
fileName: string,
fileType: 'middleware' | 'instrumentation'
) {
super(
`${fileType === 'middleware' ? 'Middleware' : 'Instrumentation'} file found outside src directory.\n` +
`Found: ${fileName}\n` +
`When using the src directory, ${fileType} files must be placed inside the src directory.\n` +
`Please move your ${fileType} file to src/${fileType}.\n` +
`Read More - https://nextjs.org/docs/app/api-reference/file-conventions/src-folder`
)
}
}

export function validateMiddlewareInSrcDir(fileName: string, isSrcDir: boolean) {
if (isSrcDir && isMiddlewareFile(fileName) && !fileName.startsWith('/src/')) {
Copy link

@vercel vercel bot Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new validation functions use hardcoded /src/ path prefixes which will fail on Windows systems where paths use backslashes, causing false positive errors even when files are correctly placed in the src directory.

View Details

Analysis

The validateMiddlewareInSrcDir and validateInstrumentationInSrcDir functions check if file paths start with /src/ using fileName.startsWith('/src/'). However, in the build process, the rootPaths are created using path.join(rootDir, file).replace(dir, '') which preserves platform-specific path separators.

On Windows systems, this creates paths like \src\middleware.js (with backslashes), but the validation checks for /src/ (with forward slashes). This means:

  1. On Windows: A middleware file correctly placed in src/middleware.js will have a path like \src\middleware.js
  2. The validation: !fileName.startsWith('/src/') will return true (since \src\middleware.js doesn't start with /src/)
  3. Result: The validation throws SrcDirectoryError even though the file is in the correct location

This is inconsistent with the dev server code path, which uses normalizePathSep() to convert backslashes to forward slashes before validation, ensuring cross-platform compatibility.

The codebase has established patterns for handling cross-platform paths (see normalizePathSep(), ensureLeadingSlash() functions), but the new validation functions don't follow these patterns.


Recommendation

Normalize the path separators before validation to ensure cross-platform compatibility. Update both validation functions to use forward slashes consistently:

export function validateMiddlewareInSrcDir(fileName: string, isSrcDir: boolean) {
  const normalizedPath = fileName.replace(/\\/g, '/')
  if (isSrcDir && isMiddlewareFile(fileName) && !normalizedPath.startsWith('/src/')) {
    throw new SrcDirectoryError(fileName, 'middleware')
  }
}

export function validateInstrumentationInSrcDir(fileName: string, isSrcDir: boolean) {
  const normalizedPath = fileName.replace(/\\/g, '/')
  if (isSrcDir && isInstrumentationHookFile(fileName) && !normalizedPath.startsWith('/src/')) {
    throw new SrcDirectoryError(fileName, 'instrumentation')
  }
}

Alternatively, import and use the existing normalizePathSep function from shared/lib/page-path/normalize-path-sep.ts for consistency with the rest of the codebase.

throw new SrcDirectoryError(fileName, 'middleware')
}
}

export function validateInstrumentationInSrcDir(fileName: string, isSrcDir: boolean) {
if (isSrcDir && isInstrumentationHookFile(fileName) && !fileName.startsWith('/src/')) {
throw new SrcDirectoryError(fileName, 'instrumentation')
}
}

export function getSupportedBrowsers(
dir: string,
isDevelopment: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import {
isInstrumentationHookFile,
getPossibleMiddlewareFilenames,
getPossibleInstrumentationHookFilenames,
validateMiddlewareInSrcDir,
validateInstrumentationInSrcDir,
} from '../../../build/utils'
import { devPageFiles } from '../../../build/webpack/plugins/next-types-plugin/shared'
import type { LazyRenderServerInstance } from '../router-server'
Expand Down Expand Up @@ -426,6 +428,8 @@ async function startWatcher(
})

if (isMiddlewareFile(rootFile)) {
validateMiddlewareInSrcDir(rootFile, isSrcDir)

const staticInfo = await getStaticInfoIncludingLayouts({
pageFilePath: fileName,
config: nextConfig,
Expand Down Expand Up @@ -453,6 +457,8 @@ async function startWatcher(
continue
}
if (isInstrumentationHookFile(rootFile)) {
validateInstrumentationInSrcDir(rootFile, isSrcDir)

serverFields.actualInstrumentationHookFile = rootFile
await propagateServerField(
opts,
Expand Down
5 changes: 5 additions & 0 deletions test/integration/src-dir-validation/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
experimental: {
appDir: false,
},
}
3 changes: 3 additions & 0 deletions test/integration/src-dir-validation/src/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Home() {
return <div>Hello World</div>
}
59 changes: 59 additions & 0 deletions test/integration/src-dir-validation/test/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* eslint-env jest */

import fs from 'fs-extra'
import { join } from 'path'
import { nextBuild, nextStart, findPort, killApp } from 'next-test-utils'

const appDir = join(__dirname, '../')

describe('Src Directory Validation', () => {
beforeEach(async () => {
await fs.remove(join(appDir, 'middleware.js')).catch(() => {})
await fs.remove(join(appDir, 'instrumentation.js')).catch(() => {})
})

afterEach(async () => {
await fs.remove(join(appDir, 'middleware.js')).catch(() => {})
await fs.remove(join(appDir, 'instrumentation.js')).catch(() => {})
})

it('should throw error when middleware is outside src directory', async () => {
await fs.writeFile(
join(appDir, 'middleware.js'),
`export function middleware() { return new Response('middleware') }`
)

const result = await nextBuild(appDir, [], { stderr: true, stdout: true })
expect(result.stderr + result.stdout).toContain('Middleware file found outside src directory')
})

it('should throw error when instrumentation is outside src directory', async () => {
await fs.writeFile(
join(appDir, 'instrumentation.js'),
`export function register() { console.log('instrumentation') }`
)

const result = await nextBuild(appDir, [], { stderr: true, stdout: true })
expect(result.stderr + result.stdout).toContain('Instrumentation file found outside src directory')
})

it('should work correctly when middleware is inside src directory', async () => {
await fs.writeFile(
join(appDir, 'src/middleware.js'),
`export function middleware() { return new Response('middleware') }`
)

const result = await nextBuild(appDir, [], { stderr: true, stdout: true })
expect(result.code).toBe(0)
})

it('should work correctly when instrumentation is inside src directory', async () => {
await fs.writeFile(
join(appDir, 'src/instrumentation.js'),
`export function register() { console.log('instrumentation') }`
)

const result = await nextBuild(appDir, [], { stderr: true, stdout: true })
expect(result.code).toBe(0)
})
})
Loading