While Artemis II is busy taking astronauts around the Moon right now, I’ve been on my own mission — except my spacecraft is called Astro and the payload is full-stack web apps. For the last year or so, every time I sat down to start a new app, I ended up reaching for it. Not because I was building a blog. Not because the project needed perfect Lighthouse scores. But because Astro turned out to be the most coherent way I know to ship a small-to-mid-sized full-stack app in 2026.
That’s a little ironic, because Astro doesn’t really sell itself that way. Open the homepage and you’ll see talk of content-driven websites, marketing pages, documentation, and blogs. All of which is true — Astro is genuinely great at all of those.
But when I look back at the apps I’ve actually built and shipped lately — admin panels, dashboards, customer-facing forms, things with logins and databases and background work behind them — they’re all Astro apps. The “static site framework” label sells it short. Most of them also run on Bun, and that turns out to matter more than I expected — but we’ll get to that.
This post is a tour through the stack I keep landing on when I use Astro as an application framework, and the libraries that fill the gaps Astro deliberately leaves open.
Flipping the Switch: output: 'server'
The first thing that has to change is the rendering mode. The default is static — beautiful for blogs, less helpful for apps that need per-request data. For real applications I flip three knobs in astro.config.mjs:
output: 'server'@astrojs/nodeinstandalonemodesecurity.checkOrigin: true
import { defineConfig } from 'astro/config'import node from '@astrojs/node'import vue from '@astrojs/vue'
export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), integrations: [vue()], security: { checkOrigin: true, },})A few things this gets you:
- A real long-running Node process you can run in any container.
- No “edge function” boundaries to worry about — your handlers are just functions in a Node server.
checkOriginadds built-in CSRF protection for SSR pages by rejecting cross-origin form-style writes by default, which is exactly what you want for a session-cookie app.
Once output is server, Astro stops behaving like a static site generator by default and starts behaving more like an HTTP framework. Pages can run on every request, endpoints can talk to a database, and middleware can intercept anything.
One nice side-effect: there is no special “Astro on Bun” mode. In practice, the standalone Node adapter also runs on Bun thanks to Bun’s Node compatibility layer. You build with astro build, you run with bun ./dist/server/entry.mjs, and that’s the entire bridge. The same runtime that hosts the server also gives you a pile of native APIs you can reach for from inside any route or middleware — which is where the rest of this post starts to get interesting.
☝️ Good To Know: You can still mark individual pages or endpoints with
prerender = true. The default flips, but the option is right there when you
want a marketing page or an /about route to be plain HTML.
The Middleware Is Where the App Actually Lives
If you take one thing from this post, take this: in an Astro app, src/middleware.ts is the spine.
Almost every cross-cutting concern lands there: session resolution, auth gating, rate limiting, CSP headers, origin checks, error normalisation. Once you stop thinking of middleware as “the redirect file” and start treating it as the entry point for every request, things get a lot easier.
A typical shape looks like this:
import { defineMiddleware } from 'astro:middleware'import { auth } from '@/lib/server/auth'
const PUBLIC_PATHS = ['/login', '/api/auth', '/healthz']
export const onRequest = defineMiddleware(async (context, next) => { const session = await auth.api.getSession({ headers: context.request.headers, })
context.locals.session = session ?? null context.locals.user = session?.user ?? null
const { pathname } = context.url const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
if (!isPublic && !session) { if (pathname.startsWith('/api/')) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'content-type': 'application/json' }, }) } return context.redirect('/login') }
return next()})The full version usually has a few more layers — per-route CSP profiles (admin areas get a stricter Content-Security-Policy than public pages), rate-limit checks via Bun.redis, an error-handling wrapper that converts thrown exceptions into JSON for /api/* and into a friendly error page for everything else. But the heart of it is always the same: read the session, attach it to context.locals, decide who’s allowed past.
☝️ Good To Know: Type context.locals once in src/env.d.ts (declare the
App.Locals interface) and the rest of your codebase gets autocompletion on
Astro.locals.user for free.
API Routes as a First-Class Backend
Astro’s file-based routing extends to .ts endpoints. Drop a file in src/pages/api/notes.ts with a POST export and you have a handler. No extra adapter, no Express, no Hono — just functions that return Response.
The pattern I keep using inside those handlers is the same on every project:
- Validate the input with Zod (
safeParse, neverparse). - Do the work against the database, in a transaction if anything writes.
- Return a typed JSON response with a consistent shape.
import type { APIRoute } from 'astro'import { z } from 'zod'
import { db } from '@/lib/server/db'import { notes } from '@/lib/server/schema'
const CreateNoteSchema = z.object({ title: z.string().min(1).max(120), body: z.string().max(10_000),})
export const POST: APIRoute = async ({ request, locals }) => { const json = await request.json() const parsed = CreateNoteSchema.safeParse(json)
if (!parsed.success) { return Response.json( { error: 'Invalid input', issues: parsed.error.issues }, { status: 400 }, ) }
const [created] = await db .insert(notes) .values({ ...parsed.data, userId: locals.user!.id }) .returning()
return Response.json({ data: created }, { status: 201 })}Error responses always look like { error: string }. Successful ones always look like { data: T }. Status codes do the heavy lifting: 400 for bad input, 401 for unauthenticated, 403 for forbidden, 429 for rate-limited, 500 for everything else. There’s no clever wrapper library in play here — just a convention that’s easy to write and easy to read on the client.
For race-sensitive writes — think redeeming a code or decrementing a counter — I lean on Drizzle’s transaction API and let the database do the locking. No queue, no Redis dance, just a plain old SQL transaction.
A Type-Safe Database Layer with Drizzle
For the database itself, my default is Drizzle ORM on top of Postgres, talking to it via Bun.sql. For smaller things that fit on a single box, I swap in bun:sqlite and call it a day. Both have first-class Drizzle adapters: drizzle-orm/bun-sql and drizzle-orm/bun-sqlite. No pg, no better-sqlite3, no extra native package to wrangle on bun install.
The mental model is the same either way: schema files describe the tables, drizzle-kit generates and applies migrations, and the runtime client is a tiny singleton you import everywhere.
import { drizzle } from 'drizzle-orm/bun-sql'import * as schema from './schema'
export const db = drizzle(process.env.DATABASE_URL!, { schema })export type DatabaseClient = typeof dbA schema fragment usually looks like this:
import { relations } from 'drizzle-orm'import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'import { users } from './users'
export const notes = pgTable( 'notes', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), title: text('title').notNull(), body: text('body').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => ({ userIdx: index('notes_user_idx').on(table.userId), }),)
export const notesRelations = relations(notes, ({ one }) => ({ user: one(users, { fields: [notes.userId], references: [users.id] }),}))A few things I like about this layer:
- The schema is TypeScript. Renaming a column is a
tscerror, not a runtime surprise. - Migrations are checked into the repo, which makes schema changes explicit and reviewable.
drizzle-kit studiois a really nice browser UI for poking at the database in dev — somewhere betweenpsqland a proper admin tool.
🔥 Hot Tip: Wrap the Drizzle client in a tiny src/lib/server/db.ts that
exports a singleton plus a DatabaseClient type. Imports stay clean and tests
can swap the client out trivially.
Authentication Without Rebuilding Auth
The biggest gap in the “Astro as an app framework” story used to be authentication. That’s much less true now. Better Auth has made that part of the stack far easier, and it slots into Drizzle like it was designed for it.
The core configuration is small:
import { betterAuth } from 'better-auth'import { drizzleAdapter } from 'better-auth/adapters/drizzle'import { admin, organization, passkey, twoFactor } from 'better-auth/plugins'
import { db } from './db'import * as schema from './schema/auth'
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema }), emailAndPassword: { enabled: true }, plugins: [passkey(), twoFactor(), admin(), organization()],})The plugin list is where Better Auth really earns its keep. The four I reach for on almost every app:
passkey()— WebAuthn / FIDO2, no third-party service needed.twoFactor()— TOTP, with backup codes if you want them.admin()— role gating and an admin API for managing users.organization()— multi-tenant workspaces with invitations, member roles, and a notion of an “active org” baked into the session.
Email-based flows (verification, password reset, organization invitations) wire straight into a transactional sender — I use nodemailer with whatever SMTP provider is convenient. Password hashing is done via Bun.password — argon2id, built into the runtime, with no separate argon2 or bcrypt package needed. Better Auth is flexible enough to wire that in cleanly; I walked through that exact setup in an earlier post if you want to see it spelled out.
The result is that “build login” goes from a multi-week project to a single afternoon. You still own the schema, the database, and the cookies — Better Auth just stops you from reinventing the parts you’d rather not.
End-to-End Validation with Zod
The same Zod schema that validates a request body on the server can also validate a form on the client. That’s not a small thing — it means you can keep exactly one source of truth for what a valid Note, Invitation, or User looks like.
On the client I use vee-validate with @vee-validate/zod to wire those schemas into Vue forms. Real-time inline errors, instant submit-button states, no duplicated validation logic. The server still re-validates everything — it has to, because the client is not a security boundary — but the user-facing UX gets much better without needing a second schema.
Zod also pulls double duty as my environment variable parser. A small env.ts file at the top of the server entry validates process.env against a schema and throws on boot if anything’s missing. It has saved me from a “works on my machine” deploy more than once.
A UI Layer That Scales Beyond a Landing Page
The Astro islands model is what makes all of this feel coherent. Static .astro files render most of the page. Vue islands hydrate where they’re actually needed: forms, dialogs, data tables, anything reactive. For pages that are basically “an SPA with a URL” I drop in a single root component with client:only="vue" and treat that route as a Vue app.
The Vue side of the stack is pretty consistent across projects:
- Vue 3 via
@astrojs/vuefor the islands themselves. - Reka UI for headless primitives — dialogs, popovers, comboboxes, sliders.
- shadcn-vue as the design-system layer on top of Reka.
- Lucide Vue Next for icons, Vue Sonner for toasts.
- TanStack Vue Table when I need a real data grid with pagination, sorting, and column visibility.
- Unovis for charts that need to do more than draw a line.
- Tailwind CSS 4 via the Vite plugin (not PostCSS), with
class-variance-authority,clsxandtailwind-mergefor component variants.
That’s it. There’s no global state manager — Vue 3’s composition API and @vueuse/core cover everything I’ve needed so far. There’s no router for the islands — the pages they live on already have URLs, courtesy of Astro.
Caching and Rate Limiting with Bun.redis
Once an app has a real user base, two things become non-negotiable: caching and rate limiting. For both, I lean on Bun.redis — Bun’s native Redis client, available as import { redis } from 'bun'. No driver to install, no connection-pool config, just redis.get / redis.set / redis.incr straight out of the runtime.
The pattern for caching is plain old cache-aside:
import { redis } from 'bun'
export async function cacheGetOrSet<T>( key: string, ttl: number, factory: () => Promise<T>,): Promise<T> { const hit = await redis.get(key) if (hit) return JSON.parse(hit) as T
const value = await factory() await redis.set(key, JSON.stringify(value)) await redis.expire(key, ttl) return value}Mutations invalidate keys by prefix using SCAN plus UNLINK. Same client, same handful of methods.
For rate limiting, I hash the client IP with SHA-256 to get a key, then INCR per window. Auth endpoints get aggressive limits — five to ten attempts per minute is typical. Read endpoints get generous ones. Responses usually include either the newer RateLimit-* headers or the familiar X-RateLimit-* set so the client can back off intelligently.
When Redis isn’t available — which is most of the time in local dev — both helpers fall back to an in-memory map. Same API, fewer guarantees, no friction.
Bun: The Other Half of the Stack
Notice how much of this stack is already inside Bun:
Bun.sqlfor SQL backends, including Postgres.bun:sqlitefor SQLite. Both have native Drizzle adapters.Bun.redisfor caching and rate limiting.Bun.passwordfor argon2id hashing inside Better Auth.
That’s the database driver, the cache client, and the password hasher — three things that would normally be three packages with three transitive dependency trees and three potential native-build headaches. On Bun they’re three imports and zero additional node_modules weight for those capabilities.
The reason this matters for Astro specifically is the bridge I mentioned earlier: Astro’s standalone Node adapter runs unmodified on Bun. There is no special Astro-on-Bun build target. You ship the same dist/server/entry.mjs you’d ship to Node, you start it with bun, and from inside any API route or middleware you can reach for the Bun globals as if they were part of the standard library.
The net effect on package.json is what you’d hope for: the dependency list keeps shrinking. Less to install, less to audit, less to upgrade, fewer footguns at deploy time.
🔥 Hot Tip: Even when you’re stuck on Node for a deploy target, writing the database/cache/auth code against Bun’s APIs first and treating Node as the fallback (rather than the other way around) keeps the dependency story cleaner. Bun is increasingly the path of least resistance, not the exotic choice.
Testing, Lint, and the Developer Loop
The dev loop is the unsexy part that makes or breaks a stack. Mine is:
- Vitest for unit and component tests, with happy-dom as the lightweight DOM environment.
@vue/test-utilsfor component testing, files co-located next to the source as*.test.ts.- oxlint as the linter — fast enough to run on save without thinking about it.
- Prettier with
prettier-plugin-astrofor formatting. - TypeScript in strict mode, end to end.
Nothing exotic in here. Everything plays nicely with everything else, and Vitest is quick enough that tests actually get written.
What Astro Gives You That the Others Don’t
There are plenty of full-stack frameworks. Next, Nuxt, SvelteKit, Remix — pick your poison. So why Astro?
- No RSC gymnastics. Server code is server code. Client code is client code. The boundary is a file extension or a
client:directive, not a hidden compiler decision. - Pick any UI framework, or none. Vue, React, Svelte, Solid, plain HTML — per island, per page, per project.
- Rendering can be decided per route. Some pages are static, some are server-rendered, some are interactive islands. The decision lives next to the page, not only in top-level config.
- A small mental model. Pages, endpoints, middleware, components. That’s pretty much the whole vocabulary.
Astro never asked to be an app framework. It just turned out to have all the right pieces.
Conclusion
The content-site pitch sells Astro short. With output: 'server', a decent middleware file, and a handful of well-chosen libraries, it’s a genuinely capable application framework — and one that doesn’t drag its own opinions across your codebase the way some of its larger competitors do.
If you’re a small team (or a team of one) and you want a coherent full-stack setup without adopting Next’s conventions or Nuxt’s universe, Astro in server mode is a very reasonable answer. It has been mine for a while now.
The Stack, at a Glance
- Framework: Astro (
output: 'server',@astrojs/nodestandalone) - UI: Vue 3 via
@astrojs/vue, Reka UI, shadcn-vue, Lucide Vue Next, Vue Sonner, TanStack Vue Table, Unovis - Styling: Tailwind CSS 4,
class-variance-authority,clsx,tailwind-merge - Database: Drizzle ORM via Bun-native drivers (
drizzle-orm/bun-sqlfor Postgres,drizzle-orm/bun-sqlitefor SQLite),drizzle-kitfor migrations - Auth: Better Auth with
passkey,twoFactor,admin,organizationplugins - Password hashing:
Bun.password(built-in argon2id) - Validation: Zod on the server, vee-validate +
@vee-validate/zodon the client - Email: nodemailer
- Cache & rate limit:
Bun.redis(built-in) - Testing: Vitest + happy-dom +
@vue/test-utils - Runtime & tooling: Bun (runtime + native
sql/redis/password), TypeScript, Prettier, oxlint