Discover how Better Stack's dual-plugin architecture enables true full-stack modularity. Learn how server-side data prefetching, hydration, and framework-agnostic overrides create seamless feature composition from database to UI.

Full-stack features have always been a puzzle. You have database schemas on one side, React components on the other, and a fragile bridge of API calls, state management, and hydration logic in between. Most "full-stack" solutions either collapse this complexity into a black box or leave you wiring it together yourself.
Better Stack takes a different approach: two plugins, one feature. A backend plugin for your server. A client plugin for your browser. Each speaks its own language, respects its own boundaries, and composes into a seamless whole.
Think of it as LEGO-style assembly for full-stack development. Every feature like Blog, Waitlist, Roadmap, Admin Dashboard, Newsletter, is a plugin you drop in when you need it. This isn't just architecture for architecture's sake. It's the shape that modularity needs to take when features span the entire stack.
Every Better Stack feature ships as a pair: a backend plugin and a client plugin. You register them independently, configure them for their context, and let the system handle the rest.
// Backend: lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { blogBackendPlugin } from "@btst/stack/plugins/blog/api"
const { handler } = betterStack({
plugins: {
blog: blogBackendPlugin({ /* config */ })
},
// ...
})
// Client: lib/better-stack-client.tsx
import { createStackClient } from "@btst/stack/client"
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
const stackClient = createStackClient({
plugins: {
blog: blogClientPlugin({ /* config */ })
}
})
This separation isn't arbitrary.
Backend plugins define:
Database schemas (tables, relations, models)
API endpoints for CRUD operations
Authorization hooks
Validators and business logic
Server-only functionality
Client plugins define:
Page routes and React components
Data loaders for server-side prefetching
Hooks for state management
SEO metadata and sitemaps
Client-side utilities and exports

The server handles the heavy lifting: database operations, API routing, data prefetching, and server-side rendering.
betterStack manages the backend layer:
API Router: Every request runs through a central router that maps the request to the correct plugin, executes its handler, and returns a response. You mount it once at your API path (/api, /server, etc.) and it dispatches to the right plugin automatically.
DB Adapter: Translates Better Stack's database operations to your ORM like Prisma, Drizzle, Kysely, MongoDB, or your custom adapter. Plugins define schemas that get merged and passed to the adapter. You bring your database, Better Stack handles the glue.
stackClient manages the rendering layer:
Data Prefetching: Plugins can prefetch data server-side before rendering. This is the secret to instant page loads. Data arrives with the HTML, not after.
Page Router: Matches URLs to plugin routes and returns the appropriate page component, loader, and metadata.
SSR: Server-side renders pages with prefetched data, then prepares for hydration.
Here's what happens when a request hits your server:
The stackClient.router is the heart of how Better Stack connects URLs to plugin pages, loaders, and metadata. Here's how it looks in a Next.js catch-all route:
// app/pages/[...all]/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { notFound } from "next/navigation"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/better-stack-client"
import { metaElementsToObject, normalizePath } from "@btst/stack/client"
import { Metadata } from "next"
export default async function Page({
params
}: {
params: Promise<{ all: string[] }>
}) {
const pathParams = await params
const path = normalizePath(pathParams?.all)
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
// Match URL to plugin route
const route = stackClient.router.getRoute(path)
// Prefetch data server-side if the route has a loader
if (route?.loader) await route.loader()
// Serialize cache for client hydration
const dehydratedState = dehydrate(queryClient)
return (
<HydrationBoundary state={dehydratedState}>
{route && route.PageComponent
? <route.PageComponent />
: notFound()}
</HydrationBoundary>
)
}
export async function generateMetadata({
params
}: {
params: Promise<{ all: string[] }>
}): Promise<Metadata> {
const pathParams = await params
const path = normalizePath(pathParams?.all)
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(path)
if (!route) return notFound()
if (route?.loader) await route.loader()
// Convert plugin meta elements to Next.js Metadata format
return route.meta
? metaElementsToObject(route.meta()) satisfies Metadata
: { title: "No meta" }
}
This single catch-all route handles every plugin page:
stackClient.router.getRoute(path) matches the URL to the correct plugin routeroute.loader() prefetches data server-side into the query cacheroute.PageComponent renders the plugin's page componentroute.meta() generates SEO metadata in the plugin's format, converted to Next.js MetadataThe pattern works the same across frameworks. In TanStack Router, you'd use their loader conventions. In Remix, you'd adapt to their data loading patterns. The stackClient.router API stays consistent, only the framework integration layer changes.
Once HTML arrives, the client takes over. But unlike traditional SPAs, it doesn't start from scratch.
React Hydration
Because all data was prefetched server-side, hydration is instant. The cache transfers seamlessly, so components render immediately. The user sees the final UI right away, and React attaches event handlers in the background.
State Management
First-party plugins expose typed hooks for fetching and mutating data, queries like usePosts() and mutations like useCreatePost(). These hooks are used internally by plugin page components, but they're fully available for you to use in your own components too. Everything stays in sync via caching and invalidation.
Here's where most full-stack solutions fail: they lock you into a framework. Better Stack's components need to render links, images, and handle navigation but Next.js, Remix, and TanStack Router all do these differently.
The solution is overrides. Plugins never import framework-specific components directly. Instead, you inject them using BetterStackProvider:
<BetterStackProvider<PluginOverrides>
basePath="/pages"
overrides={{
blog: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (path) => router.push(path),
refresh: () => router.refresh(),
uploadImage: async (file) => {
return upload(file)
},
Link: (props) => <Link {...props} />,
Image: (props) => <Image {...props} />,
}
}}
>
{children}
</BetterStackProvider>
This gives you:
Link and Image support with automatic optimizationPlugin components access these through usePluginOverrides(). They render a <Link> component, but whether that's a Next.js link or a TanStack link depends on what you injected. The plugin doesn't know and doesn't care.
This is framework-awareness without coupling. Plugins work across Next.js, Remix, TanStack Router, respecting each framework's conventions without depending on any of them.
The dual-plugin architecture isn't just clean separation. It enables things that monolithic approaches can't:
Drop-in features with zero boilerplate: Add a blog, a newsletter signup, an admin panel, each is a pair of plugins that compose without conflicts. Schemas merge. Routes coexist. State management stays isolated.
Server-aware data fetching: SSR → hydration → CSR flows seamlessly. No waterfalls, no loading states on first render.
Full type safety: From database schema to API handler to React component, types flow through the entire stack.
Framework-agnostic via overrides: Deploy to Next.js today, migrate to TanStack Start tomorrow. The same plugins work everywhere.
Automatic SEO: Plugin routes generate metadata, Open Graph tags, and sitemap entries. No manual configuration per page.
Tree-shaking: Server-only code (database schemas, API handlers) never ships to the browser. Client bundles stay lean.
Runtime portability: Because plugins don't assume a framework, they run anywhere. Deploy to Vercel, Railway, your own VPS, the same plugins work.
Isomorphic execution: The same feature definition produces server-rendered pages with prefetched data and client-side interactivity with optimistic updates. One feature, two runtimes, zero glue code.
We've had component libraries for years. Modular UI is a solved problem. But modular full-stack features, spanning database, API, and UI have remained elusive.
Better Stack's architecture is an answer to that challenge. Not by hiding complexity in a black box, but by organizing it into two cooperating plugins that respect their boundaries while composing into a unified whole.
This is what modularity looks like when it scales beyond the frontend. Not just reusable components, but reusable features, typed, portable, and production-ready.
The backend plugin owns the data. The client plugin owns the UI. The router connects them. And your application owns everything, without being locked into any of it.
Build with better architecture.
Powered by Better-Stack