Repository: honojs/hono (2025-06-27T02:38:23Z)

Issue:
Invoke callback for `ssgParams()` only once
### What is the feature you are proposing?

Same issue as @nakasyou mentions below, but now `ssgParams()` is pure middleware and also not handling special skipping internally, so it is called many times during SSG.
https://github.com/honojs/hono/pull/2169#issuecomment-1934040823

I think the following tests need to be success (now it will fail)

```ts
describe('ssgParams', () => {
  it('callback', async() => {
    const app = new Hono()
    const cb = vi.fn(() => [{ post: '1' }, { post: '2' }])
    app.get(
      '/post/:post',
      ssgParams(cb),
      (c) => c.html(<h1>{c.req.param('post')}</h1>)
    )
    const fsMock: FileSystemModule = {
      writeFile: vi.fn(() => Promise.resolve()),
      mkdir: vi.fn(() => Promise.resolve()),
    }
    await toSSG(app, fsMock)

    expect(cb).toHaveBeenCalledTimes(1)
  })
})
```

### Does `ssgParams()` need to be a REAL middleware?

I understand that it is better to have it in `app.get(...)` for the appearance of the definition. However, if it functions as REAL middleware, it will be called on every request at the time of creation, which is a useless overhead.

I think it would be better to introduce the concept of "PSEUDO middleware" in hono and solve the problem as shown in the following PR. The test passed without any major changes.

#2174
SSG route called 
### What version of Hono are you using?

4.4.11

### What runtime/platform is your app running on?

NodeJS

### What steps can reproduce the bug?

```ts
// build.ts
import fs from 'fs/promises'
import { Hono } from 'hono'
import { ssgParams, toSSG } from 'hono/ssg'

const app = new Hono()
app.get(
    '/shops/:id',
    ssgParams(() => [{ id: 'shop1' }]),
    async (c) => {
        const id = c.req.param('id')
        console.warn(id)
        return c.html(id)
    },
)

toSSG(app, fs)
```
```
$ npx vite-node src/build.ts
```

### What is the expected behavior?

It is expected to log 
```
shop1
```

### What do you see instead?


```ts
:id
shop1
```

### Additional information

Route is called twice instead of once. First time the route is called the parameter *id* is not replaced with the actual value but stays ":id".

I think it might be related to https://github.com/honojs/hono/issues/2175


Discussion:
How about that?
@yusukebe @sor4chi @watany-dev @nakasyou
@usualoma 

Hi!

Interesting approach. I'll take a look at the details later. But can you think of any other PSEUDO middleware?
@yusukebe 
Hmmm no, there is no particular other use for PSEUDO middleware.
But I still don't think "send a request to the entire routing to get the value from `ssgParams()`" is a good approach, I think we should just invoke `ssgParams()`.
@usualoma 

Thanks. I also can't think of other use cases.

> But I still don't think "send a request to the entire routing to get the value from ssgParams()" is a good approach, I think we should just invoke ssgParams().

Totally agree. It's not a good approach. 

Actually, I don't want to change hono-base. If there are other use cases for PSEUDO middleware, I'd be happy to do that. But, hmm.
@yusukebe Assuming we don't change hono-base, I think we can do this as well by changing only src/helper/ssg/index.ts.
https://github.com/honojs/hono/pull/2178
@usualoma 

Thanks, I'll take a look at it later (I'll be a little bit busy to release v4 and have YAPC!)
Hi,

We ended up writing a similar ssg functionality, which won't call ssgParams multiple times and it also does not call a dynamic route with `:dynamicParam`. It is not as generic and we used headers instead of env, but I think it would be possible to do this in a similar way in hono. In our case ssgParams is still a real middleware

Our headers:
`x-ssg` = ssg mode or not
`x-ssg-find` = if true, means that we're trying to find the routes

our `ssgParams` function:
```ts
export function ssgParams(
  fn?: (c: Context) => Promise<unknown[]>,
): MiddlewareHandler {
  return async (c, next) => {
    if (c.req.header('x-ssg-find') === 'true') {
      return fn ? c.json(await fn(c)) : c.json([]);
    }
    return next();
  };
}
```


Stage 1: Find routes
1. Find all unique routes.
2. If route is not dynamic --> add to a list
3. If route is dynamic --> Call the route using ''x-ssg=true" and "x-ssg-find=true" and add all routes given by `ssgParams`

Stage 2: Create ssg export
Call the found routes using ''x-ssg=true"

Available here: https://github.com/CyberismoCom/cyberismo/blob/main/tools/backend/src/export.ts

@samutoljamo-pareto 

Thank you for the comment. That gave me a hint and created the PR #4249

Hi @usualoma, is this issue solved with #4249? This is a simple way, but the test passed.

Damn, that's a much better way than mine. I think #3088 could be also fixed this way if it did not call `next` for dynamic routes in ssg context

I'll look abt this
hi @RobertSasak 
Does this mean that when I access "/shop1" it is logged?
Or are you talking about "/:id is also generated"?
https://github.com/honojs/hono/blob/main/src/helper/ssg/ssg.ts#L228
https://github.com/honojs/hono/blob/main/src/helper/ssg/middleware.ts#L43
I run vite to command to produce static pages. ssgParams define only one page to be generated. It has id="shop1". However actual route get called twice. First time with a id=":id" and second time with id="shop1". 

Only one static page is actually produced on the disk(shop1.html). However during my test I also saw ":id.html" created on the disk. I am not sure how to reproduce it. 

hmm...
Looking at the code, it does not seem to be generated
@RobertSasak 
Hi @RobertSasak 

This is a known issue. We may fix it.
@watany-dev @sor4chi @nakasyou @usualoma Do you have any idea?
Thank you for following up this issue. I don't think it is necessary to spend energy on it as there is a simple workaround for it. I expected it will be fixed onced the question whether SSG should be middleware or not. 
```ts
const id = c.req.param('id')
if (id==':id') {
    return c.html('Ignored', 500) // https://github.com/honojs/hono/issues/3088
}
```
When I implemented this, I couldn't find how to resolve this.

However, application users can access `/shops/:id` when it isn't on SSG, so I think there is no problem.
I have an idea.

What do you think about allowing `ssgParams` to response params as JSON when request is for SSG?
In this solution, it will be returned early, so after handler will not be called. But we must set `disableSSG` and `onlySSG` before `ssgParams`, it may be breaking change.



Patch:
diff --git a/src/helper/ssg/middleware.ts b/src/helper/ssg/middleware.ts
index 0e4a4909c..e9684cb8d 100644
--- a/src/helper/ssg/middleware.ts
+++ b/src/helper/ssg/middleware.ts
@@ -1,5 +1,6 @@
 import type { Context } from '../../context'
 import type { Env, MiddlewareHandler } from '../../types'
+import { isDynamicRoute } from './utils'
 
 export const SSG_CONTEXT = 'HONO_SSG_CONTEXT'
 export const X_HONO_DISABLE_SSG_HEADER_KEY = 'x-hono-disable-ssg'
@@ -40,7 +41,10 @@ export type AddedSSGDataRequest = Request & {
  * Define SSG Route
  */
 export const ssgParams: SSGParamsMiddleware = (params) => async (c, next) => {
-  ;(c.req.raw as AddedSSGDataRequest).ssgParams = Array.isArray(params) ? params : await params(c)
+  if (isDynamicRoute(c.req.path)) {
+    ;(c.req.raw as AddedSSGDataRequest).ssgParams = Array.isArray(params) ? params : await params(c)
+    return c.notFound() // Prevent subsequent handler execution after ssgParams
+  }
   await next()
 }
 
diff --git a/src/helper/ssg/ssg.ts b/src/helper/ssg/ssg.ts
index 6f6d59585..26722bcde 100644
--- a/src/helper/ssg/ssg.ts
+++ b/src/helper/ssg/ssg.ts
@@ -5,7 +5,7 @@ import { createPool } from '../../utils/concurrent'
 import { getExtension } from '../../utils/mime'
 import type { AddedSSGDataRequest, SSGParams } from './middleware'
 import { SSG_CONTEXT, X_HONO_DISABLE_SSG_HEADER_KEY } from './middleware'
-import { dirname, filterStaticGenerateRoutes, joinPaths } from './utils'
+import { dirname, filterStaticGenerateRoutes, isDynamicRoute, joinPaths } from './utils'
 
 const DEFAULT_CONCURRENCY = 2 // default concurrency for ssg
 
@@ -280,10 +280,6 @@ export const fetchRoutesContent = function* <
   }
 }
 
-const isDynamicRoute = (path: string): boolean => {
-  return path.split('/').some((segment) => segment.startsWith(':') || segment.includes('*'))
-}
-
 /**
  * @experimental
  * `saveContentToFile` is an experimental feature.
diff --git a/src/helper/ssg/utils.ts b/src/helper/ssg/utils.ts
index afc6c4aa3..55a2fb5a7 100644
--- a/src/helper/ssg/utils.ts
+++ b/src/helper/ssg/utils.ts
@@ -69,3 +69,7 @@ export const filterStaticGenerateRoutes = <E extends Env>(
     return acc
   }, [] as FilterStaticGenerateRouteData[])
 }
+
+export const isDynamicRoute = (path: string): boolean => {
+  return path.split('/').some((segment) => segment.startsWith(':') || segment.includes('*'))
+}


Test patch:
diff --git a/src/helper/ssg/ssg.test.tsx b/src/helper/ssg/ssg.test.tsx
index 9af23e3df..67af8b863 100644
--- a/src/helper/ssg/ssg.test.tsx
+++ b/src/helper/ssg/ssg.test.tsx
@@ -955,3 +955,42 @@ describe('SSG Plugin System', () => {
     )
   })
 })
+
+describe('ssgParams', () => {
+  it('should invoke callback only once', async () => {
+    const app = new Hono()
+    const cb = vi.fn(() => [{ post: '1' }, { post: '2' }])
+    app.get('/post/:post', ssgParams(cb), (c) => c.html(<h1>{c.req.param('post')}</h1>))
+    const fsMock: FileSystemModule = {
+      writeFile: vi.fn(() => Promise.resolve()),
+      mkdir: vi.fn(() => Promise.resolve()),
+    }
+    await toSSG(app, fsMock)
+
+    expect(cb).toHaveBeenCalledTimes(1)
+  })
+
+  it('should not invoke handler after ssgParams for dynamic route request', async () => {
+    const app = new Hono()
+    const log = vi.fn()
+    app.get(
+      '/shops/:id',
+      ssgParams(() => [{ id: 'shop1' }]),
+      async (c) => {
+        const id = c.req.param('id')
+        log(id)
+        return c.html(id)
+      }
+    )
+    const fsMock: FileSystemModule = {
+      writeFile: vi.fn(() => Promise.resolve()),
+      mkdir: vi.fn(() => Promise.resolve()),
+    }
+    await toSSG(app, fsMock)
+
+    expect(log).toHaveBeenCalledTimes(1)
+    expect(log).toHaveBeenCalledWith('shop1')
+    expect(fsMock.writeFile).toHaveBeenCalledTimes(1)
+    expect(fsMock.writeFile).toHaveBeenCalledWith('static/shops/shop1.html', 'shop1')
+  })
+})