Zero dependencies
Just native fetch. No Axios, no polyfills. ESM-only. Works in Node ≥ 18, Vite, Webpack 5+, Nuxt 3, Next.js 13+, Vue 3, React 18+, SvelteKit.
Zero dependencies. Just fetch. Eloquent-style fluent API that speaks Spatie laravel-query-builder's URL contract — including JSON:API Filter Groups.
Zero dependencies. Just fetch. An Eloquent-style fluent API that speaks Spatie laravel-query-builder's URL contract — including JSON:API Filter Groups (v7.3.0).
The same query against a Spatie-powered Laravel API:
const url = new URL('/users', 'https://api.example.com');
url.searchParams.set('filter[status]', 'active');
url.searchParams.set('filter[id]', [1, 2, 3].join(','));
url.searchParams.set('filter[salary]', '>3000');
url.searchParams.set('sort', '-created_at');
url.searchParams.set('include', 'posts,profile');
url.searchParams.set('page', '2');
url.searchParams.set('per_page', '25');
const res = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const items = json.data.map((row) => /* hand-roll mapper */);import { FilterOperator } from '@bir-tan/crisp-oquent';
const page = await User.crispy()
.filter('status', 'active')
.filter('id', [1, 2, 3])
.where('salary', FilterOperator.GREATER_THAN, 3000)
.sortByDesc('created_at')
.include('posts', 'profile')
.paginate(2, 25);
page.items; // User[]
page.hasMorePages();
page.links.next;crisp-oquent is the JavaScript companion to Spatie laravel-query-builder, the de-facto query builder for Laravel APIs.
Backend defines what's allowed:
// Laravel + Spatie
QueryBuilder::for(User::class)
->allowedFilters(
AllowedFilter::partial('status'),
AllowedFilter::exact('id'),
AllowedFilter::operator('salary', FilterOperator::DYNAMIC),
AllowedFilter::groupOr('q', [
AllowedFilter::partial('name'),
AllowedFilter::partial('full_name'),
]),
)
->allowedSorts('created_at', 'name')
->allowedIncludes('posts', 'profile')
->paginate(25);Frontend speaks that contract verbatim:
// crisp-oquent
await User.crispy()
.filter('status', 'active')
.where('salary', FilterOperator.GREATER_THAN, 3000)
.filterGroup('q', 'John') // → backend OR (name LIKE %John% OR full_name LIKE %John%)
.sortByDesc('created_at')
.include('posts', 'profile')
.paginate(2, 25);npm install @bir-tan/crisp-oquent// 1. Configure once
import { CrispOquentConfig } from '@bir-tan/crisp-oquent';
CrispOquentConfig.initialize({ baseUri: 'https://api.example.com' });
// 2. Define a model
import { Model } from '@bir-tan/crisp-oquent';
class User extends Model {
static override uri = '/users';
declare id?: number;
declare name?: string;
}
// 3. Query
const users = await User.crispy().filter('status', 'active').get();