Better Stack logoBetter Stack
BlogGitHubDocs
Better Stack logoBetter Stack

Full-stack features as npm packages. Install a plugin, get routes, APIs, schemas, and UI.

Plugins

  • AI Chat
  • Blog
  • CMS
  • Form Builder
  • OpenAPI

Resources

  • Documentation
  • All Plugins
  • Blog
  • GitHub↗

Get Started

Ready to ship faster? Install the package and add your first plugin in under 5 minutes.

npm i @btst/stack
Read the docs

© 2026 Better Stack. Open source under MIT License.

Built by @olliethedev
November 28, 2025ArchitectureWeb Development

The Architecture of Full-Stack Plugins: How Better Stack Bridges Server and Client

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.

The Architecture of Full-Stack Plugins: How Better Stack Bridges Server and Client

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.

The Two-Sided Coin#

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.

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
// 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 */ })
  },
  // ...
})
TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
// 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


1.00

Server Side: Where Data Lives#

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.

The SSR Flow#

Here's what happens when a request hits your server:

  1. Better Stack's router finds the plugin page for the URL
  2. Runs the loader function to prefetch data
  3. Renders HTML with fully hydrated state
  4. Sends it to the client with no loading states

Routing: Where It All Comes Together#

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:

TSX
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
  42. 42
  43. 43
  44. 44
  45. 45
  46. 46
  47. 47
  48. 48
  49. 49
  50. 50
  51. 51
  52. 52
  53. 53
  54. 54
  55. 55
  56. 56
  57. 57
  58. 58
// 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:

  1. stackClient.router.getRoute(path) matches the URL to the correct plugin route
  2. route.loader() prefetches data server-side into the query cache
  3. route.PageComponent renders the plugin's page component
  4. route.meta() generates SEO metadata in the plugin's format, converted to Next.js Metadata

The 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.

Client Side: Where Interactivity Lives#

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.

Framework Specific Overrides#

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:

TSX
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
<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:

  • Next.js Link and Image support with automatic optimization
  • TanStack Router navigation
  • Remix or Vite routing freedom
  • Custom framework compatibility

Plugin 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.

Why This Architecture Matters#

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.

The Shape of Full-Stack Modularity#

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.

In This Post

The Two-Sided CoinServer Side: Where Data LivesThe SSR FlowRouting: Where It All Comes TogetherClient Side: Where Interactivity LivesFramework Specific OverridesWhy This Architecture MattersThe Shape of Full-Stack Modularity