Docs

#Creating a new project

To create a new project, open a terminal and run the following commands (adjust as appropriate for your preferred package manager):

npx create-hwy@latest
npm i
npm run dev
#Project structure

A simple Hwy project is structured like this:

root
├── public/
│   ├── favicon.ico
├── src/
│   ├── pages/
│   │   ├── _index.page.tsx
│   │   ├── $.page.tsx
│   ├── styles/
│   │   ├── global.bundle.css
│   │   ├── global.critical.css
│   ├── client.entry.ts
│   ├── main.tsx
│   .gitignore
│   ...

The public directory is where you'll put static, public assets, such as favicons, opengraph images, font files, etc.

The src directory is where you'll put your source files. Most of the structure is completely up to you, but there are a few conventions that Hwy expects.

#Pages directory

First, you must have a src/pages directory. This is where you'll put your page files. This is similar to the "routes" directory in Remix.

The rules are very simple:

  • Pages should include .page. (e.g.,about.page.tsx) in the filename. If you want co-location in this directory, you can always just exclude the .page. part in any filename (e.g.,about-components.tsx).
  • Directory names will become part of the path, unless they are prefixed with double underscores. For example, if you have a src/pages/foo directory, and a file inside the foo directory called bar.page.tsx (/src/pages/foo/bar.page.tsx), the path would be example.com/foo/bar. If you want the directory to be ignored, prefix it with two underscores (e.g., __foo). In that case, the route will just be example.com/bar.
  • If you want a default index page inside at any route, just include an _index.page.tsx file in that directory. This includes the pages directory itself; /src/pages/_index.page.tsx will be the default route for your site.
  • If you want to include a layout for a route (e.g., a sidebar or sub-navigation), include a file with the same name as the directory (but with .page.tsx included) as a sibling to the route directory. For example, if you have a route at /foo/bar, you can include a layout at/src/pages/foo/bar.page.tsx and a default page at /src/pages/foo/bar/_index.page.tsx. Note that any layouts for your main home page (src/_index.page.tsx), such as a global navigation header, should be inserted into your root component that is rendered from your main server entry point (i.e., src/main.tsx).
  • If you want to include dynamic child routes, you can just prefix the file name with a dollar sign ($). For example, if you have a route at /foo/bar, you can include a dynamic child route at /src/pages/foo/bar/$id.page.tsx. This will match any route that starts with /foo/bar/ and will pass the id as a parameter to the page (including the page's loader, action, and component... more on this later). The /foo/bar route will still render the index page, if you have one.

    NOTE: One "gotcha" with this is that you need to use a string that would be safe to use as a JavaScript variable for your dynamic properties. For example, /src/pages/$user_id.page.tsx would be fine, but /src/pages/$user-id.page.tsx would not.
  • If you want to have "catch-all" or "splat" routes, you can include a file named simply $.page.tsx. This will match any route that hasn't already been matched by a more specific route. You can also include a top-level 404 page by including a file named $.page.tsx in src/pages. Any splat parameters "caught" by one of these routes will be passed into the page.

#Page components

Pages are very simple as well. They are simply JSX components default exported from a page route file. Again, a page route file is any file in the src/pages directory that includes .page. in the filename. For example,src/pages/about.page.tsx is a page file.

// src/pages/about.page.tsx

export default function () {
  return (
    <p>
      I like baseball.
    </p>
  )
}

Pages are passed a PageProps object, which contains a bunch of helpful properties. Here are all the properties available on the PageProps object:

export default function ({
  c,
  loaderData,
  actionData,
  Outlet,
  params,
  splatSegments,
}: PageProps<typeof loader, typeof action>) {
  return (
    <div>
      I like {loaderData?.sport}.
      <Outlet />
    </div>
  )
}

  • c - This is the Hono Context object. It contains the request and response objects, as well as some other useful properties and methods. See the Hono docs for more info.
  • loaderData - This is the data returned from the route loader. If you aren't using a route loader, this will be undefined. If you are using a route loader and pass in typeof loader as a generic to PageProps, this will be 100% type-safe.
  • actionData - Same as loaderData, except in this case the data comes from your route's action, if applicable. If you are using a route action but not a route loader, this is how you'd handle the generics: PageProps<never, typeof action>.
  • Outlet - This is the outlet for the page, and it's where child routes get rendered. You render outlets just like any other component (you can even pass in props if you want): (<Outlet whatever={whatever} />)
  • params - This is an object containing any parameters passed to the page. For example, if you have a page at src/pages/foo/bar/$id.page.tsx, the params object will contain a property called id with the value of the id parameter. In other words, if the user visits the route example.com/foo/bar/123, the params object will be { id: '123' }.
  • splatSegments - This is an array of any "splat" segments caught by the "deepest" splat route. For example, if you have a page at src/pages/foo/bar/$.page.tsx (a splat route) and the user visits example.com/foo/bar/123/456, the splatSegments array will be ['123', '456'].

PageProps is also a generic type, which takes typeof loader and typeof action as its two parameters, respectively. These are the types of the loader and action functions for the page (more on this later). If you aren't using data functions for a certain page, you can just skip the generics.

One cool thing about Hwy is that you have access to the Hono Context from within your page components. This means you can do things like set response headers right inside your page components. You can also do this from loaders and actions if you prefer.

import { PageProps } from 'hwy'

export default function ({ c }: PageProps) {
  c.res.headers.set('cache-control', 'whatever')

  return <Whatever />
}
#Page loaders

Page loaders are functions named "loader" that are exported from a page file. They are passed a subset of the PageProps object: c, params, and splatSegments. The typescript type exported by Hwy for this object is called DataProps, which can take an optional generic of your Hono Env type (see the Hono docs for more details on that, and why you might want to do that).

Loaders run before your page is returned, and they all run in parallel. They are useful for fetching data, and any data returned from a loader will be passed to its associated page component. They can also be useful for redirecting to another page (covered a little later).

If you want to consume data from a loader in your page component (usually you will), then you should just return standard raw data from the loader, like this:

import type { DataProps } from 'hwy'

export function loader({ c }: DataProps) {
  return "baseball" as const
}

export default function ({ loaderData }: PageProps<typeof loader>) {
  return (
    <p>
      I like {loaderData}. // I like baseball.
    </p>
  )
}

If you return a Response object, then that will "take over" and be returned from the route instead of your page. This is fine if you're creating a "resource route" (more on this below), but usually it's not what you want.

However, one thing that is more common is that you may want to return a redirect from a loader. You can do this with a Response object if you want, but Hwy exports a helper function that covers more edge cases and is built to work nicely with the HTMX request lifecycle.

import { redirect, type DataProps } from 'hwy'

export function loader({ c }: DataProps) {
  return redirect({ c, to: '/login' })
}

You can also "throw" a redirect if you want, which can be helpful in keeping your typescript types clean.

#Server components

In addition to using loaders to load data in parallel before rendering any components, you can also load data inside your Hwy page components. Be careful with this, as it can introduce waterfalls, but if you are doing low-latency data fetching and prefer that pattern, it's available to you in Hwy.

// src/some-page.page.tsx

export default async function ({ Outlet }: PageProps) {
  const someData = await getSomeData()

  return (
    <div>
      {JSON.stringify(someData)}

      <Outlet />
    </div>
  )
}

You can also pass data to the child outlet if you want, and it will be available in the child page component's props. Here's how that would look in the parent page component:

<Outlet someData={someData} />

And in the child component, you'll want to use PageProps & { someData: SomeType } as your prop type.

Another way of doing this would be to use Hono's c.set('some-key', someData) feature. If you do that, any child component will be able to access the data without re-fetching via c.get('some-key').

The world is your oyster!

#Page actions

Page actions behave just like loaders, except they don't run until you call them (usually from a form submission). Loaders are for loading/fetching data, and actions are for mutations. Use actions when you want to log users in, mutate data in your database, etc.

Data returned from an action will be passed to the page component as the actionData property on the PageProps object. Unlike loaders, which are designed to run in parallel and pass different data to each nested component, actions are called individually and return the same actionData to all nested components.

Here is an example page with a login form. Note that this is highly simplified and not intended to be used in production. It is only intended to show how actions work.

import { DataProps, PageProps } from 'hwy'
import { getFormStrings } from '@hwy-js/utils'
import { logUserIn } from './somewhere.js'

export async function action({ c }: DataProps) {
  const { email, password } = await getFormStrings({ c })
  return await logUserIn({ email, password })
}

export default function ({ actionData }: PageProps<never, typeof action>) {
  if (actionData?.success) {
    return <p>Success!</p>
  }

  return (
    <form action="/login" method="POST">
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button type="submit">Login</button>
    </form>
  )
}

This form uses 100% standard html attributes, and it will be automatically progressively enhanced by HTMX (uses the hx-boost feature). If JavaScript doesn't load for some reason, it will fall back to traditional web behavior (full-page reload).

#Resource routes

Remix has the concept of "resource routes", where you can define loaders and actions without defining a default export component, and then use them to build a view-less API.

In Hwy, you're probably better off just using your Hono server directly for this, as it's arguably more traditional, straightforward, and convenient. However, if you really want to use resource routes with Hwy's file-based routing, nothing is stopping you! You can do so by just making sure you return a fetch Response object from your loader or action. For example:

// src/pages/api/example-resource-root.ts

export function loader() {
  return new Response('Hello from resource route!')
}

All of that being said, Hwy is designed to work with HTMX-style hypermedia APIs, not JSON APIs. So if you return JSON from a resource route or a normal Hono endpoint, you'll be in charge of handling that on the client side. This will likely entail disabling HTMX on the form submission, doing an e.preventDefault() in the form's onsubmit handler, and then doing a standard fetch request to the Hono endpoint. You can then parse the JSON response and do whatever you want with it.

You probably don't need to do this, and if you think you do, I would challenge you to try using the hypermedia approach instead. If you still decide you need to use JSON, this is roughly the escape hatch.

#Error boundaries

Any Hwy page can export an ErrorBoundary component, which takes the same parameters as loaders and actions, as well as the error itself. The type for the ErrorBoundary component props is exported as ErrorBoundaryProps. If an error is thrown in the page or any of its children, the error will be caught and passed to the nearest applicable parent error boundary component. You can also pass a default error boundary component that effectively wraps your outermost RootOutlet (in main.tsx) like so:

import type { ErrorBoundaryProps } from 'hwy'

...

<RootOutlet
  c={c}
  activePathData={activePathData}
  fallbackErrorBoundary={(props: ErrorBoundaryProps) => {
    return <div>{props.error.message}</div>
  }}
/>
#Hono middleware and variables

You will very likely find yourself in a situation where there is some data you'd like to fetch before you even run your loaders, and you'd like that data to be available in all downstream loaders and page components. Here's how you might do it:

app.use('*', async (c, next) => {
  const user = await getUserFromCtx(c)

  c.set('user', user)

  await next()
})

This isn't very typesafe though, so you'll want to make sure you create app-specific types. For this reason, all Hwy types that include the Hono Context object are generic, and you can pass your app-specific Hono Env type as a generic to the PageProps object. For example:

import type { DataProps } from 'hwy'

type AppEnv = {
  Variables: {
    user: Awaited<ReturnType<typeof getUserFromCtx>>
  }
}

type AppDataProps = DataProps<AppEnv>

export async function loader({ c }: AppDataProps) {
  // this will be type safe!
  const user = c.get('user')
}
#Main.tsx

In your main.tsx file, you'll have a handler that looks something like this.

import {
  CssImports,
  RootOutlet,
  DevLiveRefreshScript,
  ClientScripts,
  getDefaultBodyProps,
  renderRoot,
} from 'hwy'

app.all('*', async (c, next) => {
  return await renderRoot({ 
    c,
    next,
    root: ({ activePathData }) => {
      return (
        <html lang="en">
          <head>
            <meta charset="UTF-8" />
            <meta name="viewport" content="width=device-width,initial-scale=1" />
  
            <HeadElements
              c={c}
              activePathData={activePathData}
              defaults={defaultHeadBlocks}
            />
  
            <CssImports />
            <ClientScripts activePathData={activePathData} />
            <DevLiveRefreshScript />
          </head>
  
          <body {...getDefaultBodyProps()}>
            <RootOulet
              c={c}
              activePathData={activePathData}
              fallbackErrorBoundary={FallbackErrorBoundary}
            />
          </body>
        </html>
      )
    },
    experimentalStreaming: false, // optional
  })
})

The easiest way to get this set up correctly is to bootstrap your app with npx create-hwy@latest.

#Document head (page metadata)

Your document's head is rendered via the HeadElements component in your main.tsx file, like this:

<HeadElements
  activePathData={activePathData}
  c={c}
  defaults={[
    { title: 'Hwy Framework' },
    {
      tag: 'meta',
      props: {
        name: 'description',
        content:
          'Hwy is a simple, lightweight, and flexible web framework, built on Hono and HTMX.',
      },
    },
  ]}
/>

As you can probably see, the "defaults" property takes an array of head elements. "Title" is a special one, in that it is just an object with a title key. Other elements are just objects with a tag and props key. The props key is an object of key-value pairs that will be spread onto the element.

The defaults you set here can be overridden at any Hwy page component by exporting a head function. For example:

import { HeadFunction } from 'hwy'

export const head: HeadFunction = (props) => {
  // props are the same as PageProps, but without the Outlet

  return [
    { title: 'Some Child Page' },
    {
      tag: 'meta',
      props: {
        name: 'description',
        content:
          'Description for some child page.',
      },
    },
  ]
}

This will override any conflicting head elements set either by an ancestor page component or by the root defaults. The head function is passed all the same props as a page component, excluding Outlet.

#Styling

Hwy includes built-in support for several CSS patterns, including a very convenient way to inline critical CSS. CSS is rendered into your app through the CssImports component. That component in turn reads from the src/styles directory, which is where you should put your CSS files. Inside the styles directory, you can put two types of CSS files: critical and bundle. Any files that include .critical. in the filename will be concatenated (sorted alphabetically by file name), processed by esbuild, and inserted inline into the head of your document. Any files that include .bundle. in the filename will similarly be concatenated (sorted alphabetically by file name), processed by esbuild, and inserted as a standard linked stylesheet in the head of your document.

It's also very easy to configure Tailwind, if that's your thing. To see how this works, spin up a new project with npx create-hwy@latest and select "Tailwind" when prompted.

And of course, if you don't like these patterns, you can just choose not to use them, and do whatever you want for styling instead!

#Deployment

Hwy can be deployed to any Node-compatible runtime with filesystem read access. This includes more traditional Node app hosting like Render.com or Railway.app, or Vercel (Lambda), or Deno Deploy. This should also include Bun once that ecosystem becomes more stable and has more hosting options. Just choose your preferred deployment target when you run npx create-hwy@latest.

Cloudflare is a bit trickier, however, because Hwy reads from the filesystem at runtime. We may add support for this in the future through a specialized build step, but for now, it's not supported. This also means that Vercel edge functions are not supported, as they rely on Cloudflare Workers, which do not have runtime read access to the filesystem. Normal Vercel serverless, which runs on AWS Lambda under the hood, will work just fine.

#Progressive enhancement

When you included the hx-boost attribute on the body tag (included by default when you use getDefaultBodyProps), anchor tags (links) and form submissions will be automatically progressively enhanced. For forms, include the traditional attributes, like this:

<form action="/login" method="POST">

#Random

Here is some random stuff that is worth noting, but doesn't fit well into any existing sections.

  • HTMX handles scroll restoration for you!
  • Code splitting is not a concern with this architecture.
  • @hwy/dev is in a separate package so that it doesn't need to be loaded in production. This probably doesn't matter much, but theoretically it could help with cold starts if you're deploying to serverless.
  • Never have to fix a hydration error again.
#Using Hwy without HTMX

If you would like to use Hwy like a traditional MPA framework, and skip using HTMX, you can do so simply by excluding HTMX from your src/client.entry.ts file.

#Security

A few points on security:

  • Similar to React, Hono JSX rendering will automatically escape the outputted html. If you want to render scripts, you should do the classic React thing (works the same with Hono JSX):


    <script
      dangerouslySetInnerHTML={{
        __html: `console.log('hello world')`,
      }}
    />
  • When you implement auth in your app, make sure you consider CSRF protection. Hwy isn't doing anything special there. It's your responsibility to get that right.
  • When you use npx create-hwy@latest, we include the Hono secure headers middleware for you. Please see the Hono docs for more info on what that is doing, and make sure it's appropriate for your use case.
#Self-vendoring

If you would like to self-vendor your dependencies instead of using a src/client.entry.ts file (which is bundled with esbuild), you can do so simply by including your dependencies in the /public directory and referencing them in a script. For example:

<script
  src={getPublicUrl('your-script.js')}
  defer
/>

#Roadmap

Other than the obvious (stabilize APIs, more tests, better docs, etc.), here are a few things open for consideration on the roadmap:

  • A simple solution for identifying "active" navigation links, similar perhaps to Remix's NavItem component. For now, it shouldn't be too hard to build one yourself.
  • It would be nice to have a built-in solution for link prefetching.
  • What else? You tell me!