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.jsonhas"cssVariables": trueor 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-querypnpm add @btst/stack @tanstack/react-queryyarn add @btst/stack @tanstack/react-queryBetter 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-prismapnpm add @btst/adapter-prismayarn add @btst/adapter-prismaFor Drizzle ORM:
npm install @btst/adapter-drizzlepnpm add @btst/adapter-drizzleyarn add @btst/adapter-drizzleFor Kysely query builder:
npm install @btst/adapter-kyselypnpm add @btst/adapter-kyselyyarn add @btst/adapter-kyselyFor MongoDB:
npm install @btst/adapter-mongodbpnpm add @btst/adapter-mongodbyarn add @btst/adapter-mongodbFor development and testing, use the in-memory adapter:
npm install @btst/adapter-memorypnpm add @btst/adapter-memoryyarn add @btst/adapter-memoryCreate Backend Instance
Create a file named better-stack.ts in your lib/ folder to configure the backend API:
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 }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 }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 }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 }// 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 unifieddbSchema- The
basePathdetermines where your API is mounted (e.g.,/api/data/*) - The
adapterfunction receives this merged schema (db) and returns an adapter that translates Better Stack's database operations to your ORM - The
handleris 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.prismanpx @btst/cli generate --config=lib/better-stack.ts --orm=drizzle --output=src/db/schema.tsKysely 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.sqlOr using --database-url flag:
npx @btst/cli generate --config=lib/better-stack.ts --orm=kysely --output=migrations/schema.sql --database-url=sqlite:./dev.dbnpx @btst/cli generate --config=lib/better-stack.ts --orm=kysely --output=migrations/schema.sql --database-url=postgres://user:pass@localhost:5432/dbSee 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.
import { handler } from "@/lib/better-stack"
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handlerimport { 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)
}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:
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
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:
@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:
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
createStackClientfunction, such asheadersfor 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:
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:
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>
)
}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>
)
}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:
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>
)
}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>
)
}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'sLink,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.
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" }
}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
}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
shouldDehydrateQueryto 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.
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()
}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",
},
})
}// 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:
-
Add plugins to both backend and client configurations:
- Backend:
plugins: { blog: blogBackendPlugin() } - Client:
plugins: { blog: blogClientPlugin() }
- Backend:
-
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:
- Next.js Example - Full Next.js App Router setup with blog and todo plugins
- React Router Example - React Router v7 setup with SSR support
- TanStack Start Example - TanStack Router setup with file-based routing
Each example includes complete configuration, plugin setup, and demonstrates framework-specific patterns.