Basic Usage

Better Query provides built-in CRUD endpoint generation for any resource you define with:

  • Type-safe schemas using Zod
  • Permission controls per operation (create, read, update, delete, list)
  • Automatic client generation with full TypeScript support

But can also be easily extended using plugins, such as: audit, cache, better-auth integration, and more.

Resource Definition

To create CRUD endpoints for a resource, you need to define it using createResource:

query.ts
import { betterQuery, createResource, withId } from "better-query"
import { z } from "zod"

// Define your schema
const productSchema = withId({
  name: z.string().min(1),
  price: z.number().min(0),
  status: z.enum(["active", "inactive", "draft"]).default("draft"),
})

export const query = betterQuery({
    resources: [ 
        createResource({ 
            name: "product", 
            schema: productSchema, 
        }) 
    ] 
})

Generated Endpoints

For each resource, the following endpoints are automatically created:

  • POST /api/product - Create a product
  • GET /api/product/:id - Get a product by ID
  • PATCH /api/product/:id - Update a product
  • DELETE /api/product/:id - Delete a product
  • GET /api/products - List products (with pagination)

Creating Resources

To create a resource, you can use the client method create with the resource data.

create-product.ts
import { queryClient } from "@/lib/query-client"; //import the query client

const { data, error } = await queryClient.product.create({
    name: "Awesome T-Shirt",
    price: 29.99,
    status: "active"
}, {
    onRequest: (ctx) => {
        //show loading
    },
    onSuccess: (ctx) => {
        //redirect to the product page or show success message
    },
    onError: (ctx) => {
        // display the error message
        alert(ctx.error.message);
    },
});

By default, the resources are created with auto-generated IDs and timestamps. You can customize this behavior in your schema definition.

query.ts
import { betterQuery, createResource } from "better-query"
import { z } from "zod"

const productSchema = z.object({
    id: z.string().optional(), // Will be auto-generated if not provided
    name: z.string().min(1),
    price: z.number().min(0),
    createdAt: z.date().default(() => new Date()),
    updatedAt: z.date().default(() => new Date()),
})

export const query = betterQuery({
    resources: [
        createResource({
            name: "product",
            schema: productSchema,
        })
    ]
})

Reading Resources

To read a specific resource, you can use the read function provided by the client.

get-product
const { data, error } = await queryClient.product.read("product-id", {
    headers: {
        "X-API-Key": "your-api-key"
    }
});

Always invoke client methods from the client side. Don't call them from the server.

Server-Side Resource Management

To manage resources on the server, you can use the query.api methods.

server.ts
import { query } from "./query"; // path to your Better Query server instance

const response = await query.api.createProduct({
    body: {
        name: "New Product",
        price: 49.99
    },
    asResponse: true // returns a response object instead of data
});

If the server cannot return a response object, you'll need to manually parse and set headers. But for frameworks like Next.js we provide framework-specific helpers to handle this automatically

Updating Resources

To update a resource, you can use the update method provided by the client.

update-product.ts
import { queryClient } from "@/lib/query-client"; //import the query client

const { data, error } = await queryClient.product.update("product-id", {
    price: 24.99, // Partial updates are supported
    status: "active"
}, {
    headers: {
        "X-API-Key": "your-api-key"
    }
});

Deleting Resources

To delete a resource, you can use the delete method provided by the client.

delete-product.ts
import { queryClient } from "@/lib/query-client"; //import the query client

await queryClient.product.delete("product-id", {
    headers: {
        "X-API-Key": "your-api-key"
    }
});

You can pass fetchOptions to handle success/error callbacks:

delete-product.ts
await queryClient.product.delete("product-id", {
    fetchOptions: {
        onSuccess: () => {
            // Redirect to products list or show success message
            router.push("/products");
        },
        onError: (ctx) => {
            alert("Failed to delete product");
        }
    },
});

Listing Resources

To list resources with pagination and filtering, you can use the list method.

list-products.ts
const { data, error } = await queryClient.product.list({
    page: 1,
    limit: 10,
    search: "shirt", // Search in searchable fields
    sortBy: "name",
    sortOrder: "asc",
}, {
    headers: {
        "X-API-Key": "your-api-key"
    }
});

console.log(data);
// {
//   items: [...products],
//   pagination: {
//     page: 1,
//     limit: 10,
//     total: 25,
//     totalPages: 3,
//     hasNext: true,
//     hasPrev: false
//   }
// }

Resource Management

Once resources are defined, you'll want to control access and manage data. Better Query allows you to easily manage resources from both the server and client sides.

Client Side

Using Client Methods

Better Query provides type-safe client methods to interact with your resources, similar to Better Auth's approach.

product-manager.tsx
import { queryClient } from "@/lib/query-client" // import the query client

export function ProductManager(){
    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(false);

    const fetchProducts = async () => {
        setLoading(true);
        const result = await queryClient.product.list({
            page: 1,
            limit: 10
        });
        if (result.data) {
            setProducts(result.data.items);
        }
        setLoading(false);
    };

    const createProduct = async (productData) => {
        const result = await queryClient.product.create(productData);
        if (result.data) {
            setProducts(prev => [...prev, result.data]);
        }
    };

    return (
        <div>
            {loading ? "Loading..." : (
                products.map(product => (
                    <div key={product.id}>{product.name}</div>
                ))
            )}
        </div>
    )
}
product-manager.js
import { queryClient } from "~/lib/query-client"; //import the query client

const products = [];
let loading = false;

const fetchProducts = async () => {
    loading = true;
    const result = await queryClient.product.list({
        page: 1,
        limit: 10
    });
    if (result.data) {
        products.push(...result.data.items);
    }
    loading = false;
    renderProducts();
};

const renderProducts = () => {
    const container = document.getElementById('products');
    container.innerHTML = products.map(p => `<div>${p.name}</div>`).join('');
};

Server Side

The server provides resource management methods that you can use to handle resources directly. It requires request headers object to be passed when needed.

Example: Using some popular frameworks

server.ts
import { query } from "./query"; // path to your Better Query server instance
import { headers } from "next/headers";

const product = await query.api.createProduct({
    body: {
        name: "Server Product",
        price: 99.99
    },
    headers: await headers() // you need to pass the headers object.
})
index.ts
import { query } from "./query";

const app = new Hono();

app.post("/create-product", async (c) => {
    const body = await c.req.json();
    const product = await query.api.createProduct({
        body,
        headers: c.req.raw.headers
    })
    return c.json(product);
});

For more details check resource management documentation.

Using Plugins

One of the key features of Better Query is a plugins ecosystem. It allows you to add complex functionality with small lines of code, similar to Better Auth.

Below is an example of how to add audit logging using the audit plugin.

Server Configuration

To add a plugin, you need to import the plugin and pass it to the plugins option of the query instance. For example, to add audit logging, you can use the following code:

query.ts
import { betterQuery, createResource } from "better-query"
import { auditPlugin } from "better-query/plugins"

export const query = betterQuery({
    //...rest of the options
    plugins: [ 
        auditPlugin() 
    ] 
})

now audit-related routes and methods will be available on the server.

Migrate Database

After adding the plugin, you'll need to add the required tables to your database. You can do this by running the migrate command, or by using the generate command to create the schema and handle the migration manually.

generating the schema:

terminal
npx better-query generate

using the migrate command:

terminal
npx better-query migrate

If you prefer adding the schema manually, you can check the schema required on the audit plugin documentation.

Client Usage

Once the audit plugin is configured on the server, you can access audit functionality through the regular query client API. The audit plugin adds endpoints that can be called from the client:

query-client.ts
import { createQueryClient } from "better-query/client";

const queryClient = createQueryClient({
    baseURL: "http://localhost:3000/api"
})

Now audit-related endpoints are available through the server API:

audit-example.ts
import { queryClient } from "./query-client"

const getAuditLogs = async() => {
    // Call the audit endpoint added by the server plugin
    const response = await queryClient.$fetch('/audit/logs', {
        query: {
            resource: "product", // Filter by resource type
            operation: "create", // Filter by operation
            page: 1,
            limit: 20
        }
    });
    return response;
}

Note: Unlike some other frameworks, Better Query doesn't use client-side plugins. All functionality is provided through server endpoints that are accessible via the standard client methods.

Next step: See the audit plugin documentation.

On this page