Skip to content
/ res-x Public

A ReScript framework for building server-driven web sites and applications. Use familiar tech like JSX and the component model from React, combined with simple server driven client side technologies like HTMX. Built on Bun and Vite.

Notifications You must be signed in to change notification settings

zth/res-x

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

59 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ResX

A ReScript framework for building server-driven web sites and applications. Use familiar tech like JSX and the component model from React, combined with simple server driven client side technologies like HTMX. Built on Bun and Vite.

ResX is suitable for building everything from blogs to complex web applications.

THIS IS ALPHA GRADE SOFTWARE.

Philosophy

ResX focuses on the web platform, and aims to see how far we can get building web sites and applications before reaching for a full blown client side framework is necessary.

ResX has an "open hood". That means that it's trying to stay close to the metal, and have fairly few abstractions. It encourages you to understand how a web server and the web platform works. This will lead to you building better and more robust things as you're encouraged to understand the platform itself.

Demo

The demo is currently a WIP. The demo/ will contain a comprehensive example of using ResX.

Getting started

First, make sure you have Bun installed and setup. Then, install rescript-x and the dependencies needed:

npm i rescript-x vite @rescript/core rescript-bun

Note that ResX requires these versions:

  • rescript@>=11.1.0-rc.2
  • @rescript/core@>=1.0.0
  • rescript-bun@>=0.4.1

Configure our rescript.json:

{
  "jsx": {
    "module": "Hjsx"
  },
  "bs-dependencies": ["@rescript/core", "rescript-x", "rescript-bun"],
  "bsc-flags": [
    "-open RescriptCore",
    "-open RescriptBun",
    "-open RescriptBun.Globals",
    "-open ResX.Globals"
  ]
}

Go ahead and install the dependencies for Tailwind as well if you want to use it:

npm i autoprefixer postcss tailwindcss

Let's set everything up. Start by setting up vite.config.js:

import { defineConfig } from "vite";
import { resXVitePlugin } from "rescript-x";

export default defineConfig({
  plugins: [resXVitePlugin()],
  server: {
    port: 9000,
  },
});

Make sure you have both folders for static assets set up: assets and public in the root, next to vite.config.js. More on static assets later.

If you're using Tailwind, add tailwind.config.js and postcss.config.js as well:

// postcss.config.js
module.exports = {
  plugins: [require("tailwindcss"), require("autoprefixer")],
};
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.res"],
  theme: {
    extend: {},
  },
  plugins: [],
};

There! If you want, you can also set up a bunch of scripts in package.json that'll make life easier:

{
  "scripts": {
    "start": "NODE_ENV=production bun run src/App.js",
    "build": "NODE_ENV=production && bun run build:vite && bun run build:res",
    "build:vite": "vite build",
    "build:res": "rescript",
    "clean:res": "rescript clean",
    "dev:res": "rescript build -w",
    "dev:server": "bun --watch run src/App.js",
    "dev:vite": "vite",
    "dev": "concurrently 'bun:dev:*'"
  }
}

Note: These scripts use concurrently. Install via npm i concurrently.

Now, let's create your Handler instance. You'll use this throughout your app as a sort of context:

// Handler.res

// This context will be passed throughout your application. Use it for any per-request needs, like dataloaders, the id of the currently logged in user, etc.
type context = {userId: option<string>}

// `requestToContext` should produce your context above from the pending `request`. It'll be called fresh for each request.
let handler = ResX.Handlers.make(~requestToContext=async _request => {
  userId: None,
})

// This isn't required but is a shorthand to pull out the context a bit more conveniently from your handler.
let useContext = () => ResX.Handlers.useContext(handler)

Next, let's set up our webserver via Bun:

// App.res
let port = 4444

let server = Bun.serve({
  port,
  development: ResX.BunUtils.isDev,
  fetch: async (request, server) => {
    open Bun

    // Serve static files first
    switch await ResX.BunUtils.serveStaticFile(request) {
    | Some(staticResponse) => staticResponse
    | None =>
      // Handle the request using the ResX handler if this wasn't a static file request.
      // Note: By default, all HTMX handler routes are prefixed with "_api", and all form action routes are prefixed with "_form".
      await Handler.handler->ResX.Handlers.handleRequest({
        request,
        setupHeaders: () => {
          // You can do any basic headers setup here that you want. These can be overwritten easily by your main application regardless of what you set here.
          Headers.makeWithInit(FromArray([("Content-Type", "text/html")]))
        },
        render: async ({path, requestController, headers}) => {
          // This handles the actual request.
          switch path {
          | list{"sitemap.xml"} => <SiteMap />
          | appRoutes =>
            requestController->ResX.RequestController.appendTitleSegment("Test App")
            <Html>
              <div>
                {switch appRoutes {
                | list{} =>
                  <div> {Hjsx.string("Start page!")} </div>
                | list{"moved"} =>
                  requestController->ResX.RequestController.redirect("/start", ~status=302)
                | _ =>
                  requestController->ResX.RequestController.setStatus(404)
                  <div>{Hjsx.string("404")}</div>
                }}
              </div>
            </Html>
          }
        },
      })
    }
  },
})

let portString = server->Bun.Server.port->Int.toString

Console.log(`Listening! on localhost:${portString}`)

// Run the dev server, responsible for hot module reloading etc, when in dev mode.
if ResX.BunUtils.isDev {
  ResX.BunUtils.runDevServer(~port)
}

Note that there's plenty of more things you can configure here, but for the sake of keeping it simple we'll just go with the basics.

You can now start up the dev environment: bun run dev. Open up localhost:9000 and you should see your "Start page!" string.

There's a ton more to ResX of course, but this should get you started.

Routing

As you noticed from the example above, there's no explicit router in ResX itself. In the future, we might ship a dedicated type safe router in the style of rescript-relay-router. But for now, we'll use pattern matching!

You route by just pattern matching on path:

switch path {
| list{} =>
  // Path: /
  <div> {Hjsx.string("Start page!")} </div>
| list{"moved"} =>
  // Path: /moved
  requestController->ResX.RequestController.redirect("/start", ~status=302)
| _ =>
  // Any other path
  requestController->ResX.RequestController.setStatus(404)
  <div>{Hjsx.string("404")}</div>
}

Static assets

ResX comes with full static asset (fonts, images, etc) handling via Vite, that you can use if you want. In order to actually serve the static assets, make sure you use ResX.BunUtils.serveStaticFile before trying to handle your request in another way:

fetch: async (request, server) => {
    open Bun

    switch await ResX.BunUtils.serveStaticFile(request) {
    | Some(staticResponse) => staticResponse
    | None =>
      await Handler.handler->ResX.Handlers.handleRequest({
        ...

ResX.BunUtils.serveStaticFile check if the request is for a static file, and if it is return a response serving that static file via Bun. If it's not a static file request, you continue as usual with serving the response.

As for the assets themselves, there are two ways of handling them in ResX:

public for assets that don't need transformation

Putting assets in the public directory. Any assets you put in the top level public directory next to vite.config.js will be copied as-is to your production environment. It's then available to you via the top level:

// public/robots.txt exists
GET /robots.txt

assets for assets that do need transformation

If you have assets you'd like transformed by Vite before using, put them in the top level assets folder. This could be CSS, images, additional JavaScript, and so on. Anything you might want Vite to transform.

Here's an example of how you wire up Tailwind:

/* assets/styles.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

Then, include it in your ReScript:

<head>
  <link type_="text/css" rel="stylesheet" href={ResXAssets.assets.styles_css} />
</head>

There! It's now available to you, and Vite will both transform and hot module reload the asset if it's possible.

Referring to transformed assets

Notice how we're not using a "/assets/styles.css" string to refer to styles.css, but rather ResXAssets.assets.styles_css? This is because ResX comes with a "type safe" asset layer - anything you put in assets/ will be available via ResXAssets.assets.

Always use this to refer to assets. One for the type safety of course, but also because this is how Vite keeps track of all asset files, so you get the transformed asset in production, and so on.

Bonus: Since the asset map is a regular ReScript record, you'll automatically get dead code analysis via the ReScript code analyzer. Dead code analysis for your assets! Makes it really easy to keep your assets folder clean.

ResX client side tools

ResX wants you to think "server side rendering" as much as possible. In order to allow you to take this as far as possible, ResX ships with 2 client side libraries that's intended to help you solve as many cases where client side JavaScript is needed as possible.

HTMX

ResX comes pre-baked with full HTMX support.

Make sure you include the HTMX script:

<script src="https://unpkg.com/htmx.org@1.9.5" />

hx-get, hx-post and friends

ResX has first class support for using hx-get, hx-post and friends from HTMX. There are two ways to use each hx attribute:

  1. Via the builtin ResX HTML attribute hx<method>. So, for example hxGet. This is the recommended way. Details below on how to use this.
  2. Putting a raw string on the hx<method> attribute. This is useful when you want to use HTMX with a route URL that you don't want to go through the regular ResX handling. Every hx<method> comes with an equivalent rawHx<method> prop, that takes a plain string. So you could do this: rawHxGet={"/some/path"}.

In the vast majority of cases you'll likely use number 1. In order to use 1., you create a htmxHandler, and then attach actions to that handler. You then pass those actions to hxGet, hxPost etc. Here's a simple example.

First, set up your htmxHandler. This maker takes a requestToContext function, that's responsible for translating a request into a (per-request) context. This is where you put the current user ID, dataloaders, or whatever else you want to have available through the lifetime of your request.

// Handler.res
type context = {userId: option<string>}

let handler = ResX.Handlers.make(~requestToContext=async request => {
  // Pull out the current user ID from the request, if it exists
  userId: Some("some-user-id"),
})

// Short hand for retrieving the context
let useContext = () => handler->ResX.Handlers.useContext

Now, we can attach and use actions via this handler:

// User.res
let onForm = Handler.handler->ResX.Handlers.hxPost("/user-single", ~handler=async ({request}) => {
  let formData = await request->Request.formData
  try {
    let name = formData->ResX.FormDataHelpers.expectString("name")
    <div>{Hjsx.string(`Hi ${name}!`)}</div>
  } catch {
  | Exn.Error(err) =>
    Console.error(err)
    <div> {Hjsx.string("Failed...")} </div>
  }
})

@jsx.component
let make = () => {
  <form
    hxPost={onForm}
    hxSwap={ResX.Htmx.Swap.make(InnerHTML)}
    hxTarget={ResX.Htmx.Target.make(CssSelector("#user-single"))}>
    <input type_="text" name="name" />
    <div id="user-single">
      {Hjsx.string("Hello...")}
    </div>
    <button>{Hjsx.string("Submit")}</button>
  </form>
}

This is all wired up automatically via ResX.Handlers.handleRequest. Also notice that as all of this is server side, you don't need to worry about accidentally leaking things to the client.

Handling cyclic dependencies

Sometimes you end up in a situation where you want to refer to the hxGet (or any other hx handler) you're implementing inside of the implementation itself. For example, a component that can "refresh" itself. This can't be done with the regular ResX.Handlers.get etc because that'd create a situation of cyclic dependencies where the definition of the handler refers to itself. In order to handle these specific scenarios, you can leverage ResX.Handlers.makeGet + ResX.Handlers.implementGet to first get a hxGet identifier you can attach to your DOM nodes, and then implement it in a place where you won't get cyclic dependencies.

Let's look at the example above and adjust it to work that way instead:

// User.res
let onForm = Handler.handler->ResX.Handlers.makeHxPostIdentifier("/user-single")

Handler.handler->ResX.Handlers.implementHxPostIdentifier(onForm, ~handler=async ({request}) => {
  let formData = await request->Request.formData
  try {
    let name = formData->ResX.FormDataHelpers.expectString("name")
    <div>{Hjsx.string(`Hi ${name}!`)}</div>
  } catch {
  | Exn.Error(err) =>
    Console.error(err)
    <div> {Hjsx.string("Failed...")} </div>
  }
})

@jsx.component
let make = () => {
  <form
    hxPost={onForm}
    hxSwap={ResX.Htmx.Swap.make(InnerHTML)}
    hxTarget={ResX.Htmx.Target.make(CssSelector("#user-single"))}>
    <input type_="text" name="name" />
    <div id="user-single">
      {Hjsx.string("Hello...")}
    </div>
    <button>{Hjsx.string("Submit")}</button>
  </form>
}

Notice how producing the hxPost identitifer is now separate from implementing it. This means you can put the implementation in a place where it won't suffer from circular dependencies.

Other hx-attributes are handled type safely

Note: All hx-attributes have equivalent raw versions, so you can always opt out of the type safe handling if it doesn't suite your needs.

All hx-attributes have type safe maker-style APIs. Let's look at the example above again:

@jsx.component
let make = () => {
  <form
    hxPost={onForm}
    hxSwap={ResX.Htmx.Swap.make(InnerHTML)}
    hxTarget={ResX.Htmx.Target.make(CssSelector("#user-single"))}>
    <input type_="text" name="name" />
    <div id="user-single">
      {Hjsx.string("Hello...")}
    </div>
    <button>{Hjsx.string("Submit")}</button>
  </form>
}

Notice how hxSwap and hxTarget are passed things from Htmx.Something.make? This is the way you interface with the typed hx attributes.

Regular form actions

Sometimes you don't need a full blown HTMX handler for handling a form action. Maybe all you want to do is redirect, or something else where you want full control over what response you return.

This is easy to do in ResX using a formAction. It's similar to a HTMX handler. Let's look at how to implement a form action that redirects as a form is submitted:

// User.res
let onSubmit = Handler.handler->ResX.Handlers.formAction("/some-url", ~handler=async ({request, context}) => {
  Response.makeRedirect("/some-other-page")
})

@jsx.component
let make = () => {
  <form action={onSubmit}>
    <button>{Hjsx.string("Submit and get redirected!")}</button>
  </form>
}

Form actions have access to your context object, as well as the full request object. They're expected to return a Response.t, which you're in charge of building yourself.

You control whether you want the form method to be POST or GET via the method attribute on <form>, just like you normally do.

ResX Client

ResX also ships with a tiny client side library that will help you do basic client side tasks fully declaratively. It's quite basic at the moment, but will be extended (tastefully) as we discover more places where it can help you avoid having to use a full blown client side framework to accomplish fairly basic tasks.

To use ResX client, make sure you include its script:

<script src={ResXAssets.assets.resXClient_js} async=true />

Handling CSS classes on events

Sometimes all you need to do is add, remove or toggle a CSS class in response to something like a click somewhere. Here's how you do that with ResX:

<button
  id="test"
  resXOnClick={ResX.Client.Actions.make([
    ToggleClass({className: "text-xl", target: This}),
  ])}>
  {Hjsx.string("Submit form")}
</button>

Notice resXOnClick. This will trigger on any click of the button, and toggle the CSS class text-xl on the button element itself.

Have a look in the ResX.Client module for an exhaustive list of all actions that are available and how to use them.

Setting custom validity messages for HTML5 form validation

Contrary to popular belief, the built in HTML5 form validation is actually pretty good, and will get you really far before you need to reach for client side JavaScript to validate. But, it has one glaring omission that makes it harder - you can't set custom validation messages without a fairly involved process, depending on client side JavaScript. ResX aims to fix this via resXValidityMessage:

<input
  type_="text"
  name="lastName"
  required=true
  resXValidityMessage={ResX.Client.ValidityMessage.make({
    valueMissing: "Yo, you need to fill this in!",
  })}
/>

This will turn the validity message for when the value is missing (since it's marked a required) into the supplied message, rather than the generic message printed by the browser.

resXValidityMessage supports changing all available validity messages. Refer to the ResX.Client.ValidityMessage module for an exhaustive list.

Building UI with ResX

If you're familiar with React, JSX and the component model, building UI with ResX is very straight forward. It's essentially like using React as a templating engine, with a sprinkle of React Server Components flavor.

In ResX, you'll interface with 2 modules mainly when working with JSX:

  1. Hjsx - this holds functions like string, int etc for converting primitives to JSX, and a bunch of things that are needed for the JSX transform.
  2. H - this holds the Context module, as well as functions for turning JSX elements into strings.

The bulk of your code is going to be (reusable) components. You define one just like you do in React, with the difference that React.string, React.int etc are called Hjsx.string and Hjsx.int, and @react.component is called @jsx.component instead:

// Greet.res
@jsx.component
let make = (~name) => {
  <div>{Hjsx.string("Hello " ++ name)}</div>
}

// SomeFile.res
@jsx.component
let make = (~userName) => {
  <div>
    <Greet name=userName />
  </div>
}

Async components

Components can be defined using async/await. This enables you to do data fetching directly in them:

// User.res
@jsx.component
let make = async (~id) => {
  let user = await getUser(id)

  <div>{Hjsx.string("Hello " ++ user.name)}</div>
}

WARNING! As with all async things you need to be careful to not create waterfalls, or performance will suffer. Handling that is out of scope for this readme, but following this tip will get you far - initiate data fetching as far up the tree as possible. Awaiting the data is fine to do in leaf components, but it's good for perf to initiate data fetching as high up as possible, and then pass the promise of that data down the tree.

Context

Just like in React, you can use context to pass data down your tree without having to prop drill it:

// CurrentUserContext.res
let context = H.Context.createContext(None)

let use = () => H.Context.useContext(context)

module Provider = {
  let make = H.Context.provider(context)
}

@jsx.component
let make = (~children, ~currentUserId: option<string>) => {
  <Provider value={currentUserId}> {children} </Provider>
}

// App.res
let currentUserId = request->UserUtils.getCurrentUserId
<CurrentUserContext currentUserId>
  <div> ... </div>
</CurrentUserContext>

// LoggedInUser.res
// This is rendered somewhere far down in the tree
@jsx.component
let make = () => {
  switch CurrentUserId.use() {
  | None => <div>{Hjsx.string("Not logged in")}</div>
  | Some(currentUserId) => <div>{Hjsx.string("Logged in as: " ++ currentUserId)}</div>
  }
}

Error boundaries

Just like in React, you can protect parts of your UI from errors during render using an error boundary, using the <ResX.ErrorBoundary /> component. You need to pass it a renderError function, and this function will be called whenever there's an error:

<ResX.ErrorBoundary renderError={err => {
  Console.error(err)
  <div>{Hjsx.string("Oops, this blew up!")}</div>
}}>
  <div>
    <ComponentThatWillBlowUp />
  </div>
</ResX.ErrorBoundary>

You can use as many error boundaries as you want. You're recommended to wrap your entire app with an error boundary as well.

Request conveniences

ResX ships with a number of conveniences for handling common things when building responses for requests.

onBeforeSendResponse hook for manipulating the final response before sending it

ResX tries to ship with as few "hooks" and similar concepts as possible. You're encouraged to mold your application as you see if, in order to keep as much control as possible.

But, we still do ship a few conveniences. The onBeforeSendResponse hook is one of them. It lets you manipulate the response you're producing one last time before sending it to the client. Let's look at an example of overriding any cache header set when the user is logged in:

await Handler.handler->ResX.Handlers.handleRequest({
  request,
  onBeforeSendResponse: ({context, response, request}) => {
    // Change (or replace) the final response here.
    if context.isLoggedIn {
      response->Response.headers->Headers.set("Cache-Control", "no-store, no-cache"))
    }

    response
  },
  render: async ({path, requestController, headers}) => {
    // This handles the actual request.
    ...

This way, you can conveniently make sure that no logged in pages are cached, and so on.

<title> integration

It's nice to be able to set the <title> incrementally as you render your app. But, <title> belongs in <head> and when you render <head> you probably don't have everything you need to produce the title you want.

Therefore, ResX ships with a helper for handling the title using ResX.RequestController. This helper lets you either append items to the title, or set the full title. You can then easily build your title element as you render your app, without having to know the full title as you render <head>:

// App.res
let context = ResX.Handlers.useContext(HtmxHandler.handler)
context.requestController->ResX.RequestController.appendTitleSegment("My App")

// Users.res
// Title is now "MyApp | Users"
context.requestController->ResX.RequestController.appendTitleSegment("Users")

// SingleUser.res
// Title is now "MyApp | Users | Someuser Name"
context.requestController->ResX.RequestController.appendTitleSegment(user.name)

It's also easy to set the title to something else entirely with setFullTitle:

// SingleUser.res
// Title is now "Failed!"
context.requestController->ResX.RequestController.setFullTitle("Failed!")

Note: You control how the title is rendered by passing a renderTitle function to handleRequest.

Generic append to head

It's not just <title> that might be inconvenient to have to produce as you're rendering <head>. You might have styles or other things that you might want to load depending on what you're rendering, and that belongs in <head>. ResX.RequestController comes with a generic appendToHead function for that:

// SingleUser.res
context.requestController->ResX.RequestController.appendToHead(<link href={ResXAssets.assets.single_user_page_styles_css} rel="text/stylesheet" />)

There's also a component you can use to render things into head. This component can be rendered anywhere in the component tree and the content will still be rendered in <head>:

// SingleUser.res
<div>
  <ResX.RenderInHead>
    <link href={ResXAssets.assets.single_user_page_styles_css} rel="text/stylesheet" />
  </ResX.RenderInHead>
</div>

Redirects

You can redirect easily using ResX.RequestController.redirect:

requestController->ResX.RequestController.redirect("/start", ~status=302)

This returns a JSX element, so you can easily integrate it wherever you want to set the redirect:

switch path {
| list{"moved"} =>
  requestController->ResX.RequestController.redirect("/start", ~status=302)

Cache control

Cache headers can be a bit confusing. ResX comes with a helper to produce the cache control header string via ResX.Utils.CacheControl. Here's an example:

// Sets Cache-Control to "public, max-age=86400"
context.headers->Headers.set(
  "Cache-Control",
  ResX.Utils.CacheControl.make(~cacheability=Public, ~expiration=[MaxAge(Days(1.))]),
)

There's also a number of cache control presets available under ResX.Utils.CacheControl.Presets. This includes examples for static assets that are to be cached long term, to sensitive content that should never be cached by anyone.

Response status

You can set the response status anywhere when rendering:

// FourOhFour.res
@jsx.component
let make = () => {
  let context = ResX.Handlers.useContext(HtmxHandler.handler)
  context.requestController->ResX.RequestController.setStatus(404)

  <div> {Hjsx.string("404")} </div>
}

Other headers

Setting any other header anywhere when rendering is also easy:

let context = ResX.Handlers.useContext(HtmxHandler.handler)
context.headers->Headers.set("Content-Type", "text/html")

Advanced: Doc header

By default, any returned content from your handlers is prefixed with <!DOCTYPE html> because you're expected to return HTML. However, there are cases where you might want to return other things than HTML but still use JSX. One example is returning XML to produce a site map. For that, you can leverage ResX.RequestController.setDocHeader:

// SiteMap.res
@jsx.component
let make = () => {
  let context = ResX.Handlers.useContext(HtmxHandler.handler)

  context.requestController->ResX.RequestController.setDocHeader(
    Some(`<?xml version="1.0" encoding="UTF-8"?>`),
  )

  context.headers->Headers.set("Content-Type", "application/xml; charset=UTF-8")
  context.headers->Headers.set(
    "Cache-Control",
    ResX.Utils.CacheControl.make(~cacheability=Public, ~expiration=[MaxAge(Days(1.))]),
  )

  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
      <loc> {Hjsx.string("https://www.example.com/")} </loc>
      <lastmod> {Hjsx.string("2023-10-15")} </lastmod>
      <changefreq> {Hjsx.string("weekly")} </changefreq>
      <priority> {Hjsx.string("1.0")} </priority>
    </url>
  </urlset>
}

The above example renders a site map in XML. You can then simply render this whenever someone requests /sitemap.xml:

render: async ({path}) => {
  switch path {
  | list{"sitemap.xml"} => <SiteMap />
  ...

Handling forms

  • FormDataHelpers
  • FormData

Vite plugin

ResX comes with its own Vite plugin that takes care of all configuration for you. It will:

  • Ensure all ResX assets are handled and included properly
  • Ensure that Hot Module Reloading works for all assets and that Vite dev mode is properly wired up to your local ResX dev server

Note: Right now, using ResX with more elaborate Vite config than what's preconfigured for you might be problematic. This will change in the future though so that ResX is just another part of your Vite config. Open issues please when you find use cases you'd like supported but that doesn't work now.

Static site generation

WIP: Static site generation is easy to do. StaticExporter.res and demo/Exporter.res.

Ideas

This section will be expanded as we go along.

  • Auth
  • Enhanced cookies
  • Router abstraction
  • Relay for ResX
  • Static and semi-static generation
  • Suspense and (out of order) streaming

About

A ReScript framework for building server-driven web sites and applications. Use familiar tech like JSX and the component model from React, combined with simple server driven client side technologies like HTMX. Built on Bun and Vite.

Resources

Stars

Watchers

Forks

Packages

No packages published