Learn how to serve HTML content and use JSX/TSX with Hono
On this page
Introduction
Hono - means flame🔥 in Japanese - is a small, simple, and ultrafast web framework built on Web Standards.
Project
Let’s create a new Hono project using Bun.
A starter for Netlify is available.
Start your project with “create-hono” command selecting netlify template:
bun create hono --template netlify hono-htmlMove into hono-html directory.
Add dependencies:
bun add honoEdge function
The file netlify/edge-functions/index.ts contains the code for your edge function.
This is where you will write your Hono application.
Update the import statements (delete @jsr):
import { Hono } from 'hono'import { handle } from 'hono/netlify'
const app = new Hono()
app.get('/', (c) => { return c.text('Hello Hono!')})
export default handle(app)import { Hono } from 'hono'imports the coreHonoclass from the framework. This is the entry point for building any Hono application: it provides the methods to define routes (app.get(),app.post(), etc.) and middleware.import { handle } from 'hono/netlify'imports the Netlify adapter. Thehandlefunction wraps your Hono app so it can run as a Netlify Edge Function, translating between Netlify’s request/response format and Hono’s.new Hono()creates a new application instance where you register all your routes.app.get('/', (c) => ...)registers a handler forGETrequests to the root path/. The callback receives a context objectc, which provides methods to build responses:c.text()returns a plain text response,c.html()returns HTML,c.json()returns JSON, etc.export default handle(app)exports the adapted app as the default export, which is what Netlify expects from an edge function.
Server
Run the development server with Netlify CLI.
netlify devThen, access http://localhost:8888 in your Web browser.
You should see “Hello Hono!” displayed on the page.
HTML
If you update the previous code to return <h1>Hello Hono!</h1>, you will see the tags as text on the page, not as HTML.
app.get('/', (c) => { return c.text('<h1>Hello Hono!</h1>')})Instead of returning a string, you can return an HTML document using the c.html() method.
app.get('/', (c) => { return c.html('<h1>Hello Hono!</h1>')})This method is a helper function that allows you to write HTML using template literals, sets the Content-Type header to text/html, and sends the provided HTML string as the response body.
Parameters
Update the code to include a dynamic route that takes a username parameter and returns a personalized greeting.
app.get('/student/:username', (c) => { const {username} = c.req.param() return c.html(`<h1>Hello, ${username}!</h1>`)})'/student/:username'defines a route with a dynamic parameter. The:usernamesegment acts as a placeholder — if a user visits/student/alice, Hono captures"alice"as the value ofusername.c.req.param()returns an object with all the dynamic parameters from the URL. Here, we use destructuring ({username}) to extract theusernamevalue directly.c.html(`<h1>Hello, ${username}!</h1>`)sends an HTML response with theusernamevalue interpolated into the template literal.
Access http://localhost:8888/student/alice in your Web browser and you should see “Hello, alice!” displayed on the page.
TSX
You can write HTML with TSX syntax with hono/jsx rendering content on the server side.
Think of it like moving from writing everything on one big sheet of paper to using pre-built Lego blocks.
To use TSX, rename your file to src/index.tsx.
You should additionally modify the wrangler.jsonc file to reflect that change: Update "main" entry of "src/index.tsx"
// A separate View componentconst View = () => { return ( <html> <body> <h1>Hello Hono!</h1> <p>This is rendered using TSX components.</p> </body> </html> )}
app.get('/page', (c) => { // We can just return the component inside c.html() return c.html(<View />)})- Components are like custom tags.
Viewis a function that returns the structure of our page. - JSX (the HTML-like code) allows us to write our interface naturally within JavaScript.
c.html(<View />)automatically renders the component into a string and sets the correctContent-Typeheader for the browser.
Usage
import { Hono } from 'hono'import type { FC } from 'hono/jsx'
const app = new Hono()
const Layout: FC = (props) => { return ( <html> <body>{props.children}</body> </html> )}
const Top: FC<{ messages: string[] }> = (props: { messages: string[]}) => { return ( <Layout> <h1>Hello Hono!</h1> <ul> {props.messages.map((message) => { return <li>{message}!!</li> })} </ul> </Layout> )}
app.get('/message', (c) => { const messages = ['Good Morning', 'Good Evening', 'Good Night'] return c.html(<Top messages={messages} />)})
export default app- Layouts can be created by accepting
children. This allows you to have a consistent look and feel across different pages. - FC is a type that stands for “Function Component”, making it easy to define your components with TypeScript.
- Props allow you to pass data into your components, like the list of messages in the example.
PropsWithChildren
Imagine a component is a box. PropsWithChildren is a special type that ensures the box has a designated spot for “extras”—any content you put between the opening and closing tags of your component.
import { PropsWithChildren } from 'hono/jsx'
type PostProps = { id: number title: string}
function Post({ title, children }: PropsWithChildren<PostProps>) { return ( <div> <h1>{title}</h1> {children} </div> )}PropsWithChildrenis a helper type that automatically adds thechildrenproperty to your props.- It makes your components reusable wrappers. You can put anything inside the
<Post>tags, and it will show up where{children}is placed.
Memoization
If you have a component that does a lot of work but usually returns the same result, you can use memo. It’s like a student who memorizes the answer to a hard math problem so they don’t have to calculate it again next time.
import { memo } from 'hono/jsx'
const Header = memo(() => ( <header> <h1>Hono Framework</h1> </header>))memostores the rendered output of a component.- If the props don’t change, Hono will reuse the stored output instead of re-rendering, making your app faster.
Metadata hoisting
You can write document metadata tags such as <title>, <link>, and <meta> directly inside your components. These tags will be automatically hoisted to the <head> section of the document. This is especially useful when the <head> element is rendered far from the component that determines the appropriate metadata.
import { Hono } from 'hono'
const app = new Hono()
app.use('*', async (c, next) => { c.setRenderer((content) => { return c.html( <html> <head></head> <body>{content}</body> </html> ) }) await next()})
app.get('/about', (c) => { return c.render( <> <title>About Page</title> <meta name='description' content='This is the about page.' /> about page content </> )})
export default appWhen hoisting occurs, existing elements are not removed. Elements appearing later are added to the end. For example, if you have <title>Default</title> in your <head/> and a component renders <title>Page Title</title>, both titles will appear in the head.
Fragment
Use Fragment to group multiple elements without adding extra nodes:
import { Fragment } from 'hono/jsx'
const List = () => ( <Fragment> <p>first child</p> <p>second child</p> <p>third child</p> </Fragment>)Or you can write it with <></> if it sets up properly.
const List = () => ( <> <p>first child</p> <p>second child</p> <p>third child</p> </>)Context
Context is like a radio broadcast. You broadcast information (the Provider) and any component in your app can “tune in” to hear it (the Consumer), no matter how deep it is in the component tree.
import { createContext, useContext } from 'hono/jsx'
const ThemeContext = createContext('light')
const Button = () => { const theme = useContext(ThemeContext) return <button class={theme}>Click Me</button>}
app.get('/context', (c) => { return c.html( <ThemeContext.Provider value="dark"> <Button /> </ThemeContext.Provider> )})createContextcreates the “radio station”.Providersets the value being broadcast.useContextis how a component “listens” to the broadcast.
Context
You can access the Netlify’s Context through c.env:
import { Hono } from 'hono'import { handle } from 'hono/netlify'
// Import the type definitionimport type { Context } from 'https://edge.netlify.com/'
export type Env = { Bindings: { context: Context }}
const app = new Hono<Env>()
app.get('/country', (c) => c.json({ 'You are in': c.env.context.geo.country?.name, }))
export default handle(app)Deploy
You can deploy with a netlify deploy command.
netlify deploy --prodExercises
Now it’s your turn to be the senior developer!
Create a route /welcome/:name that returns an HTML page with:
- An
h1header saying “Welcome, [name]!” - A background color that is different from white (use inline CSS).
- A link
aback to the home page/.
app.get('/welcome/:name', (c) => { const { name } = c.req.param() return c.html( html`<!doctype html> <body style="background-color: lightblue;"> <h1>Welcome, ${name}!</h1> <a href="/">Go Home</a> </body>` )})Create a Box component using PropsWithChildren that:
- Accepts a title prop (string).
- Wraps its children in a section tag with a border.
- Use it in a route /box to wrap some “Secret Information”.
import { PropsWithChildren } from 'hono/jsx'
const Box = ({ title, children }: PropsWithChildren<{ title: string }>) => ( <section style={{ border: '2px solid coral', padding: '10px', margin: '10px' }}> <h2>{title}</h2> {children} </section>)
app.get('/box', (c) => { return c.html( <Box title="Secret Information"> <p>The password is 'hono-is-awesome'.</p> </Box> )})Use Context to pass a “Theme” value (“light” or “dark”) down to a button.
- Create a
ThemeContext. - Create a
ThemeButtoncomponent that consumes this context to set its class name. - In a route
/theme, provide the value “dark” and render the button.
import { createContext, useContext } from 'hono/jsx'
const ThemeContext = createContext('light')
const ThemeButton = () => { const theme = useContext(ThemeContext) return <button class={theme}>I am {theme}!</button>}
app.get('/theme', (c) => { return c.html( <ThemeContext.Provider value="dark"> <ThemeButton /> </ThemeContext.Provider> )})Create a component that renders a title tag and a meta description, then use it inside a Fragment with some text.
- Verify that the title appears in the head even if it’s deeply nested.
const SEO = () => ( <> <title>Hono SEO Mastery</title> <meta name="description" content="Mastering Hono metadata hoisting!" /> </>)
app.get('/seo-test', (c) => { return c.render( <> <SEO /> <h1>Check my head!</h1> <p>The title and description should be hoisted.</p> </> )})Try to pass some HTML tags into your /welcome/:name route via the URL.
- Verify that Hono safely escapes them.
- Now, create a route that uses the raw() helper to render that same input. What’s the risk?
- Hono escapes values by default, so tags show as literal text in the browser.
- If you use raw(), the browser will render the tags.
- The Risk: If a user sends a script tag, the script will run in the browser of anyone visiting that URL. Never use raw() with untrusted user input!
Wrap a StaticFooter component in memo.
- When should you use memo? Why is it useful for components that never change?
import { memo } from 'hono/jsx'
const StaticFooter = memo(() => ( <footer> <p>© 2024 Hono Academy</p> </footer>))- When: Use it for components that render the same output for the same props.
- Why: It skips the rendering phase and reuses the previous output, saving CPU cycles on the server.
Final Project: The Student Portal
Now, let’s put everything together. We’re going to build a small Student Directory. This project will use layouts, components, dynamic routes, and metadata hoisting.
- The Layout: Create a MainLayout component that takes children and wraps them in a consistent HTML structure.
- The Data: Create a list of student objects (id, name, bio).
- The List Page: Create a route /students that displays a list of all students as links.
- The Profile Page: Create a dynamic route /students/:id that displays the student’s bio and uses metadata hoisting to set the page title to the student’s name.
import { Hono } from 'hono' import type { FC, PropsWithChildren } from 'hono/jsx'
const app = new Hono()
// 1. The Layout Component const MainLayout: FC = ({ children }: PropsWithChildren) => { return ( <html> <head> <title>School Portal</title> </head> <body style={{ fontFamily: 'sans-serif', padding: '20px' }}> <header style={{ borderBottom: '2px solid #333', marginBottom: '20px' }}> <h1>School Portal</h1> </header> <main>{children}</main> <footer><hr/><p>Powered by Hono</p></footer> </body> </html> ) }
// 2. Sample Data const students = [ { id: '1', name: 'Alice', bio: 'Loves coding and coffee.' }, { id: '2', name: 'Bob', bio: 'Expert in SQL and pizza.' }, ]
// 3. List Page app.get('/students', (c) => { return c.html( <MainLayout> <h2>All Students</h2> <ul> {students.map(s => ( <li key={s.id}><a href={`/students/${s.id}`}>{s.name}</a></li> ))} </ul> </MainLayout> ) } )
// 4. Profile Page app.get('/students/:id', (c) => { const id = c.req.param('id') const student = students.find(s => s.id === id)
if (!student) return c.text('Student not found', 404)
return c.html( <MainLayout> <title>{student.name}'s Profile</title> <div style={{ border: '1px solid #ddd', padding: '15px', borderRadius: '8px' }}> <h2>{student.name}</h2> <p>{student.bio}</p> <a href="/students">Back to list</a> </div> </MainLayout> ) })
export default app