BETTER-STACK

Installation

Learn how to install and configure Better Stack in your project.

Prerequisites

In order to use Better Stack, your application must meet the following requirements:

  • shadcn/ui installed with CSS variables enabled - Plugins use shadcn/ui components. To verify CSS variables are enabled, check that your components.json has "cssVariables": true or your Tailwind config uses CSS variables for colors.
  • Sonner <Toaster /> component configured for toast notifications
  • TailwindCSS v4 set up and configured correctly - Plugins use Tailwind classes and utilities
  • @tanstack/react-query installed - Required for server-side prefetching and client-side data fetching/state management

Install the Package

Let's start by adding Better Stack to your project:

npm install @btst/stack @tanstack/react-query
pnpm add @btst/stack @tanstack/react-query
yarn add @btst/stack @tanstack/react-query

Better Stack plugins require @tanstack/react-query for server-side prefetching and client-side data fetching and state management.

Install Database Adapter

Better Stack requires a database adapter to work with your database. Choose one based on your setup:

For Prisma ORM:

npm install @btst/adapter-prisma
pnpm add @btst/adapter-prisma
yarn add @btst/adapter-prisma

For Drizzle ORM:

npm install @btst/adapter-drizzle
pnpm add @btst/adapter-drizzle
yarn add @btst/adapter-drizzle

For Kysely query builder:

npm install @btst/adapter-kysely
pnpm add @btst/adapter-kysely
yarn add @btst/adapter-kysely

For MongoDB:

npm install @btst/adapter-mongodb
pnpm add @btst/adapter-mongodb
yarn add @btst/adapter-mongodb

For development and testing, use the in-memory adapter:

npm install @btst/adapter-memory
pnpm add @btst/adapter-memory
yarn add @btst/adapter-memory

Create Backend Instance

Create a file named better-stack.ts in your lib/ folder to configure the backend API:

lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { createPrismaAdapter } from "@btst/adapter-prisma"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

const { handler, dbSchema } = betterStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createPrismaAdapter(prisma, db, { 
    provider: "postgresql" // or "mysql", "sqlite", "cockroachdb", "mongodb"
  })
})

export { handler, dbSchema }
lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { createDrizzleAdapter } from "@btst/adapter-drizzle"
import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc.
import postgres from "postgres"

const client = postgres(process.env.DATABASE_URL!)
const drizzleDb = drizzle(client)

const { handler, dbSchema } = betterStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createDrizzleAdapter(drizzleDb, db, {})
})

export { handler, dbSchema }
lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { createKyselyAdapter } from "@btst/adapter-kysely"
import { Kysely, PostgresDialect } from "kysely"
import { Pool } from "pg"

const kyselyDb = new Kysely({
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL })
  })
})

const { handler, dbSchema } = betterStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createKyselyAdapter(kyselyDb, db, {})
})

export { handler, dbSchema }
lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { createMongodbAdapter } from "@btst/adapter-mongodb"
import { MongoClient } from "mongodb"

const client = new MongoClient(process.env.MONGODB_URI!)
const mongoDb = client.db()

const { handler, dbSchema } = betterStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
    // blog: blogBackendPlugin()
  },
  adapter: (db) => createMongodbAdapter(mongoDb, db, {})
})

export { handler, dbSchema }
lib/better-stack.ts
// IMPORTANT: Memory adapter is used for development and testing only
import { betterStack } from "@btst/stack"
import { createMemoryAdapter } from "@btst/adapter-memory"

const { handler, dbSchema } = betterStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createMemoryAdapter(db, {})
})

export { handler, dbSchema }

What happens here:

  • betterStack() collects all plugin database schemas and merges them into a unified dbSchema
  • The basePath determines where your API is mounted (e.g., /api/data/*)
  • The adapter function receives this merged schema (db) and returns an adapter that translates Better Stack's database operations to your ORM
  • The handler is a request handler function (request: Request) => Promise<Response> that processes all API calls

Now you can generate database schema using the CLI (not needed for mongodb):

npx @btst/cli generate --config=lib/better-stack.ts --orm=prisma --output=schema.prisma
npx @btst/cli generate --config=lib/better-stack.ts --orm=drizzle --output=src/db/schema.ts

Kysely requires a database connection for introspection:

Using DATABASE_URL environment variable:

DATABASE_URL=sqlite:./dev.db npx @btst/cli generate --config=lib/better-stack.ts --orm=kysely --output=migrations/schema.sql

Or using --database-url flag:

npx @btst/cli generate --config=lib/better-stack.ts --orm=kysely --output=migrations/schema.sql --database-url=sqlite:./dev.db
npx @btst/cli generate --config=lib/better-stack.ts --orm=kysely --output=migrations/schema.sql --database-url=postgres://user:pass@localhost:5432/db

See the CLI documentation to learn more about generating database schemas and migrations.

Create API Route

Create a catch-all API route to handle Better Stack requests. The route will handle requests for the path /api/data/*. If you use a different path make sure to update the basePath in the betterStack config to match your chosen path.

app/api/data/[[...all]]/route.ts
import { handler } from "@/lib/better-stack"

export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
app/routes/api/data/route.ts
import { handler } from "~/lib/better-stack"
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node"

export async function loader({ request }: LoaderFunctionArgs) {
  return handler(request)
}

export async function action({ request }: ActionFunctionArgs) {
  return handler(request)
}
src/routes/api/data/$.ts
import { createFileRoute } from '@tanstack/react-router'
import { handler } from '@/lib/better-stack'

export const Route = createFileRoute('/api/data/$')({
  server: {
    handlers: {
      GET: async ({ request }) => {
        return handler(request)
      },
      POST: async ({ request }) => {
        return handler(request)
      },
      PUT: async ({ request }) => {
        return handler(request)
      },
      DELETE: async ({ request }) => {
        return handler(request)
      },
    },
  },
})

For standalone Node.js servers (Express, Fastify, etc.), use toNodeHandler to convert the Web API handler to a Node.js-compatible handler:

server.ts
import express from "express"
import { handler } from "./lib/better-stack"
import { toNodeHandler } from "@btst/stack/api"

const app = express()

// Convert Web API handler to Node.js handler
const nodeHandler = toNodeHandler(handler)

// Mount at your basePath
app.use("/api/data", nodeHandler)

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000")
})

Alternative: Using with Express middleware

server.ts
import express from "express"
import { handler } from "./lib/better-stack"
import { toNodeHandler } from "@btst/stack/api"

const app = express()
app.use(express.json()) // Parse JSON bodies

// Convert and mount Better Stack handler
app.all("/api/data/*", toNodeHandler(handler))

app.listen(3000)

Import Plugin Styles

Plugins use TailwindCSS v4, so you should add the following @import to your global css file to ensure proper styling:

app/globals.css
@import "@btst/stack/plugins/blog/css";

Each plugin may require its own CSS import. The import path follows the pattern @btst/stack/plugins/{plugin-name}/css. Check the plugin documentation for specific requirements.

Create Client Instance

Create a client instance that routes requests to plugin pages, prefetches their data on the server, and renders them with instant hydration on the client:

lib/better-stack-client.tsx
import { createStackClient } from "@btst/stack/client"
import { QueryClient } from "@tanstack/react-query"

export const getStackClient = (queryClient: QueryClient) => {
  return createStackClient({
    plugins: {
      // Add your client plugins here
    }
  })
}

Why a function? getStackClient takes a QueryClient because different contexts use different instances:

  • Server (SSR): Each request gets its own QueryClient (or cached per-request)
  • Client: A singleton QueryClient is shared across navigations
  • Additional options: You can pass additional options to the createStackClient function, such as headers for SSR authentication if plugins expose lifecycle hooks.

This pattern allows you to pass the appropriate QueryClient and other options for each context.

Set Up Query Client Provider

If you don't already have a query client utility, create one to ensure proper SSR hydration:

lib/query-client.ts
import { QueryClient, isServer } from "@tanstack/react-query"
import { cache } from "react"

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: isServer ? 60 * 1000 : 0,
        refetchOnMount: false,
        refetchOnWindowFocus: false,
        retry: false
      },
      dehydrate: {
        // Include both successful and error states to avoid refetching on the client
        // This prevents loading states when there's an error in prefetched data
        shouldDehydrateQuery: (query) => {
            return true
        }
      }
    }
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getOrCreateQueryClient() {
    if (isServer) {
        // Server: always make a new query client
        return makeQueryClient();
    } else {
        // Browser: make a new query client if we don't already have one
        // This is very important, so we don't re-make a new client if React
        // suspends during the initial render. This may not be needed if we
        // have a suspense boundary BELOW the creation of the query client
        if (!browserQueryClient) browserQueryClient = makeQueryClient();
        return browserQueryClient;
    }
}

Then configure QueryClientProvider in your your app:

app/layout.tsx
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"

export default function RootLayout({ children }) {
  const queryClient = getOrCreateQueryClient()
  
  return (
    <html>
      <body>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
      </body>
    </html>
  )
}
app/root.tsx
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "~/lib/query-client"
import { Outlet } from "react-router"

export default function App() {
  const queryClient = getOrCreateQueryClient()
  
  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}
src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { getOrCreateQueryClient } from '@/lib/query-client'

export interface MyRouterContext {
  queryClient: QueryClient
}

export function getRouter() {
  const queryClient = getOrCreateQueryClient()
  
  const router = createRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreload: false,
    context: {
      queryClient,
    },
    notFoundMode: "root",
  })

  setupRouterSsrQueryIntegration({
    router,
    queryClient,
  })

  return router
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof getRouter>
  }
}

The getOrCreateQueryClient() utility ensures:

  • Server: Each request gets its own QueryClient
  • Client: A singleton QueryClient prevents recreation during React Suspense
  • Hydration: Server-prefetched data seamlessly transfers to the client

Note: QueryClient might have to be configured differently in your framework of choice. See Example Projects or TanStack Query docs for more details.

Set Up Layout Provider

Wrap your Better Stack pages with the BetterStackProvider to enable framework-specific overrides:

app/pages/[[...all]]/layout.tsx
import { BetterStackProvider } from "@btst/stack/context"
import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"
import Link from "next/link"
import Image from "next/image"
import { useRouter } from "next/navigation"

// Define the shape of all plugin overrides for type safety
type PluginOverrides = {
  example: ExamplePluginOverrides
  // Add other plugins here
}

export default function Layout({ children }) {
  const router = useRouter()
  
  return (
    <BetterStackProvider<PluginOverrides>
      basePath="/pages"
      overrides={{
        example: {
          Link: (props) => <Link {...props} />,
          Image: (props) => <Image {...props} />,
          navigate: (path) => router.push(path),
          // Add other plugin overrides here
        }
        // Add other plugins here
      }}
    >
      {children}
    </BetterStackProvider>
  )
}
app/routes/pages/_layout.tsx
import { Outlet, Link, useNavigate } from "react-router"
import { BetterStackProvider } from "@btst/stack/context"
import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"

// Define the shape of all plugin overrides
type PluginOverrides = {
  example: ExamplePluginOverrides
  // Add other plugins here
}

export default function Layout() {
  const navigate = useNavigate()
  
  return (
    <BetterStackProvider<PluginOverrides>
      basePath="/pages"
      overrides={{
        example: {
          navigate: (href) => navigate(href),
          Link: ({ href, children, className, ...props }) => (
            <Link to={href || ""} className={className} {...props}>
              {children}
            </Link>
          )
          // Add other plugin overrides here
        }
        // Add other plugins here
      }}
    >
      <Outlet />
    </BetterStackProvider>
  )
}
src/routes/pages/route.tsx
import { BetterStackProvider } from "@btst/stack/context"
import { QueryClientProvider } from "@tanstack/react-query"
import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"
import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router"

// Define the shape of all plugin overrides
type PluginOverrides = {
  example: ExamplePluginOverrides
  // Add other plugins here
}

export const Route = createFileRoute('/pages')({
  component: Layout
})

function Layout() {
  const router = useRouter()
  const context = Route.useRouteContext()

  return (
    <QueryClientProvider client={context.queryClient}>
      <BetterStackProvider<PluginOverrides>
        basePath="/pages"
        overrides={{
          example: {
            navigate: (href) => router.navigate({ href }),
            Link: ({ href, children, className, ...props }) => (
              <Link to={href} className={className} {...props}>
                {children}
              </Link>
            )
            // Add other plugin overrides here
          }
          // Add other plugins here
        }}
      >
        <Outlet />
      </BetterStackProvider>
    </QueryClientProvider>
  )
}

Understanding Overrides:

  • Purpose: Injects framework-specific components via React Context. Plugin components access these overrides through usePluginOverrides() hook, allowing them to use your framework's Link, Image, and navigation without tight coupling and to avoid breaking the client/server boundary in frameworks like Next.js.
  • Type Safety: Each plugin exports its override type (e.g., ExamplePluginOverrides)

Set Up Page Handler

Create a catch-all route to handle Better Stack pages defined in your plugins. This enables server-side rendering, metadata generation and automatic route handling.

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)
  const route = stackClient.router.getRoute(path)
  
  // Prefetch data server-side if the route has a loader
  if (route?.loader) await route.loader()
  
  // Serialize React Query 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[] }> }) {
  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" }
}
app/routes/pages/index.tsx
import type { Route } from "./+types/index"
import { useLoaderData } from "react-router"
import { dehydrate, HydrationBoundary, QueryClient, useQueryClient } from "@tanstack/react-query"
import { getStackClient } from "~/lib/better-stack-client"
import { normalizePath } from "@btst/stack/client"

export async function loader({ params }: Route.LoaderArgs) {
  const path = normalizePath(params["*"])
  
  // Create QueryClient for this request with consistent config
  const queryClient = new QueryClient({
    defaultOptions: { queries: { staleTime: 1000 * 60 * 5, refetchOnMount: false, retry: false } }
  })
  const stackClient = getStackClient(queryClient)
  const route = stackClient.router.getRoute(path)
  
  if (route?.loader) await route.loader()
  
  // Include errors so client doesn't refetch on error
  const dehydratedState = dehydrate(queryClient)
  
  return { path, dehydratedState, meta: route?.meta?.() }
}

export function meta({ loaderData }: Route.MetaArgs) {
  return loaderData.meta
}

export default function PagesIndex() {
  const { path, dehydratedState } = useLoaderData<typeof loader>()
  const queryClient = useQueryClient()
  const route = getStackClient(queryClient).router.getRoute(path)
  const Page = route && route.PageComponent ? <route.PageComponent /> : <div>Route not found</div>
  
  return dehydratedState ? (
    <HydrationBoundary state={dehydratedState}>{Page}</HydrationBoundary>
  ) : Page
}
src/routes/pages/$.tsx
import { createFileRoute, notFound } from "@tanstack/react-router"
import { getStackClient } from "@/lib/better-stack-client"
import { normalizePath } from "@btst/stack/client"

export const Route = createFileRoute("/pages/$")({
  ssr: true,
  component: Page,
  loader: async ({ params, context }) => {
    const routePath = normalizePath(params._splat)
    const stackClient = getStackClient(context.queryClient)
    const route = stackClient.router.getRoute(routePath)
    
    if (!route) throw notFound()
    if (route?.loader) await route.loader()
    
    return { meta: await route?.meta?.() }
  },
  head: ({ loaderData }) => {
    return loaderData?.meta && Array.isArray(loaderData.meta) 
      ? { meta: loaderData.meta } 
      : { meta: [{ title: "No Meta" }], title: "No Meta" }
  },
  notFoundComponent: () => <p>This page doesn't exist!</p>
})

function Page() {
  const context = Route.useRouteContext()
  const { _splat } = Route.useParams()
  const routePath = normalizePath(_splat)
  const route = getStackClient(context.queryClient).router.getRoute(routePath)
  
  return route && route.PageComponent ? <route.PageComponent /> : <div>Route not found</div>
}

How it works:

stackClient.router.getRoute(path) matches the URL to a plugin route and returns a route object:

route = {
  PageComponent: React.ComponentType,     // The page to render
  loader?: () => Promise<void>,           // Prefetches React Query data
  meta?: () => MetadataElements,          // Returns SEO metadata
  ErrorComponent?: React.ComponentType,   // Standalone error components
  LoadingComponent?: React.ComponentType  // Standalone loading components
}

Key steps:

  • Server-side data loading: Call route.loader() before rendering to prefetch data into React Query cache
  • Hydration: Use dehydrate() to serialize prefetched data for the client (not required with TanStack Start)
  • Error handling: Configure your query client with shouldDehydrateQuery to include failed queries in dehydration, preventing client-side refetching on errors
  • Metadata generation: Use route.meta() with framework-specific meta functions for SEO
  • 404 handling: Return notFound() or your framework's equivalent function when routes don't exist

Set Up Sitemap Generation (Optional)

Create a sitemap route to enable automatic sitemap generation for SEO. The library automatically collects URLs from all registered plugins.

How it works: Each plugin can export a sitemap() function that returns URLs with metadata (lastModified, changeFrequency, priority). The generateSitemap() method aggregates and deduplicates entries from all plugins.

app/sitemap.ts
import type { MetadataRoute } from "next"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "@/lib/better-stack-client"

export const dynamic = "force-dynamic"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const queryClient = new QueryClient()
  const stackClient = getStackClient(queryClient)
  return stackClient.generateSitemap()
}
app/routes/sitemap.xml.ts
import type { Route } from "./+types/sitemap.xml"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "~/lib/better-stack-client"
import { sitemapEntryToXmlString } from "@btst/stack/client"

export async function loader({}: Route.LoaderArgs) {
  const queryClient = new QueryClient()
  const stackClient = getStackClient(queryClient)
  const entries = await stackClient.generateSitemap()
  const xml = sitemapEntryToXmlString(entries)

  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
    },
  })
}
src/routes/sitemap[.]xml.ts
// Note: [.] syntax in TanStack Router creates a route for "sitemap.xml"
import { createFileRoute } from "@tanstack/react-router"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "@/lib/better-stack-client"
import { sitemapEntryToXmlString } from "@btst/stack/client"

export const Route = createFileRoute("/sitemap.xml")({
  server: {
    handlers: {
      GET: async () => {
        const queryClient = new QueryClient()
        const stackClient = getStackClient(queryClient)
        const entries = await stackClient.generateSitemap()
        const xml = sitemapEntryToXmlString(entries)

        return new Response(xml, {
          headers: {
            "Content-Type": "application/xml; charset=utf-8",
            "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
          },
        })
      },
    },
  },
})

The generateSitemap() method automatically collects URLs from all registered plugins. Each plugin can contribute its own routes to the sitemap with appropriate metadata like priority and change frequency. This step is optional but recommended for SEO.

🎉 That's it!

Your setup is complete! Here's what you've configured:

  • ✅ Backend API handler that processes all plugin requests
  • ✅ Database adapter that connects plugins to your database
  • ✅ Client-side router with SSR support
  • ✅ React Query integration for data fetching
  • ✅ Framework-specific overrides

Next steps:

  1. Add plugins to both backend and client configurations:

    • Backend: plugins: { blog: blogBackendPlugin() }
    • Client: plugins: { blog: blogClientPlugin() }
  2. Visit your pages at /pages/* to see plugin routes in action

Available plugins:

  • @btst/stack/plugins/blog - Full-featured blog with markdown editor, SEO, and RSS. Learn more about the blog plugin here.
  • More plugins coming soon!

Each plugin provides everything you need: routes, API endpoints, database schemas, React components, and hooks - all working together seamlessly.

Example Projects

See complete working examples for each framework:

Each example includes complete configuration, plugin setup, and demonstrates framework-specific patterns.