Repository: honojs/honox
Pull request #280 (2025-03-30 17:42:01)

feat: Route grouping
Enable/allow routes to have grouping without adjusting their path. eg.:

Given structure
```
app/
  routes/
    index.ts
    blog/
      index.ts
      (content)/ # not added to the actual path
        _renderer.tsx
        _middleware.ts
        lorem-ipsum.mdx
        hello-world.mdx
```

| Path              | Route File                      |
|-------------------|---------------------------------|
| /                 | index.ts                        |
| /blog             | /blog/index.ts                  |
| /blog/lorem-ipsum | /blog/(content)/lorem-ipsum.mdx |
| /blog/hello-world | /blog/(content)/hello-world.mdx |

Routes in `(content)` also has `_renderer.tsx` and `_middleware.ts` applied to them, but not to route in parent directory (`/blog`)

This solves https://github.com/honojs/honox/issues/237, just in different solution - `(name)` is also used by other file-based routers, and here `_` prefix seems to be better for reserved keyword files.


yusukebe: @muningis 

This is super interesting. I'll review this later. Thanks!
yusukebe: @muningis 

Can you update the `README.md` for this feature?

muningis: Will adjust later today!
muningis: Adjusted 
yusukebe: @muningis 

Looks good to me! Let's go with this. Thank you for your contribution!

Patch:
diff --git a/README.md b/README.md
index f64b22d..0a80f30 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,11 @@ Below is a typical project structure for a HonoX application.
 │   │   ├── _renderer.tsx // renderer definition
 │   │   ├── about
 │   │   │   └── [name].tsx // matches `/about/:name`
+│   │   ├── blog
+│   │   │   ├── index.tsx // matches /blog
+│   │   │   └── (content)
+│   │   │       ├── _renderer.tsx // renderer definition for routes inside this directory
+│   │   │       └── [name].tsx    // matches `/blog/:name`
 │   │   └── index.tsx // matches `/`
 │   └── server.ts // server entry file
 ├── package.json
diff --git a/mocks/app-route-groups/routes/_renderer.tsx b/mocks/app-route-groups/routes/_renderer.tsx
new file mode 100644
index 0000000..f801aca
--- /dev/null
+++ b/mocks/app-route-groups/routes/_renderer.tsx
@@ -0,0 +1,21 @@
+import { jsxRenderer } from 'hono/jsx-renderer'
+import { HasIslands } from '../../../src/server'
+
+export default jsxRenderer(
+  ({ children, title }) => {
+    return (
+      <html>
+        <head>
+          <title>{title}</title>
+        </head>
+        <body>
+          {children}
+          <HasIslands>
+            <script type='module' async src='/app/client.ts'></script>
+          </HasIslands>
+        </body>
+      </html>
+    )
+  },
+  { stream: true }
+)
diff --git a/mocks/app-route-groups/routes/blog/(content-group)/_middleware.ts b/mocks/app-route-groups/routes/blog/(content-group)/_middleware.ts
new file mode 100644
index 0000000..8b02372
--- /dev/null
+++ b/mocks/app-route-groups/routes/blog/(content-group)/_middleware.ts
@@ -0,0 +1,9 @@
+import { createMiddleware } from 'hono/factory'
+import { createRoute } from '../../../../../src/factory'
+
+const addHeader = createMiddleware(async (c, next) => {
+  await next()
+  c.res.headers.append('x-message', 'from middleware for (content-group)')
+})
+
+export default createRoute(addHeader)
diff --git a/mocks/app-route-groups/routes/blog/(content-group)/_renderer.tsx b/mocks/app-route-groups/routes/blog/(content-group)/_renderer.tsx
new file mode 100644
index 0000000..e6defb7
--- /dev/null
+++ b/mocks/app-route-groups/routes/blog/(content-group)/_renderer.tsx
@@ -0,0 +1,15 @@
+import { jsxRenderer } from 'hono/jsx-renderer'
+
+export default jsxRenderer(
+  ({ children, Layout }) => {
+    return (
+      <Layout>
+        <div>
+          <h1>Blog</h1>
+          {children}
+        </div>
+      </Layout>
+    )
+  },
+  { stream: true }
+)
diff --git a/mocks/app-route-groups/routes/blog/(content-group)/hello-world.mdx b/mocks/app-route-groups/routes/blog/(content-group)/hello-world.mdx
new file mode 100644
index 0000000..5e1c309
--- /dev/null
+++ b/mocks/app-route-groups/routes/blog/(content-group)/hello-world.mdx
@@ -0,0 +1,1 @@
+Hello World
\ No newline at end of file
diff --git a/mocks/app-route-groups/routes/blog/index.tsx b/mocks/app-route-groups/routes/blog/index.tsx
new file mode 100644
index 0000000..848ad3b
--- /dev/null
+++ b/mocks/app-route-groups/routes/blog/index.tsx
@@ -0,0 +1,5 @@
+export default function BLogPosts() {
+  return (
+    <div>Here lies the blog posts</div>
+  )
+}
diff --git a/mocks/app-route-groups/routes/index.tsx b/mocks/app-route-groups/routes/index.tsx
new file mode 100644
index 0000000..955b4a5
--- /dev/null
+++ b/mocks/app-route-groups/routes/index.tsx
@@ -0,0 +1,7 @@
+import { createRoute } from '../../../src/factory'
+
+export default createRoute((c) => {
+  return c.render(<h1>Hello</h1>, {
+    title: 'This is a title',
+  })
+})
diff --git a/mocks/app-route-groups/server.ts b/mocks/app-route-groups/server.ts
new file mode 100644
index 0000000..ff4a24d
--- /dev/null
+++ b/mocks/app-route-groups/server.ts
@@ -0,0 +1,39 @@
+import { showRoutes } from 'hono/dev'
+import { logger } from 'hono/logger'
+import { createApp } from '../../src/server'
+
+const ROUTES = import.meta.glob('./routes/**/[a-z[-][a-z[_-]*.(tsx|ts)', {
+  eager: true,
+})
+
+const RENDERER = import.meta.glob('./routes/**/_renderer.tsx', {
+  eager: true,
+})
+
+const NOT_FOUND = import.meta.glob('./routes/**/_404.(ts|tsx)', {
+  eager: true,
+})
+
+const ERROR = import.meta.glob('./routes/**/_error.(ts|tsx)', {
+  eager: true,
+})
+
+const app = createApp({
+  // @ts-expect-error type is not specified
+  ROUTES,
+  // @ts-expect-error type is not specified
+  RENDERER,
+  // @ts-expect-error type is not specified
+  NOT_FOUND,
+  // @ts-expect-error type is not specified
+  ERROR,
+  root: './routes',
+  init: (app) => {
+    app.use(logger())
+  },
+})
+showRoutes(app, {
+  verbose: true,
+})
+
+export default app
diff --git a/src/server/server.ts b/src/server/server.ts
index 1196571..bb9e91c 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -150,7 +150,12 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
       })
 
       const middlewareFile = Object.keys(MIDDLEWARE_FILE).find((x) => {
-        const replacedDir = dir.replaceAll('[', '\\[').replace(']', '\\]')
+        const replacedDir = dir
+          .replaceAll('[', '\\[')
+          .replaceAll(']', '\\]')
+          .replaceAll('(', '\\(')
+          .replaceAll(')', '\\)')
+
         return new RegExp(replacedDir + '/_middleware.tsx?').test(x)
       })
 
diff --git a/src/server/utils/file.ts b/src/server/utils/file.ts
index 5f264e6..cfb1253 100644
--- a/src/server/utils/file.ts
+++ b/src/server/utils/file.ts
@@ -5,7 +5,9 @@ export const filePathToPath = (filePath: string) => {
     .replace(/^\/?index$/, '/') // `/index`
     .replace(/\/index$/, '') // `/about/index`
     .replace(/\[\.{3}.+\]/, '*')
+    .replace(/\((.+?)\)/g, '')
     .replace(/\[(.+?)\]/g, ':$1')
+    .replace(/\/\//g, '/')
   return /^\//.test(filePath) ? filePath : '/' + filePath
 }
 


Test patch:
diff --git a/src/server/utils/file.test.ts b/src/server/utils/file.test.ts
index ed9086f..65d0677 100644
--- a/src/server/utils/file.test.ts
+++ b/src/server/utils/file.test.ts
@@ -28,6 +28,11 @@ describe('filePathToPath', () => {
     expect(filePathToPath('/about/[...foo].tsx')).toBe('/about/*')
     expect(filePathToPath('/about/[name]/address.tsx')).toBe('/about/:name/address')
     expect(filePathToPath('/about/[arg1]/[arg2]')).toBe('/about/:arg1/:arg2')
+
+    expect(filePathToPath('/articles/(grouped1)/[slug]')).toBe('/articles/:slug')
+    expect(filePathToPath('/articles/(grouped1)/[slug]/(grouped1)/edit')).toBe(
+      '/articles/:slug/edit'
+    )
   })
 })
 
@@ -39,6 +44,7 @@ describe('groupByDirectory', () => {
     '/app/routes/blog/about.tsx': 'file4',
     '/app/routes/blog/posts/index.tsx': 'file5',
     '/app/routes/blog/posts/comments.tsx': 'file6',
+    '/app/routes/articles/(content)/[slug].tsx': 'file7',
   }
 
   it('Should group by directories', () => {
@@ -55,6 +61,9 @@ describe('groupByDirectory', () => {
         'index.tsx': 'file5',
         'comments.tsx': 'file6',
       },
+      '/app/routes/articles/(content)': {
+        '[slug].tsx': 'file7',
+      },
     })
   })
 })
diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts
index 2587c46..e58e103 100644
--- a/test-integration/apps.test.ts
+++ b/test-integration/apps.test.ts
@@ -923,3 +923,83 @@ describe('Function Component Response', () => {
     expect(await res.text()).toBe('<div>Async JSX Response</div>')
   })
 })
+
+describe('Route Groups', () => {
+  const ROUTES = import.meta.glob(
+    '../mocks/app-route-groups/routes/**/[a-z[-][a-z[_-]*.(tsx|ts|mdx)',
+    {
+      eager: true,
+    }
+  )
+  const RENDERER = import.meta.glob('../mocks/app-route-groups/routes/**/_renderer.tsx', {
+    eager: true,
+  })
+  const NOT_FOUND = import.meta.glob('../mocks/app-route-groups/routes/**/_404.(ts|tsx)', {
+    eager: true,
+  })
+  const MIDDLEWARE = import.meta.glob('../mocks/app-route-groups/routes/**/_middleware.(tsx|ts)', {
+    eager: true,
+  })
+
+  const app = createApp({
+    root: '../mocks/app-route-groups/routes',
+    ROUTES: ROUTES as any,
+    RENDERER: RENDERER as any,
+    NOT_FOUND: NOT_FOUND as any,
+    MIDDLEWARE: MIDDLEWARE as any,
+    init: (app) => {
+      app.use('*', poweredBy())
+    },
+  })
+
+  it('Should have correct routes', () => {
+    const routes: { path: string; method: string }[] = [
+      {
+        path: '/*',
+        method: 'ALL',
+      },
+      {
+        path: '/',
+        method: 'GET',
+      },
+      {
+        path: '/blog',
+        method: 'GET',
+      },
+      {
+        path: '/blog/hello-world',
+        method: 'GET',
+      },
+    ]
+    expect(app.routes).toHaveLength(13)
+    expect(app.routes).toEqual(
+      expect.arrayContaining(
+        routes.map(({ path, method }) => {
+          return {
+            path,
+            method,
+            handler: expect.any(Function),
+          }
+        })
+      )
+    )
+  })
+
+  it('Should render /blog without (content) route group layout', async () => {
+    const res = await app.request('/blog')
+    expect(res.status).toBe(200)
+    expect(res.headers.get('x-message')).not.toBe('from middleware for (content-group)')
+    expect(await res.text()).toBe(
+      '<!DOCTYPE html><html><head><title></title></head><body><div>Here lies the blog posts</div></body></html>'
+    )
+  })
+
+  it('Should render /blog/hello-world MDX with (content) route group layout', async () => {
+    const res = await app.request('/blog/hello-world')
+    expect(res.status).toBe(200)
+    expect(res.headers.get('x-message')).toBe('from middleware for (content-group)')
+    expect(await res.text()).toBe(
+      '<!DOCTYPE html><html><head><title></title></head><body><div><h1>Blog</h1><p>Hello World</p></div></body></html>'
+    )
+  })
+})