hono icon indicating copy to clipboard operation
hono copied to clipboard

SSG: Supports redirects

Open 3w36zj6 opened this issue 4 months ago • 15 comments

What is the feature you are proposing?

Redirects are essential for SSG to ensure users and search engines are properly directed when URLs change. This supports smooth migrations, maintains SEO value, and prevents broken links.

Feature Idea

When an endpoint that performs a redirect is defined as follows:

import { Hono } from 'hono'
import { toSSG } from 'hono/ssg'
import fs from 'fs/promises'

const app = new Hono()

app.get("/old", (c) => {
  return c.redirect("/new")
})

app.get("/new", (c) => {
  return c.render(
    <>
      <title>New Page</title>
      <meta name='description' content='This is the new page.' />
      new page content.
    </>
  )
})

toSSG(app, fs)

export default app

old.html is generated as follows:

<!DOCTYPE html>
<title>Redirecting to: /new</title>
<meta http-equiv="refresh" content="0;url=/new" />
<meta name="robots" content="noindex" />
<link rel="canonical" href="/new" />
<body>
  <a href="/new">Redirecting from <code>/old</code> to <code>/new</code></a>
</body>

Alternatively, an option can be added to ToSSGOptions in toSSG to define a mapping for redirects. This is inspired by Astro^1.

export interface ToSSGOptions {
    dir?: string;
    beforeRequestHook?: BeforeRequestHook | BeforeRequestHook[];
    afterResponseHook?: AfterResponseHook | AfterResponseHook[];
    afterGenerateHook?: AfterGenerateHook | AfterGenerateHook[];
    concurrency?: number;
    extensionMap?: Record<string, string>;
    plugins?: SSGPlugin[];
    redirects?: Record<string, string>; // 👈
}

3w36zj6 avatar Sep 01 '25 13:09 3w36zj6

@3w36zj6

Interesting! I'd like to have the option to decide whether to create a redirect HTML or not.

yusukebe avatar Sep 02 '25 08:09 yusukebe

I'd like to have the option to decide whether to create a redirect HTML or not.

Understood. By the way, in this approach, what do you think the behavior should be if a redirect HTML is not output?

Currently, toSSG() outputs an empty text file for endpoints that perform a redirect. However, I don't think this behavior is desirable for most users. It would be more helpful if either no file is output, or if there is some kind of info or warning message provided.

3w36zj6 avatar Sep 02 '25 09:09 3w36zj6

However, I don't think this behavior is desirable for most users. It would be more helpful if either no file is output, or if there is some kind of info or warning message provided.

Agree. But, I think the best is that it has a default useful behavior, and the user can also change the behavior.

It's just an idea. How about creating a defaultPlugin like this?

diff --git a/src/helper/ssg/ssg.ts b/src/helper/ssg/ssg.ts
index 5ec92978..d3064724 100644
--- a/src/helper/ssg/ssg.ts
+++ b/src/helper/ssg/ssg.ts
@@ -348,6 +348,15 @@ export interface ToSSGAdaptorInterface<
   (app: Hono<E, S, BasePath>, options?: ToSSGOptions): Promise<ToSSGResult>
 }

+export const defaultPlugin: SSGPlugin = {
+  afterResponseHook: (res) => {
+    if (res.status === 301 || res.status == 302) {
+      return false
+    }
+    return res
+  },
+}
+
 /**
  * @experimental
  * `toSSG` is an experimental feature.
@@ -357,7 +366,7 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => {
   let result: ToSSGResult | undefined
   const getInfoPromises: Promise<unknown>[] = []
   const savePromises: Promise<string | undefined>[] = []
-  const plugins = options?.plugins || []
+  const plugins = options?.plugins || [defaultPlugin]
   const beforeRequestHooks: BeforeRequestHook[] = []
   const afterResponseHooks: AfterResponseHook[] = []
   const afterGenerateHooks: AfterGenerateHook[] = []

The point is it's real "default". So, it is not applied when the user sets a plugin.

If the user wants to add plugins while keeping the default plugin, they can write like this:

import { toSSG, defaultPlugin } from 'hono/ssg'

// ..

toSSG(app, fs, {
  plugins: [defaultPlugin, myPlugin1, myPlugin2],
})

yusukebe avatar Sep 03 '25 10:09 yusukebe

As a result, the default plugin system was adopted, but will we implement a redirect feature similar to Astro's after the v4.10 release? For example, generating redirect HTML using both c.redirect() and key-value mapping.

There is also the option of handling this in ssg-plugins-essential, but since this is a frequently used feature, we could consider including the logic in the core.

3w36zj6 avatar Oct 15 '25 17:10 3w36zj6

@3w36zj6

we could consider including the logic in the core.

If we decide to include the logic in the core, where is it implemented? defaultPlugin?

yusukebe avatar Oct 17 '25 17:10 yusukebe

One of the ways to implement it is like this:

const getDefaultPlugin = ({ redirect = false }: { redirect: boolean }): SSGPlugin => {
  return {
    afterResponseHook: (res) => {
      if ((redirect && res.status === 301) || (redirect && res.status === 302)) {
        // do something
      }
      if (res.status !== 200) {
        return false
      }
      return res
    },
  }
}

If the do something will be fat, we may have to make it in ssg-plugins-essential.

yusukebe avatar Oct 17 '25 17:10 yusukebe

I think currying the default plugin is a good approach.

As for the // do something part, it's simply generating a file by injecting the path into a template like this:

https://github.com/withastro/astro/blob/946fe68c973c966a4f589ae43858bf486cc70eb5/packages/astro/src/core/routing/3xx.ts

Is this size acceptable?

If we add a redirects key-value mapping for additional redirect settings to the options of toSSG, since the redirect page template will be shared, I think it would be preferable to have it in the core.

3w36zj6 avatar Oct 18 '25 00:10 3w36zj6

Is this size acceptable?

Yes. But I reconsidered. Redirecting with HTML is something the user should decide. So it's better not to include it in the default plugin.

If we add a redirects key-value mapping for additional redirect settings to the options of toSSG, since the redirect page template will be shared, I think it would be preferable to have it in the core.

What does the key-value mapping look like? Could you show a concrete example?

yusukebe avatar Oct 21 '25 01:10 yusukebe

Here is a concrete example of a key-value mapping for redirects. For instance, we can specify it as follows:

toSSG(app, fs, {
  redirects: {
    "/old": "/new",
    "/foo": "/bar",
    "/sonik": "/honox",
    // We can add as many redirects as needed here.
  }
})

By specifying key-value pairs in the redirects option like this, we can automatically generate HTML redirect pages (using meta refresh, etc.) for the specified paths (e.g., /old). This mechanism simply can be expressed with app.get("/old", c => c.redirect("/new")), but it makes configuring redirects much easier. For static sites with a long operational period, it's quite common to want to keep redirecting old endpoints indefinitely, so many SSG tools seem to provide such functionality by default.

However, my thoughts have changed a bit since I first opened this issue. I believe the value of hono/ssg lies in providing only the minimum pluggable functionality needed for SSG in a Hono app, rather than aiming to provide a fully-featured SSG tool just by mimicking richer SSGs like Astro, 11ty, or Gatsby.

Therefore, I think it's a good policy to provide all redirect-related features via ssg-plugins-essential/redirect. What do you think?

3w36zj6 avatar Oct 24 '25 06:10 3w36zj6

@3w36zj6

Thank you for the explanation.

This mechanism simply can be expressed with app.get("/old", c => c.redirect("/new")), but it makes configuring redirects much easier.

I think using app.get("/old", c => c.redirect("/new")) is okay and we don't need redirect option.

redirect needs "routing" for the old path to the new path. Imagine if the user wants to write a complex rule like

app.get('/old/:path', (c) => c.redirect(`/new/${c.req.param('path')}`))

How does the user write the rule? Using regexp? It's one idea, but using Hono's basic routing is better; it's already implemented.

yusukebe avatar Oct 28 '25 08:10 yusukebe

I understand your point and agree with you. So I'm fine with not adopting an Astro-like redirects option.

3w36zj6 avatar Oct 29 '25 22:10 3w36zj6

@3w36zj6

Thanks. So, what will you do for the redirects feature? I think implementing it in a plugin is a good idea.

yusukebe avatar Oct 30 '25 00:10 yusukebe

I'll work on a PoC soon. Please wait a little.

3w36zj6 avatar Nov 06 '25 07:11 3w36zj6

@yusukebe

Thank you for your patience. I have created a PoC for the redirect plugin.

// src/helper/ssg/plugins/redirect.ts

import type { SSGPlugin } from '../ssg'

const generateRedirectHtml = (from: string, to: string) => {
  return `<!DOCTYPE html>
<title>Redirecting to: ${to}</title>
<meta http-equiv="refresh" content="0;url=${to}" />
<meta name="robots" content="noindex" />
<link rel="canonical" href="${to}" />
<body>
  <a href="${to}">Redirecting from <code>${from}</code> to <code>${to}</code></a>
</body>`
}

export const redirectPlugin = (): SSGPlugin => {
  return {
    afterResponseHook: (res) => {
      if (res.status === 301 || res.status === 302) {
        const location = res.headers.get('Location')
        if (!location) return false
        const html = generateRedirectHtml('', location)
        return new Response(html, {
          status: 200,
          headers: { 'Content-Type': 'text/html; charset=utf-8' },
        })
      }
      return res
    },
  }
}
// src/helper/ssg/ssg.test.tsx

describe('toSSG function', () => {
  // existing setup code

  it('should generate redirect HTML for 301/302 route responses using plugin', async () => {
    const writtenFiles: Record<string, string> = {}
    const fsMock: FileSystemModule = {
      writeFile: (path, data) => {
        writtenFiles[path] = typeof data === 'string' ? data : data.toString()
        return Promise.resolve()
      },
      mkdir: vi.fn(() => Promise.resolve()),
    }
    const app = new Hono()
    app.get('/old', (c) => c.redirect('/new'))
    app.get('/new', (c) => c.html('New Page'))

    await toSSG(app, fsMock, { dir: './static', plugins: [redirectPlugin()] })

    expect(writtenFiles['static/old.html']).toBeDefined()
    const content = writtenFiles['static/old.html']
    // Should contain meta refresh
    expect(content).toContain('meta http-equiv="refresh" content="0;url=/new"')
    // Should contain canonical
    expect(content).toContain('rel="canonical" href="/new"')
    // Should contain link anchor
    expect(content).toContain('<a href="/new">Redirecting from')
  })

  it('should skip generating a redirect HTML when 301/302 has no Location header', async () => {
    const writtenFiles: Record<string, string> = {}
    const fsMock: FileSystemModule = {
      writeFile: (path, data) => {
        writtenFiles[path] = typeof data === 'string' ? data : data.toString()
        return Promise.resolve()
      },
      mkdir: vi.fn(() => Promise.resolve()),
    }
    const app = new Hono()
    // Return a 301 without Location header
    app.get('/bad', (c) => new Response(null, { status: 301 }))

    await toSSG(app, fsMock, { dir: './static', plugins: [redirectPlugin()] })

    expect(writtenFiles['static/bad.html']).toBeUndefined()
  })
})

Here is the complete patch:

diff --git a/src/helper/ssg/plugins/redirect.ts b/src/helper/ssg/plugins/redirect.ts
new file mode 100644
index 00000000..136d6495
--- /dev/null
+++ b/src/helper/ssg/plugins/redirect.ts
@@ -0,0 +1,29 @@
+import type { SSGPlugin } from '../ssg'
+
+const generateRedirectHtml = (from: string, to: string) => {
+  return `<!DOCTYPE html>
+<title>Redirecting to: ${to}</title>
+<meta http-equiv="refresh" content="0;url=${to}" />
+<meta name="robots" content="noindex" />
+<link rel="canonical" href="${to}" />
+<body>
+  <a href="${to}">Redirecting from <code>${from}</code> to <code>${to}</code></a>
+</body>`
+}
+
+export const redirectPlugin = (): SSGPlugin => {
+  return {
+    afterResponseHook: (res) => {
+      if (res.status === 301 || res.status === 302) {
+        const location = res.headers.get('Location')
+        if (!location) return false
+        const html = generateRedirectHtml('', location)
+        return new Response(html, {
+          status: 200,
+          headers: { 'Content-Type': 'text/html; charset=utf-8' },
+        })
+      }
+      return res
+    },
+  }
+}
diff --git a/src/helper/ssg/ssg.test.tsx b/src/helper/ssg/ssg.test.tsx
index f779973e..e1e9d2b7 100644
--- a/src/helper/ssg/ssg.test.tsx
+++ b/src/helper/ssg/ssg.test.tsx
@@ -9,6 +9,7 @@ import {
   onlySSG,
   ssgParams,
 } from './middleware'
+import { redirectPlugin } from './plugins/redirect'
 import {
   defaultExtensionMap,
   fetchRoutesContent,
@@ -254,6 +255,49 @@ describe('toSSG function', () => {
     )
   })
 
+  it('should generate redirect HTML for 301/302 route responses using plugin', async () => {
+    const writtenFiles: Record<string, string> = {}
+    const fsMock: FileSystemModule = {
+      writeFile: (path, data) => {
+        writtenFiles[path] = typeof data === 'string' ? data : data.toString()
+        return Promise.resolve()
+      },
+      mkdir: vi.fn(() => Promise.resolve()),
+    }
+    const app = new Hono()
+    app.get('/old', (c) => c.redirect('/new'))
+    app.get('/new', (c) => c.html('New Page'))
+
+    await toSSG(app, fsMock, { dir: './static', plugins: [redirectPlugin()] })
+
+    expect(writtenFiles['static/old.html']).toBeDefined()
+    const content = writtenFiles['static/old.html']
+    // Should contain meta refresh
+    expect(content).toContain('meta http-equiv="refresh" content="0;url=/new"')
+    // Should contain canonical
+    expect(content).toContain('rel="canonical" href="/new"')
+    // Should contain link anchor
+    expect(content).toContain('<a href="/new">Redirecting from')
+  })
+
+  it('should skip generating a redirect HTML when 301/302 has no Location header', async () => {
+    const writtenFiles: Record<string, string> = {}
+    const fsMock: FileSystemModule = {
+      writeFile: (path, data) => {
+        writtenFiles[path] = typeof data === 'string' ? data : data.toString()
+        return Promise.resolve()
+      },
+      mkdir: vi.fn(() => Promise.resolve()),
+    }
+    const app = new Hono()
+    // Return a 301 without Location header
+    app.get('/bad', (c) => new Response(null, { status: 301 }))
+
+    await toSSG(app, fsMock, { dir: './static', plugins: [redirectPlugin()] })
+
+    expect(writtenFiles['static/bad.html']).toBeUndefined()
+  })
+
   it('should handle asynchronous beforeRequestHook correctly', async () => {
     const beforeRequestHook: BeforeRequestHook = async (req) => {
       await new Promise((resolve) => setTimeout(resolve, 10))

Could you please review this approach? If there are no issues, I will proceed with making an official PR.

3w36zj6 avatar Nov 29 '25 07:11 3w36zj6

Hi @3w36zj6

The approach looks good! Can you proceed?

yusukebe avatar Dec 11 '25 08:12 yusukebe

Thanks for reviewing the PoC.

I have one last question about the PR destination. My understanding is that the redirectPlugin will not be included in the defaultPlugin but will be provided as a built-in feature of hono/ssg (not in @hono/ssg-plugins-essential). Is that correct?

3w36zj6 avatar Dec 15 '25 09:12 3w36zj6

@3w36zj6

Honestly, I think both are okay in hono/ssg or @hono/ssg-plugins-essential. It may be better not to include plugins in hono/ssg, but to put them in redirectPlugin. What do you think is good?

yusukebe avatar Dec 16 '25 09:12 yusukebe

It's a tough decision, but I think it might be a good idea to manage it under hono/ssg for now, given that the plugin feels closer to the core functionality. Since it's still an experimental feature, we can consider moving it to Third-party Middleware in the future, depending on how things go.

Sorry for the back-and-forth, and thank you for your patience. What do you think about this approach?

3w36zj6 avatar Dec 19 '25 06:12 3w36zj6