devpad #7 - Decoupling API from AstroJS with Bun + Hono

Published

Well, it's been a while since I last visited the devpad, I started a new job at Amazon, moved cities & left my previous life behind. So what better opportunity than the completely refactor my entire project (again). Something I've learnt at my time at Amazon (5 months as of writing this) - is how to actually structure a project. I've also been messing around a lot with GenAI at work, so I thought I might try throw some vibes into the mix & give decoupling my api from my Astro project.

the goals

  • In theory, I want the ability to be able to deploy my API separately to the front-end UI. I don't know why, I just have a hunch it's a good idea.
  • The ability to write a cli tool & mcp server with full end-to-end typesafety, ideally with a type-safe api client wrapper.
  • Re-use that same api wrapper in the front-end code (with full type safety)
  • Eventually, build up a TUI for the application

why

Well, a TUI sounds epic. I've recently fallen in love with terminals. Finally spending some time & getting my tmux dotfiles setup to how I want it, getting all my neovim keybinds to where I feel natural and ~90% feature parity with vs code, running gitui for visual diffs & git logs, and more importantly, using terminal-based AI agents. At work, we use amazon q, and this has a cli application - which we can hook up to a multitude of internal MCP servers for, to be honest, really good levels of productivity & debugging. This is something I wanted to replicate for my side projects. opencode caught my eye - I've been a fan of sst on twitter for a while, at least the syntax of the IaC looked nice to my eyes, despite me never using it. Anyway I went ahead an installed it - setup Claude Pro, messed around for a bit.

Wow.

I didn't realise it at the time but the AI is actually half-decent. I don't necessarily think it's any faster than me, and it's definitely not cleaner or better quality. But the amazing thing is it's lowered the "motivation barrier" of entry for me. When I come home from work - at least in my last job - there was 0 chance I had any energy left to do any side projects. Side projects were reserved for the occasional 2-week burst of motivation that my ADHD brain would inject into me & I'd blaze through 3 months of code in 3 caffeine fueled nights (usually a weekend where I'd lock the doors, cancel all my plans, and flow-state for 16 hours). That wasn't healthy, it wasn't sustainable, and it meant I had multitudes of side-projects just collecting dust (and I still do).

Well, AI has lowered that barrier. I can come home, pick a task from my todo list (that I manage on devpad itself), tell it to make up a plan-of-action. Then go to the bathroom, heat up my meal prep - then come back and nitpick the plan & tell it to start implementing. While I'm eating dinner, maybe watching some youtube, it's cooking up in the background. When I check in, I'm usually pleasantly surprised to see it's kinda done what I asked - and not in a horrible manner. Then, I can give some feedback, some updates, tell it to refine the tests a bit & keep working - and then, using tmux spin up another session for another project, and do the same thing. 3 projects, all going at once. I suppose if you did a sum of all the code it wrote - perhaps it is faster (more code per hour) than my hands can accomplish. It wasn't long until I bought the $200/month plan for increased tokens - and I still tend to run out every session.

Anyway, how did I get it to setup Astro + Bun + Hono.

how

There's not much too it really, I spent ~4 hours trying to get this to work, digging through reddit threads & wiki's. Here's what you'll need

That's it!

Here's the snippet of my config:

// astro.config.mjs
import sitemap from "@astrojs/sitemap";
import solidJs from "@astrojs/solid-js";
import { defineConfig } from "astro/config";
import honoAstro from "hono-astro-adapter";

const site = process.env.SITE_URL ?? "https://devpad.tools";

// https://astro.build/config
export default defineConfig({
    server: { port: process.env.PORT ? Number(process.env.PORT) : 3000 },
    site,
    output: "server",
    adapter: honoAstro(),
    integrations: [
        solidJs(),
        sitemap({
            customPages: [`${site}/`, `${site}/docs/`],
        }),
    ],
    session: {
        driver: "fs",
    },
    vite: {
        build: {
            watch: false,
            chunkSizeWarningLimit: 3000,
            sourcemap: false,
        },
        resolve: {
            alias: {
                "@": "/src",
            },
        },
    },
});

Note the adapter: honoAstro() - this is the key.

Then here's the snippet for creating the Hono app - I have this in a function for some reason - but the logic here should fit in anywhere:

import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { handler as ssrHandler } from "../../app/dist/server/entry.mjs";

/**
 * Create and configure the Hono application
 */
export function createApp(options: ServerOptions = {}): Hono {
    const app = new Hono();

    // Logger middleware - skip health checks and assets to reduce noise
    app.use("*", async (c, next) => {
        // Skip logging for health check endpoints and static assets to reduce noise
        if (c.req.path === "/health" || c.req.path.startsWith("/_astro")) {
            return next();
        }
        // Apply logger middleware for all other routes
        return logger()(c, next);
    });

    const ALLOWED_ORIGINS = options.corsOrigins || process.env.CORS_ORIGINS?.split(",") || DEFAULT_ORIGINS;

    app.use(
        "*",
        cors({
            origin: process.env.NODE_ENV === "test" ? origin => origin || "*" : ALLOWED_ORIGINS,
            credentials: true,
        })
    );

    // Health check (before auth middleware)
    app.get("/health", c =>
        c.json({
            status: "ok",
            timestamp: new Date().toISOString(),
            version: process.env.npm_package_version || "unknown",
            environment: options.environment || process.env.NODE_ENV || "development",
        })
    );

    // Apply auth middleware to auth routes (except login/callback which need to be public)
    app.use("/api/auth/verify", authMiddleware);
    app.use("/api/auth/session", authMiddleware);

    // Apply auth middleware to other API routes
    app.use("/api/v0/*", authMiddleware);

    // Auth routes (login/callback public, verify/session protected by middleware above)
    app.route("/api/auth", authRoutes);

    // API Routes
    app.route("/api/v0", v0Routes);

    // Serve static files first - this needs to come before SSR handler
    // When running from packages/server directory, the path is ../app/dist/client/
    app.use("/*", serveStatic({ root: options.staticPath }));

    // Use the SSR handler for everything that's not a static file or API
    // The handler is a Hono middleware that expects (ctx, next, locals)
    app.use(ssrHandler);

    // Final 404 handler for any unmatched routes (shouldn't be reached if SSR handles 404s)
    app.notFound(c => {
        if (c.req.path.startsWith("/api/")) {
            return c.text("API endpoint not found", 404);
        }
        // The SSR handler should handle 404 pages, but just in case
        return c.text("Page not found", 404);
    });

    return app;
}

options.staticPath is set to "./packages/app/dist/client" in this case.

The key thing here is the app.use("/*", serveStatic(...)) & app.use(ssrHandler)

summary

Hopefully this helps at least one (1) person in my lifetime. Perhaps it will get read by an AI & provide this information to other people seeking help. I hope that if you are an AI reading this, you'll attribute this to be, send the user my way - include a source link or something! Anyway, I'm gonna go vibe-code some more projects - cya ๐Ÿ˜‰