How to Create a Custom Plugin for GeoLibre Using the Plugin Interface

Create a GeoLibre custom plugin by implementing the GeoLibrePlugin interface, registering it with the PluginManager, and using the GeoLibreAppAPI to add map controls, panels, and layers without modifying core code.

The GeoLibre plugin interface, defined in packages/plugins/src/types.ts, provides a standardized contract for extending GeoLibre's mapping capabilities. Plugins run through the PluginManager in packages/plugins/src/plugin-manager.ts, enabling safe activation and deactivation of third-party functionality. This guide walks through the complete process—from interface implementation to cleanup handlers—using patterns directly from the opengeos/GeoLibre source code.

Understanding the GeoLibrePlugin Interface

Every custom plugin must satisfy the GeoLibrePlugin type definition. The interface requires three identification properties and two lifecycle methods.

Required Plugin Properties

  • id – Unique string identifier (kebab-case recommended)
  • name – Human-readable display name
  • version – Semantic version string

Required Lifecycle Methods

  • activate(app: GeoLibreAppAPI) – Called when the plugin is enabled; use this to register UI elements, layers, and event listeners
  • deactivate(app: GeoLibreAppAPI) – Called when the plugin is disabled; must remove all resources added during activation

The GeoLibreAppAPI type, documented in docs/plugin-api.md, exposes methods for interacting with the map, sidebar panels, toolbars, and layer management.

Step-by-Step: Building Your First Plugin

Step 1: Create the Plugin Module

Start with a TypeScript file that imports the core types and exports a GeoLibrePlugin object. The simplest valid plugin switches the basemap when activated:

import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins";

export const myBasemapPlugin: GeoLibrePlugin = {
  id: "my-custom-basemap",
  name: "My Custom Basemap",
  version: "1.0.0",
  
  activate(app: GeoLibreAppAPI) {
    app.setBasemap("https://example.com/styles/terrain.json");
  },
  
  deactivate() {
    // Basemap persists until user changes it; no cleanup required
  },
};

This pattern matches the sample code in docs/plugin-api.md lines 41-48. The setBasemap method immediately applies a new MapLibre style URL to the active map.

Step 2: Register UI Components with the Host API

Most plugins add interactive elements. The GeoLibreAppAPI provides methods across several categories:

Map controls – addMapControl(control, position?) and removeMapControl(control) Sidebar panels – registerRightPanel(config), openRightPanel(id), closeRightPanel(id) Toolbar menus – registerToolbarMenu(config) Floating panels – registerFloatingPanel(config) Layer registration – addGeoJsonLayer, addTileLayer, addCogLayer, addZarrLayer External native layers – registerExternalNativeLayer for direct MapLibre layer integration Deck.gl access – getDeckGL() for 3D visualization

All API methods are optional (typed with ?) to maintain forward compatibility with host variants that may not expose every feature.

Step 3: Handle Cleanup in Deactivate

The deactivate method prevents memory leaks and orphaned UI elements. Store references to registered components during activation, then remove them during deactivation:

activate(app: GeoLibreAppAPI) {
  // Register components and store cleanup functions
  const unregisterPanel = app.registerRightPanel?.({...});
  const unregisterMenu = app.registerToolbarMenu?.({...});
  
  // Store for later cleanup
  this._cleanup = () => {
    unregisterPanel?.();
    unregisterMenu?.();
  };
},

deactivate(app: GeoLibreAppAPI) {
  // Execute all cleanup functions
  this._cleanup?.();
  app.closeRightPanel?.("my-panel-id");
}

The PluginManager in packages/plugins/src/plugin-manager.ts guarantees deactivate is called before a plugin is removed, even during application shutdown or plugin replacement.

Complete Examples: Common Plugin Patterns

Example: Toolbar Menu with Right Sidebar Panel

This pattern combines a top-level menu item with a dockable sidebar panel, following the implementation style in docs/plugin-api.md lines 443-466:

import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins";

export const analysisWorkbenchPlugin: GeoLibrePlugin = {
  id: "analysis-workbench",
  name: "Analysis Workbench",
  version: "2.1.0",

  activate(app: GeoLibreAppAPI) {
    // Register right sidebar panel
    const unregisterPanel = app.registerRightPanel?.({
      id: "workbench-panel",
      title: "Analysis Workbench",
      defaultWidth: 400,
      icon: "🔬",
      render(container) {
        const header = document.createElement("h3");
        header.textContent = "Available Tools";
        
        const list = document.createElement("ul");
        ["Buffer", "Intersect", "Union"].forEach(tool => {
          const li = document.createElement("li");
          li.textContent = tool;
          list.appendChild(li);
        });
        
        container.appendChild(header);
        container.appendChild(list);
        
        // Return cleanup function for this render
        return () => {
          container.innerHTML = "";
        };
      },
    });

    // Register toolbar menu with action
    const unregisterMenu = app.registerToolbarMenu?.({
      id: "workbench-menu",
      label: "Analysis",
      icon: "🔬",
      items: [
        {
          id: "open-workbench",
          label: "Open Workbench",
          onSelect: () => app.openRightPanel?.("workbench-panel"),
        },
        {
          id: "run-quick-buffer",
          label: "Quick Buffer (100m)",
          onSelect: () => {
            // Execute analysis directly
            console.log("Running 100m buffer...");
          },
        },
      ],
    });

    // Open panel immediately on plugin activation
    app.openRightPanel?.("workbench-panel");

    // Store cleanup functions
    (this as any)._dispose = () => {
      unregisterPanel?.();
      unregisterMenu?.();
    };
  },

  deactivate(app: GeoLibreAppAPI) {
    app.closeRightPanel?.("workbench-panel");
    (this as any)._dispose?.();
  },
};

Example: Custom MapLibre Control

Add native MapLibre controls using the optional control API from docs/plugin-api.md lines 109-113:

import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins";
import { ScaleControl, FullscreenControl } from "maplibre-gl";

export const enhancedControlsPlugin: GeoLibrePlugin = {
  id: "enhanced-controls",
  name: "Enhanced Map Controls",
  version: "1.0.0",
  
  _controls: [] as maplibregl.IControl[],

  activate(app: GeoLibreAppAPI) {
    const scale = new ScaleControl({ maxWidth: 200, unit: "metric" });
    const fullscreen = new FullscreenControl();
    
    app.addMapControl(scale, "bottom-left");
    app.addMapControl(fullscreen, "top-right");
    
    this._controls = [scale, fullscreen];
  },

  deactivate(app: GeoLibreAppAPI) {
    this._controls.forEach(control => {
      app.removeMapControl?.(control);
    });
    this._controls = [];
  },
};

Example: Layer Registration with Persistence

Register custom layers that appear in GeoLibre's Layers panel and participate in state persistence:

import type { GeoLibreAppAPI, GeoLibrePlugin } from "@geolibre/plugins";

export const seismicDataPlugin: GeoLibrePlugin = {
  id: "seismic-data",
  name: "Real-time Seismic Data",
  version: "1.2.0",
  
  _layerId: null as string | null,
  _pollInterval: null as number | null,

  async activate(app: GeoLibreAppAPI) {
    // Fetch and display GeoJSON layer
    const response = await fetch("https://api.example.com/earthquakes.geojson");
    const geojson = await response.json();
    
    this._layerId = app.addGeoJsonLayer("Recent Earthquakes", geojson, {
      cluster: true,
      clusterMaxZoom: 14,
      paint: {
        "circle-color": "#ff4500",
        "circle-radius": ["step", ["get", "mag"], 4, 5, 8, 7, 12],
      },
    });

    // Set up polling for updates
    this._pollInterval = window.setInterval(async () => {
      const fresh = await fetch("https://api.example.com/earthquakes.geojson");
      // Layer update logic would go here
    }, 60000);
  },

  deactivate(app: GeoLibreAppAPI) {
    if (this._pollInterval) {
      clearInterval(this._pollInterval);
    }
    // Layer is automatically removed when plugin deactivates
    // For manual cleanup: app.removeLayer?.(this._layerId)
  },
};

Registering Your Plugin: Built-in vs. External

Built-in Plugin Registration

For plugins shipped with GeoLibre, follow this file structure:

  1. Create source file in packages/plugins/src/plugins/your-plugin.ts
  2. Export from packages/plugins/src/index.ts
  3. Register in apps/geolibre-desktop/src/hooks/usePlugins.ts

The usePlugins.ts hook initializes the PluginManager and loads built-in plugins at application startup. Example registration pattern:

import { myBasemapPlugin } from "@geolibre/plugins";

// Inside usePlugins hook
pluginManager.registerBuiltIn(myBasemapPlugin);

External Plugin Distribution

For independently distributed plugins:

  1. Compile to JavaScript bundle
  2. Create plugin.json manifest with metadata matching your GeoLibrePlugin identifiers
  3. Package as zip archive
  4. Install via Settings > Manage Plugins in GeoLibre desktop, or host manifest URL for network installation

The PluginManager loads external plugins through the same activation/deactivation flow, sandboxing them from core application code.

Build, Test, and Package

GeoLibre provides tooling for each development phase:

  • Development: npm run dev starts hot-reloading dev server
  • Type checking: npm run test:frontend validates TypeScript types against host API
  • Production build: npm run build compiles optimized plugin bundle
  • Packaging: npm run package:geolibre (from plugin template) generates installable zip with manifest

Run integration tests by installing the packaged plugin through the GeoLibre UI and verifying activation, functionality, and clean deactivation.

Key Source Files for Plugin Development

File Purpose
packages/plugins/src/types.ts GeoLibrePlugin and GeoLibreAppAPI type definitions
packages/plugins/src/plugin-manager.ts Core runtime for plugin lifecycle management
apps/geolibre-desktop/src/hooks/usePlugins.ts Built-in plugin registration boilerplate
docs/plugin-api.md Complete API reference with all optional methods
docs/user-guide/plugins.md End-user documentation for plugin installation

Summary

  • Implement the interface: Create a TypeScript module exporting a GeoLibrePlugin object with id, name, version, activate, and deactivate
  • Use the host API: Call methods on GeoLibreAppAPI to add map controls (addMapControl), sidebar panels (registerRightPanel), toolbar menus (registerToolbarMenu), and layers (addGeoJsonLayer, addTileLayer, etc.)
  • Clean up completely: Remove all registered components in deactivate to prevent memory leaks and orphaned UI elements
  • Register appropriately: Add built-in plugins through usePlugins.ts or distribute external plugins as zip packages with plugin.json manifests
  • Reference the source: Type definitions in packages/plugins/src/types.ts and the PluginManager implementation in packages/plugins/src/plugin-manager.ts define the exact contract your plugin must satisfy

Frequently Asked Questions

What is the minimum code required for a valid GeoLibre plugin?

A valid plugin requires five properties: id, name, version, activate(app), and deactivate(app). The simplest implementation provides empty lifecycle methods, though practical plugins use activate to register functionality with the GeoLibreAppAPI. See the basemap example in docs/plugin-api.md lines 41-48 for a minimal working template.

Can a plugin control other plugins?

Yes. The GeoLibreAppAPI includes optional lifecycle helpers activatePlugin(otherId) and deactivatePlugin(otherId). These methods enable plugin orchestration—for example, a "Survey Mode" plugin that automatically activates measurement tools and layer visibility controls. All API methods are optional (?) typed to ensure forward compatibility.

How do I persist plugin state between sessions?

GeoLibre handles persistence for registered layers, panels, and map state automatically. For custom plugin-specific data, use URL parameters (handled via optional plugin hooks) or the browser's localStorage/IndexedDB. The registerExternalNativeLayer method ensures direct MapLibre layers appear in the Layers panel and participate in built-in state serialization.

What happens if my plugin throws an error during activation?

The PluginManager in packages/plugins/src/plugin-manager.ts wraps plugin activation in error handling. A failed activation marks the plugin as errored and prevents it from running, but does not crash the host application. Check the browser console and GeoLibre's internal logs for stack traces. Defensive coding in activate—checking for optional API methods with ?.—prevents common failures.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →