Plugins
Plugins are a key part of Better Query, they let you extend the base CRUD functionalities. You can use them to add new features like audit logging, caching, resource management integration, or customize resource behaviors.
Better Query comes with several built-in plugins ready to use:
- Audit Plugin: Comprehensive audit logging for all CRUD operations
- Cache Plugin: High-performance caching with configurable TTL
- Validation Plugin: Enhanced data validation beyond Zod schemas
- OpenAPI Plugin: Automatic OpenAPI documentation generation
Check the plugins section for details on each plugin. You can also create your own plugins.
Using a Plugin
Plugins can be a server-side plugin, a client-side plugin, or both.
To add a plugin on the server, include it in the plugins array in your query configuration. The plugin will initialize with the provided options.
import { betterQuery } from "better-query";
import { auditPlugin, cachePlugin } from "better-query/plugins";
export const query = betterQuery({
plugins: [
auditPlugin({
enabled: true,
operations: ["create", "update", "delete"],
}),
cachePlugin({
enabled: true,
defaultTTL: 300,
}),
]
});Client Usage
Better Query plugins work entirely on the server side. The client accesses plugin functionality through the standard API endpoints that plugins create. There are no separate client-side plugins to install.
import { createQueryClient } from "better-query/client";
const queryClient = createQueryClient({
baseURL: "http://localhost:3000/api"
});Once server plugins are configured, their endpoints become available through the client's $fetch method or through generated resource endpoints.
We recommend keeping the query-client and your normal query instance in separate files.
Built-in Plugins
Better Query provides several powerful built-in plugins to enhance your application with common functionality.
Audit Plugin
The audit plugin provides comprehensive logging of all CRUD operations with customizable event tracking.
import { auditPlugin } from "better-query/plugins";
export const query = betterQuery({
plugins: [
auditPlugin({
enabled: true,
operations: ["create", "update", "delete"], // Which operations to audit
excludeFields: ["password", "secret"], // Fields to exclude from logs
includeRequestInfo: true, // Include IP, user agent, etc.
customLogger: async (event) => {
// Custom logging logic
console.log("Audit Event:", event);
// Send to external logging service
await sendToLogService(event);
}
})
]
});Features:
- Automatic CRUD operation tracking
- Request metadata capture (IP, user agent, timestamp)
- Field-level filtering for sensitive data
- Custom logging backend integration
- Query audit trail with full change history
Cache Plugin
The cache plugin provides high-performance caching with Redis or memory backends.
import { cachePlugin } from "better-query/plugins";
export const query = betterQuery({
plugins: [
cachePlugin({
enabled: true,
backend: "redis", // or "memory"
connection: {
host: "localhost",
port: 6379,
},
defaultTTL: 300, // 5 minutes
keyPrefix: "myapp:",
operations: {
read: { ttl: 600 }, // 10 minutes for reads
list: { ttl: 120 }, // 2 minutes for lists
},
invalidationRules: {
// Invalidate user cache when user is updated
user: ["user:*"],
// Invalidate post lists when new post is created
post: ["post:list:*"]
}
})
]
});Features:
- Multiple backend support (Redis, in-memory)
- Operation-specific TTL configuration
- Smart cache invalidation rules
- Cache key customization
- Performance metrics and monitoring
Validation Plugin
Enhanced validation beyond Zod schemas with business logic validation.
import { validationPlugin } from "better-query/plugins";
export const query = betterQuery({
plugins: [
validationPlugin({
rules: {
user: {
email: [
{
validator: async (email, context) => {
const exists = await context.adapter.findFirst("user", {
where: { email }
});
return !exists || "Email already exists";
}
}
],
username: [
{
validator: (username) => {
return /^[a-zA-Z0-9_]+$/.test(username) || "Username can only contain letters, numbers, and underscores";
}
}
]
}
},
// Custom error formatting
formatError: (field, message) => ({
field,
message,
code: "VALIDATION_ERROR"
})
})
]
});Features:
- Async validation with database access
- Field-specific validation rules
- Custom error formatting
- Integration with existing Zod schemas
- Cross-field validation support
OpenAPI Plugin
Automatic OpenAPI/Swagger documentation generation for all your endpoints.
import { openApiPlugin } from "better-query/plugins";
export const query = betterQuery({
plugins: [
openApiPlugin({
info: {
title: "My API",
version: "1.0.0",
description: "Better Query API Documentation"
},
servers: [
{ url: "http://localhost:3000/api/query", description: "Development" },
{ url: "https://api.myapp.com/query", description: "Production" }
],
includeAuth: true,
outputPath: "./openapi.json", // Generate spec file
uiPath: "/docs", // Swagger UI endpoint
customTags: {
user: { description: "User management operations" },
post: { description: "Blog post operations" }
}
})
]
});Features:
- Automatic schema generation from Zod schemas
- Authentication documentation
- Interactive Swagger UI
- Custom tags and descriptions
- Export OpenAPI specifications
Creating a Plugin
To get started, you'll need a server plugin. Server plugins are the backbone of all plugins, and client plugins are there to provide an interface with frontend APIs to easily work with your server plugins.
If your server plugins has endpoints that needs to be called from the client, you'll also need to create a client plugin.
What can a plugin do?
- Create custom
endpoints to perform any action you want. - Extend database tables with custom
schemas. - Use a
middlewareto target a group of routes using it's route matcher, and run only when those routes are called through a request. - Use
hooksto target a specific route or request. And if you want to run the hook even if the endpoint is called directly. - Use
onRequestoronResponseif you want to do something that affects all requests or responses. - Create custom
rate-limitrule.
Create a Server plugin
To create a server plugin you need to pass an object that satisfies the Plugin interface.
The only required property is id, which is a unique identifier for the plugin.
Both server and client plugins can use the same id.
import type { Plugin } from "better-query";
export const myPlugin = ()=>{
return {
id: "my-plugin",
} satisfies Plugin
}You don't have to make the plugin a function, but it's recommended to do so. This way you can pass options to the plugin and it's consistent with the built-in plugins.
Endpoints
To add endpoints to the server, you can pass endpoints which requires an object with the key being any string and the value being an AuthEndpoint.
To create an Auth Endpoint you'll need to import createAuthEndpoint from better-query.
Better Query uses wraps around another library called Better Call to create endpoints. Better call is a simple ts web framework made by the same team behind Better Query.
import { createQueryEndpoint } from "better-query";
const myPlugin = ()=> {
return {
id: "my-plugin",
endpoints: {
getHelloWorld: createQueryEndpoint("/my-plugin/hello-world", {
method: "GET",
}, async(ctx) => {
return ctx.json({
message: "Hello World"
})
})
}
} satisfies BetterQueryPlugin
}Create Query endpoints wraps around createEndpoint from Better Call. Inside the ctx object, it'll provide another object called context that give you access better-query specific contexts including options, db, baseURL and more.
Context Object
appName: The name of the application. Defaults to "Better Query".options: The options passed to the Better Query instance.tables: Core tables definition. It is an object which has the table name as the key and the schema definition as the value.baseURL: the baseURL of the query server. This includes the path. For example, if the server is running onhttp://localhost:3000, the baseURL will behttp://localhost:3000/api/queryby default unless changed by the user.session: The session configuration. IncludesupdateAgeandexpiresInvalues.secret: The secret key used for various purposes. This is defined by the user.queryCookie: The default cookie configuration for core query cookies.logger: The logger instance used by Better Query.db: The Kysely instance used by Better Query to interact with the database.adapter: This is the same as db but it give youormlike functions to interact with the database. (we recommend using this overdbunless you need raw sql queries or for performance reasons)internalAdapter: These are internal db calls that are used by Better Query. For example, you can use these calls to create a resource instead of usingadapterdirectly.internalAdapter.createResource(data)createQueryCookie: This is a helper function that let's you get a cookienameandoptionsfor either tosetorgetcookies. It implements things like__secureprefix and__hostprefix for cookies based on
For other properties, you can check the Better Call documentation and the source code .
Rules for Endpoints
- Makes sure you use kebab-case for the endpoint path
- Make sure to only use
POSTorGETmethods for the endpoints. - Any function that modifies a data should be a
POSTmethod. - Any function that fetches data should be a
GETmethod. - Make sure to use the
createQueryEndpointfunction to create API endpoints. - Make sure your paths are unique to avoid conflicts with other plugins. If you're using a common path, add the plugin name as a prefix to the path. (
/my-plugin/hello-worldinstead of/hello-world.)
Schema
You can define a database schema for your plugin by passing a schema object. The schema object should have the table name as the key and the schema definition as the value.
import { BetterQueryPlugin } from "better-call";
const myPlugin = ()=> {
return {
id: "my-plugin",
schema: {
myTable: {
fields: {
name: {
type: "string"
}
},
modelName: "myTable" // optional if you want to use a different name than the key
}
}
} satisfies BetterQueryPlugin
}Fields
By default better-query will create an id field for each table. You can add additional fields to the table by adding them to the fields object.
The key is the column name and the value is the column definition. The column definition can have the following properties:
type: The type of the field. It can be string, number, boolean, date.
required: if the field should be required on a new record. (default: false)
unique: if the field should be unique. (default: false)
reference: if the field is a reference to another table. (default: null) It takes an object with the following properties:
model: The table name to reference.field: The field name to reference.onDelete: The action to take when the referenced record is deleted. (default:null)
Other Schema Properties
disableMigration: if the table should not be migrated. (default: false)
const myPlugin = (opts: PluginOptions)=>{
return {
id: "my-plugin",
schema: {
rateLimit: {
fields: {
key: {
type: "string",
},
},
disableMigration: opts.storage.provider !== "database",
},
},
} satisfies Plugin
}if you add additional fields to a user or session table, the types will be inferred automatically on getSession and signUpEmail calls.
const myPlugin = ()=>{
return {
id: "my-plugin",
schema: {
user: {
fields: {
age: {
type: "number",
},
},
},
},
} satisfies Plugin
}This will add an age field to the user table and all user returning endpoints will include the age field and it'll be inferred properly by typescript.
Don't store sensitive information in user or session table. Crate a new table if you need to store sensitive information.
Hooks
Hooks are used to run code before or after an action is performed, either from a client or directly on the server. You can add hooks to the server by passing a hooks object, which should contain before and after properties.
import { createMiddleware } from "better-call";
const myPlugin = ()=>{
return {
id: "my-plugin",
hooks: {
before: [{
matcher: (context)=>{
return context.headers.get("x-my-header") === "my-value"
},
handler: createMiddleware(async (ctx)=>{
//do something before the request
return {
context: ctx // if you want to modify the context
}
})
}],
after: [{
matcher: (context)=>{
return context.path === "/api/users"
},
handler: createMiddleware(async (ctx)=>{
return ctx.json({
message: "Hello World"
}) // if you want to modify the response
})
}]
}
} satisfies BetterQueryPlugin
}Middleware
You can add middleware to the server by passing a middlewares array. This array should contain middleware objects, each with a path and a middleware property. Unlike hooks, middleware only runs on api requests from a client. If the endpoint is invoked directly, the middleware will not run.
The path can be either a string or a path matcher, using the same path-matching system as better-call.
If you throw an APIError from the middleware or returned a Response object, the request will be stopped and the response will be sent to the client.
const myPlugin = ()=>{
return {
id: "my-plugin",
middlewares: [
{
path: "/my-plugin/hello-world",
middleware: createAuthMiddleware(async(ctx)=>{
//do something
})
}
]
} satisfies Plugin
}On Request & On Response
Additional to middlewares, you can also hook into right before a request is made and right after a response is returned. This is mostly useful if you want to do something that affects all requests or responses.
On Request
The onRequest function is called right before the request is made. It takes two parameters: the request and the context object.
Here’s how it works:
- Continue as Normal: If you don't return anything, the request will proceed as usual.
- Interrupt the Request: To stop the request and send a response, return an object with a
responseproperty that contains aResponseobject. - Modify the Request: You can also return a modified
requestobject to change the request before it's sent.
const myPlugin = ()=> {
return {
id: "my-plugin",
onRequest: async (request, context) => {
//do something
},
} satisfies Plugin
}On Response
The onResponse function is executed immediately after a response is returned. It takes two parameters: the response and the context object.
Here’s how to use it:
- Modify the Response: You can return a modified response object to change the response before it is sent to the client.
- Continue Normally: If you don't return anything, the response will be sent as is.
const myPlugin = ()=>{
return {
id: "my-plugin",
onResponse: async (response, context) => {
//do something
},
} satisfies Plugin
}Rate Limit
You can define custom rate limit rules for your plugin by passing a rateLimit array. The rate limit array should contain an array of rate limit objects.
const myPlugin = ()=>{
return {
id: "my-plugin",
rateLimit: [
{
pathMatcher: (path)=>{
return path === "/my-plugin/hello-world"
},
limit: 10,
window: 60,
}
]
} satisfies Plugin
}Server-plugin helper functions
Some additional helper functions for creating server plugins.
getSessionFromCtx
Allows you to get the client's session data by passing the query middleware's context.
import { createMiddleware } from "better-call";
const myPlugin = {
id: "my-plugin",
hooks: {
before: [{
matcher: (context)=>{
return context.headers.get("x-my-header") === "my-value"
},
handler: createMiddleware(async (ctx) => {
const queryData = await getQueryDataFromCtx(ctx);
//do something with the client's data.
return {
context: ctx
}
})
}],
}
} satisfies BetterQueryPluginqueryContextMiddleware
A middleware that enhances the context object with query-specific functionality. If you need to access Query context in your plugin endpoints, use this middleware.
import { createMiddleware } from "better-call";
import { queryContextMiddleware } from "better-query";
const myPlugin = ()=>{
return {
id: "my-plugin",
endpoints: {
getHelloWorld: createQueryEndpoint("/my-plugin/hello-world", {
method: "GET",
use: [queryContextMiddleware],
}, async(ctx) => {
const queryContext = ctx.context;
return ctx.json({
message: "Hello World"
})
})
}
} satisfies Plugin
}Creating a client plugin
If your endpoints needs to be called from the client, you'll need to also create a client plugin. Better Query clients can infer the endpoints from the server plugins. You can also add additional client side logic.
import type { BetterQueryClientPlugin } from "better-query";
export const myPluginClient = ()=>{
return {
id: "my-plugin",
} satisfies BetterQueryClientPlugin
}Endpoint Interface
Endpoints are inferred from the server plugin by adding a $InferServerPlugin key to the client plugin.
The client infers the path as an object and converts kebab-case to camelCase. For example, /my-plugin/hello-world becomes myPlugin.helloWorld.
import type { BetterQueryClientPlugin } from "better-query/client";
import type { myPlugin } from "./plugin";
const myPluginClient = ()=> {
return {
id: "my-plugin",
$InferServerPlugin: {} as ReturnType<typeof myPlugin>,
} satisfies BetterQueryClientPlugin
}Get actions
If you need to add additional methods or what not to the client you can use the getActions function. This function is called with the fetch function from the client.
Better Query uses Better fetch to make requests. Better fetch is a simple fetch wrapper made by the same queryor of Better Query.
import type { BetterAuthClientPlugin } from "better-query/client";
import type { myPlugin } from "./plugin";
import type { BetterFetchOption } from "@better-fetch/fetch";
const myPluginClient = {
id: "my-plugin",
$InferServerPlugin: {} as ReturnType<typeof myPlugin>,
getActions: ($fetch)=>{
return {
myCustomAction: async (data: {
foo: string,
}, fetchOptions?: BetterFetchOption)=>{
const res = $fetch("/custom/action", {
method: "POST",
body: {
foo: data.foo
},
...fetchOptions
})
return res
}
}
}
} satisfies BetterAuthClientPluginAs a general guideline, ensure that each function accepts only one argument, with an optional second argument for fetchOptions to allow users to pass additional options to the fetch call. The function should return an object containing data and error keys.
If your use case involves actions beyond API calls, feel free to deviate from this rule.
Get Atoms
This is only useful if you want to provide hooks like useSession.
Get atoms is called with the fetch function from better fetch and it should return an object with the atoms. The atoms should be created using nanostores. The atoms will be resolved by each framework useStore hook provided by nanostores.
import { atom } from "nanostores";
import type { BetterAuthClientPlugin } from "better-query/client";
const myPluginClient = {
id: "my-plugin",
$InferServerPlugin: {} as ReturnType<typeof myPlugin>,
getAtoms: ($fetch)=>{
const myAtom = atom<null>()
return {
myAtom
}
}
} satisfies BetterAuthClientPluginSee built-in plugins for examples of how to use atoms properly.
Path methods
By default, inferred paths use GET method if they don't require a body and POST if they do. You can override this by passing a pathMethods object. The key should be the path and the value should be the method ("POST" | "GET").
import type { BetterAuthClientPlugin } from "better-query/client";
import type { myPlugin } from "./plugin";
const myPluginClient = {
id: "my-plugin",
$InferServerPlugin: {} as ReturnType<typeof myPlugin>,
pathMethods: {
"/my-plugin/hello-world": "POST"
}
} satisfies BetterAuthClientPluginFetch plugins
If you need to use better fetch plugins you can pass them to the fetchPlugins array. You can read more about better fetch plugins in the better fetch documentation.
Atom Listeners
This is only useful if you want to provide hooks like useSession and you want to listen to atoms and re-evaluate them when they change.
You can see how this is used in the built-in plugins.