Documentation

Manual Install

If you prefer not to use the CLI, you can manually create every file. This guide walks you through each layer from scratch.

Tip: This takes about 5–10 minutes. If you just want to get started fast, use the CLI installer instead.


Step 1: Create the folder structure

Inside your src/components/ directory, create the following folders:

bash
mkdir -p src/components/conditional-filter/{helpers,hooks,provider,ui}
touch src/components/conditional-filter/{types,constants,index}.ts

Step 2: Install dependencies

bash
npm install lucide-react date-fns
npx shadcn@latest add button input select popover calendar command badge

Step 3: Create types.ts

This is the foundation — all shared TypeScript types live here.

ts
// Field types determine what input UI is rendered
export type FieldType =
  | "text" | "number" | "select" | "multiselect"
  | "date" | "datetime" | "boolean" | "combobox";

export type OperatorType =
  | "is" | "is_not"
  | "contains" | "not_contains"
  | "gt" | "gte" | "lt" | "lte"
  | "between" | "in" | "not_in"
  | "is_empty" | "is_not_empty";

export interface SelectOption {
  label: string;
  value: string;
}

export interface FilterFieldDefinition {
  name: string;
  label: string;
  type: FieldType;
  operators?: OperatorType[];
  options?: SelectOption[];
  fetchOptions?: (search: string) => Promise<SelectOption[]>;
}

export interface FilterRow {
  id: string;
  field: FilterFieldDefinition | null;
  operator: OperatorType | null;
  value: FilterValue;
}

export type FilterValue =
  | string | string[]
  | number | [number, number]
  | [string, string]
  | boolean | null;

export interface FilterState {
  rows: FilterRow[];
  conjunction: "and" | "or";
}

export type RestQueryParams = Record<string, string | string[]>;

export interface FilterLocale {
  addFilter: string;
  reset: string;
  apply: string;
  placeholder: string;
  and: string;
  or: string;
  noFilters: string;
}

export interface FilterConfig {
  fields: FilterFieldDefinition[];
  allowConjunctionToggle?: boolean;
  maxRows?: number;
  paramStyle?: "underscore" | "bracket" | "custom";
  customParamBuilder?: (
    field: string,
    operator: OperatorType,
    value: FilterValue
  ) => Record<string, string>;
  locale?: FilterLocale;
}

Step 4: Create constants.ts

ts
import type { OperatorType, FieldType, FilterLocale } from "./types";

export const DEFAULT_LOCALE: FilterLocale = {
  addFilter: "Add filter",
  reset: "Reset",
  apply: "Apply",
  placeholder: "Select...",
  and: "And",
  or: "Or",
  noFilters: "No filters applied",
};

export const FIELD_OPERATORS: Record<FieldType, OperatorType[]> = {
  text:        ["is", "is_not", "contains", "not_contains", "is_empty", "is_not_empty"],
  number:      ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
  select:      ["is", "is_not", "in", "not_in", "is_empty", "is_not_empty"],
  multiselect: ["in", "not_in", "is_empty", "is_not_empty"],
  date:        ["is", "is_not", "gt", "lt", "between", "is_empty", "is_not_empty"],
  datetime:    ["is", "is_not", "gt", "lt", "between", "is_empty", "is_not_empty"],
  boolean:     ["is", "is_not"],
  combobox:    ["is", "is_not", "in", "not_in", "is_empty", "is_not_empty"],
};

export const OPERATOR_LABELS: Record<OperatorType, string> = {
  is:           "is",
  is_not:       "is not",
  contains:     "contains",
  not_contains: "does not contain",
  gt:           "greater than",
  gte:          "greater than or equal",
  lt:           "less than",
  lte:          "less than or equal",
  between:      "is between",
  in:           "is one of",
  not_in:       "is not one of",
  is_empty:     "is empty",
  is_not_empty: "is not empty",
};

Step 5: Create helpers

operators.ts

ts
import { FIELD_OPERATORS } from "../constants";
import type { FilterFieldDefinition, OperatorType } from "../types";

export function getOperatorsForField(field: FilterFieldDefinition): OperatorType[] {
  if (field.operators && field.operators.length > 0) return field.operators;
  return FIELD_OPERATORS[field.type] ?? [];
}

validators.ts

ts
import type { FilterRow } from "../types";

export function isValidFilterRow(row: FilterRow): boolean {
  if (!row.field || !row.operator) return false;
  if (row.operator === "is_empty" || row.operator === "is_not_empty") return true;
  if (row.value === null || row.value === "" || row.value === undefined) return false;
  if (Array.isArray(row.value) && row.value.length === 0) return false;
  return true;
}

export function getValidFilterRows(rows: FilterRow[]): FilterRow[] {
  return rows.filter(isValidFilterRow);
}

query-builder.ts

ts
import type { FilterRow, RestQueryParams, FilterConfig } from "../types";
import { getValidFilterRows } from "./validators";

export function buildRestQuery(
  rows: FilterRow[],
  config: Pick<FilterConfig, "paramStyle" | "customParamBuilder">
): RestQueryParams {
  const params: RestQueryParams = {};
  const validRows = getValidFilterRows(rows);

  for (const row of validRows) {
    if (!row.field || !row.operator) continue;
    const { name } = row.field;
    const op = row.operator;
    const val = row.value;

    if (op === "is_empty" || op === "is_not_empty") {
      const key = config.paramStyle === "bracket" ? `${name}[${op}]` : `${name}_${op}`;
      params[key] = "true";
      continue;
    }

    if (config.paramStyle === "custom" && config.customParamBuilder) {
      const custom = config.customParamBuilder(name, op, val);
      Object.assign(params, custom);
      continue;
    }

    const key = config.paramStyle === "bracket" ? `${name}[${op}]` : `${name}_${op}`;
    if (op === "is" || op === "is_not") {
      params[name] = String(val);
    } else {
      params[key] = Array.isArray(val) ? (val as string[]) : String(val);
    }
  }
  return params;
}

serializer.ts

ts
import type { FilterState, FilterRow, FilterFieldDefinition } from "../types";

export function serializeFiltersToUrl(state: FilterState): URLSearchParams {
  const params = new URLSearchParams();
  params.set("conjunction", state.conjunction);
  state.rows.forEach((row, i) => {
    if (!row.field || !row.operator) return;
    params.set(`filter[${i}][field]`,    row.field.name);
    params.set(`filter[${i}][operator]`, row.operator);
    params.set(`filter[${i}][value]`,    JSON.stringify(row.value));
  });
  return params;
}

export function deserializeUrlToFilters(
  params: URLSearchParams,
  fields: FilterFieldDefinition[]
): FilterState {
  const conjunction = (params.get("conjunction") ?? "and") as "and" | "or";
  const rows: FilterRow[] = [];
  let i = 0;
  while (params.has(`filter[${i}][field]`)) {
    const fieldName = params.get(`filter[${i}][field]`);
    const operator  = params.get(`filter[${i}][operator]`) as FilterRow["operator"];
    const valueRaw  = params.get(`filter[${i}][value]`);
    const field     = fields.find((f) => f.name === fieldName) ?? null;
    rows.push({
      id: `row-${i}`,
      field,
      operator,
      value: valueRaw ? JSON.parse(valueRaw) : null,
    });
    i++;
  }
  return { rows, conjunction };
}

Step 6: Create hooks

use-filter-state.ts

ts
import { useCallback, useState } from "react";
import type { FilterRow, FilterFieldDefinition, OperatorType, FilterValue } from "../types";

let _id = 0;
function uid() { return `row-${++_id}`; }

function emptyRow(): FilterRow {
  return { id: uid(), field: null, operator: null, value: null };
}

export function useFilterState(maxRows = 10) {
  const [rows, setRows] = useState<FilterRow[]>([emptyRow()]);
  const [conjunction, setConjunction] = useState<"and" | "or">("and");

  const addRow = useCallback(() => {
    if (rows.length >= maxRows) return;
    setRows((prev) => [...prev, emptyRow()]);
  }, [rows.length, maxRows]);

  const removeRow = useCallback((id: string) => {
    setRows((prev) => prev.filter((r) => r.id !== id));
  }, []);

  const updateField = useCallback((id: string, field: FilterFieldDefinition | null) => {
    setRows((prev) =>
      prev.map((r) => r.id === id ? { ...r, field, operator: null, value: null } : r)
    );
  }, []);

  const updateOperator = useCallback((id: string, operator: OperatorType | null) => {
    setRows((prev) =>
      prev.map((r) => r.id === id ? { ...r, operator, value: null } : r)
    );
  }, []);

  const updateValue = useCallback((id: string, value: FilterValue) => {
    setRows((prev) =>
      prev.map((r) => r.id === id ? { ...r, value } : r)
    );
  }, []);

  const reset = useCallback(() => {
    setRows([emptyRow()]);
    setConjunction("and");
  }, []);

  return {
    rows, conjunction, setConjunction,
    addRow, removeRow, updateField, updateOperator, updateValue, reset,
  };
}

use-filter-url-sync.ts

ts
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { serializeFiltersToUrl, deserializeUrlToFilters } from "../helpers/serializer";
import type { FilterState, FilterFieldDefinition } from "../types";

export function useFilterUrlSync(fields: FilterFieldDefinition[]) {
  const router       = useRouter();
  const pathname     = usePathname();
  const searchParams = useSearchParams();

  const readState = useCallback((): FilterState => {
    return deserializeUrlToFilters(
      searchParams as unknown as URLSearchParams,
      fields
    );
  }, [searchParams, fields]);

  const writeState = useCallback((state: FilterState) => {
    const params = serializeFiltersToUrl(state);
    router.replace(`${pathname}?${params.toString()}`);
  }, [router, pathname]);

  return { readState, writeState };
}

use-filter-options.ts

ts
import { useEffect, useState } from "react";
import type { FilterFieldDefinition, SelectOption } from "../types";

export function useFilterOptions(field: FilterFieldDefinition | null, search: string) {
  const [options, setOptions] = useState<SelectOption[]>(field?.options ?? []);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!field?.fetchOptions) {
      setOptions(field?.options ?? []);
      return;
    }
    setLoading(true);
    field.fetchOptions(search).then((opts) => {
      setOptions(opts);
      setLoading(false);
    });
  }, [field, search]);

  return { options, loading };
}

Step 7: Create provider

filter-context.ts

ts
import { createContext, useContext } from "react";
import type { FilterConfig, FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";

export interface FilterContextValue {
  config: FilterConfig;
  state: FilterState;
  addRow: () => void;
  removeRow: (id: string) => void;
  updateField: (id: string, field: FilterFieldDefinition | null) => void;
  updateOperator: (id: string, op: OperatorType | null) => void;
  updateValue: (id: string, value: FilterValue) => void;
  setConjunction: (c: "and" | "or") => void;
  reset: () => void;
  apply: () => void;
}

export const FilterContext = createContext<FilterContextValue | null>(null);

export function useFilterContext(): FilterContextValue {
  const ctx = useContext(FilterContext);
  if (!ctx) throw new Error("useFilterContext must be used inside <FilterProvider>");
  return ctx;
}

filter-provider.tsx

tsx
"use client";
import { useCallback } from "react";
import { FilterContext } from "./filter-context";
import { useFilterState } from "../hooks/use-filter-state";
import { useFilterUrlSync } from "../hooks/use-filter-url-sync";
import { buildRestQuery } from "../helpers/query-builder";
import type { FilterConfig } from "../types";

interface FilterProviderProps {
  config: FilterConfig;
  children: React.ReactNode;
}

export function FilterProvider({ config, children }: FilterProviderProps) {
  const {
    rows, conjunction, setConjunction,
    addRow, removeRow, updateField, updateOperator, updateValue, reset,
  } = useFilterState(config.maxRows);

  const { writeState } = useFilterUrlSync(config.fields);

  const apply = useCallback(() => {
    writeState({ rows, conjunction });
    const _query = buildRestQuery(rows, config);
    console.log("Apply filter:", _query);
  }, [rows, conjunction, writeState, config]);

  return (
    <FilterContext.Provider value={{
      config,
      state: { rows, conjunction },
      addRow, removeRow, updateField, updateOperator, updateValue,
      setConjunction, reset, apply,
    }}>
      {children}
    </FilterContext.Provider>
  );
}

Step 8: Create index.ts

ts
export * from "./types";
export * from "./constants";
export * from "./hooks/use-filter-state";
export * from "./hooks/use-filter-url-sync";
export * from "./hooks/use-filter-options";
export * from "./provider/filter-context";
export * from "./provider/filter-provider";
export * from "./ui/filter-root";
export { buildRestQuery } from "./helpers/query-builder";
export { serializeFiltersToUrl, deserializeUrlToFilters } from "./helpers/serializer";
export { isValidFilterRow, getValidFilterRows } from "./helpers/validators";

Step 9: Copy the UI components

Copy the 7 files from the GitHub repository into src/components/conditional-filter/ui/:

FileRole
filter-root.tsxTop-level container — renders all rows + footer
filter-row.tsxA single row: field select + operator select + value input
field-select.tsxDropdown for choosing a field
operator-select.tsxDropdown for choosing an operator
value-input.tsxRenders the correct input (text, number, date picker, etc.)
filter-footer.tsxAdd filter / Reset / Apply buttons
filter-badge.tsxSmall badge showing active filter count

Once all files are in place, head to Usage Examples to start filtering.