Back to use cases
builder1 day

EU Regulatory Monitor Dashboard

Build a React dashboard that tracks new EU regulations, directives, and CJEU rulings across selected legal domains.

APIReactDashboard

What you'll build

Regulatory compliance teams need a centralized view of legislative and judicial developments that affect their business. This tutorial shows how to build a React dashboard that monitors EU regulations, directives, and CJEU case law in real time. The dashboard uses the Moonlit filter endpoints to build dynamic filter controls (jurisdictions, document types, fields of law), then queries the search API for recent documents matching the selected criteria. Results are displayed in a sortable table with links to full document retrieval. You will use the facets feature to show aggregate counts by jurisdiction and document type, giving compliance officers an at-a-glance overview of regulatory activity across Europe.

Architecture


  ┌────────────────────────────────────────────────────────┐
  │                React Dashboard                          │
  │  ┌────────────┐  ┌────────────┐  ┌───────────────────┐  │
  │  │  Filters   │  │  Facet      │  │  Results Table     │  │
  │  │  Panel     │  │  Charts     │  │  (sortable)        │  │
  │  └──────┬─────┘  └──────┬─────┘  └─────────┬─────────┘  │
  └─────────┴────────────────┴───────────┴───────────────┘
                         │
                         ▼
  ┌─────────────────────────────────────────────────────────┐
  │                 Moonlit API                               │
  │  /search/filters/*  /search/hybrid_search  /document/*   │
  └─────────────────────────────────────────────────────────┘

Prerequisites

  • A Moonlit API key
  • Node.js 18+ and npm
  • Basic React and TypeScript knowledge
  • A React project (Vite or Next.js recommended)

Step-by-step

1

Create the API client

Build a typed API client that wraps all Moonlit endpoints you need: filters for building the UI controls, search for querying documents, and retrieve for fetching full texts.

// lib/moonlit.ts
const BASE_URL = "https://api.moonlit.ai/v1.1";
const API_KEY = process.env.NEXT_PUBLIC_MOONLIT_API_KEY!;

const headers = {
  "Ocp-Apim-Subscription-Key": API_KEY,
  "Content-Type": "application/json",
};

export async function getJurisdictions() {
  const res = await fetch(`${BASE_URL}/search/filters/jurisdictions_portals`, { headers });
  return res.json();
}

export async function getFieldsOfLaw() {
  const res = await fetch(`${BASE_URL}/search/filters/trees`, { headers });
  return res.json();
}

export async function getDocumentTypes() {
  const res = await fetch(`${BASE_URL}/search/filters/documenttypes`, { headers });
  return res.json();
}

export async function searchDocuments(params: {
  query: string;
  jurisdictions?: string[];
  documentTypes?: string[];
  fieldsOfLaw?: string[];
  from_date?: string;
  sort_type?: number;
  page?: number;
}) {
  const res = await fetch(`${BASE_URL}/search/hybrid_search`, {
    method: "POST",
    headers,
    body: JSON.stringify({ ...params, num_results: 20, facets: true }),
  });
  return res.json();
}
2

Build the filter panel

Load jurisdictions, document types, and fields of law from the filter endpoints on mount. Render them as checkbox groups that update the search query when toggled.

// components/FilterPanel.tsx
"use client";

import { useEffect, useState } from "react";
import { getJurisdictions, getFieldsOfLaw } from "@/lib/moonlit";

interface FilterPanelProps {
  onFilterChange: (filters: Record<string, string[]>) => void;
}

export function FilterPanel({ onFilterChange }: FilterPanelProps) {
  const [jurisdictions, setJurisdictions] = useState([]);
  const [fields, setFields] = useState([]);
  const [selected, setSelected] = useState<Record<string, string[]>>({
    jurisdictions: ["European Union"],
    documentTypes: ["legislation", "case_law"],
    fieldsOfLaw: [],
  });

  useEffect(() => {
    getJurisdictions().then((d) => setJurisdictions(d));
    getFieldsOfLaw().then((d) => setFields(d.FieldsOfLaw));
  }, []);

  const toggle = (key: string, value: string) => {
    setSelected((prev) => {
      const arr = prev[key] || [];
      const next = arr.includes(value)
        ? arr.filter((v) => v !== value)
        : [...arr, value];
      const updated = { ...prev, [key]: next };
      onFilterChange(updated);
      return updated;
    });
  };

  return (
    <aside className="w-64 space-y-6 p-4 border-r">
      <div>
        <h3 className="font-semibold mb-2">Jurisdictions</h3>
        {jurisdictions.map((j: any) => (
          <label key={j.name} className="flex items-center gap-2">
            <input
              type="checkbox"
              checked={selected.jurisdictions.includes(j.name)}
              onChange={() => toggle("jurisdictions", j.name)}
            />
            {j.name} ({j.count})
          </label>
        ))}
      </div>
      {/* Similar checkbox groups for documentTypes and fieldsOfLaw */}
    </aside>
  );
}
3

Display results with facet charts

Use the facets returned by the search API to render aggregate counts by jurisdiction and document type. Display the actual results in a sortable table with ECLI links.

// components/ResultsDashboard.tsx
"use client";

import { searchDocuments } from "@/lib/moonlit";
import { useEffect, useState } from "react";

interface Props {
  query: string;
  filters: Record<string, string[]>;
}

export function ResultsDashboard({ query, filters }: Props) {
  const [data, setData] = useState<any>(null);
  const [sortField, setSortField] = useState<string>("score");

  useEffect(() => {
    if (!query) return;
    searchDocuments({
      query,
      jurisdictions: filters.jurisdictions,
      documentTypes: filters.documentTypes,
      fieldsOfLaw: filters.fieldsOfLaw,
      sort_type: sortField === "date" ? 1 : 0,
    }).then(setData);
  }, [query, filters, sortField]);

  if (!data) return <p>Enter a query to begin monitoring.</p>;

  return (
    <div className="flex-1 p-6">
      {/* Facet summary cards */}
      <div className="grid grid-cols-2 gap-4 mb-6">
        <div className="border rounded p-4">
          <h4 className="font-semibold mb-2">By Jurisdiction</h4>
          {Object.entries(data.facets?.jurisdictions || {}).map(([k, v]) => (
            <div key={k} className="flex justify-between text-sm">
              <span>{k}</span>
              <span className="font-mono">{v as number}</span>
            </div>
          ))}
        </div>
        <div className="border rounded p-4">
          <h4 className="font-semibold mb-2">By Document Type</h4>
          {Object.entries(data.facets?.document_types || {}).map(([k, v]) => (
            <div key={k} className="flex justify-between text-sm">
              <span>{k}</span>
              <span className="font-mono">{v as number}</span>
            </div>
          ))}
        </div>
      </div>

      {/* Results table */}
      <table className="w-full text-sm">
        <thead>
          <tr className="border-b text-left">
            <th className="py-2">ECLI</th>
            <th>Source</th>
            <th className="cursor-pointer" onClick={() => setSortField("date")}>
              Date {sortField === "date" ? "(sorted)" : ""}
            </th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          {data.result?.results?.map((r: any) => (
            <tr key={r.identifier} className="border-b hover:bg-gray-50">
              <td className="py-2 font-mono text-xs">{r.identifier}</td>
              <td>{r.source}</td>
              <td>{r.date}</td>
              <td className="max-w-md truncate">{r.title}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What's next