11. Chapter 11: React and Next.js for Web GIS#

11.1. Learning Objectives#

By the end of this chapter, you will understand:

  • How to build scalable Web GIS applications using React

  • Next.js features that enhance Web GIS development

  • State management patterns for geospatial applications

  • Component-based architecture for mapping interfaces

  • Server-side rendering and performance optimization for maps

11.2. React for Web GIS Applications#

React has become the de facto standard for building modern web applications, and its component-based architecture provides excellent benefits for Web GIS development. The declarative nature of React, combined with its powerful ecosystem, makes it ideal for building complex, interactive mapping applications that need to manage substantial amounts of geospatial data and user interactions.

11.2.1. Why React for Web GIS?#

Component Reusability: React’s component-based architecture enables you to build reusable mapping components that can be shared across different parts of your application or even different projects. A well-designed Map component can encapsulate complex mapping logic while providing a clean, declarative interface for other developers to use.

State Management: Geospatial applications often need to manage complex state including map view parameters, layer visibility, selected features, filter criteria, and user preferences. React’s state management capabilities, combined with tools like Redux or Zustand, provide robust solutions for handling this complexity.

Ecosystem Integration: The React ecosystem includes thousands of packages that complement Web GIS development, from UI component libraries and form handling to data fetching and animation. This rich ecosystem accelerates development and provides battle-tested solutions for common problems.

Performance Optimization: React’s virtual DOM and reconciliation algorithm help optimize performance when dealing with dynamic map interfaces. Features like memoization, lazy loading, and code splitting are essential for Web GIS applications that often handle large datasets and complex visualizations.

Developer Experience: React’s excellent developer tools, hot reloading capabilities, and strong TypeScript support create a productive development environment for building sophisticated mapping applications.

11.2.2. React Concepts for Web GIS#

Understanding key React concepts is crucial for building effective Web GIS applications:

Functional Components and Hooks: Modern React development relies heavily on functional components and hooks, which provide a clean way to manage state and side effects in mapping applications.

Effect Management: Web GIS applications often need to handle side effects like data fetching, map initialization, and event listeners. React’s useEffect hook provides the foundation for managing these operations safely.

Context and State Sharing: Mapping applications frequently need to share state between components that aren’t directly related in the component tree. React Context provides an elegant solution for sharing map state, user preferences, and application settings.

Performance Considerations: Web GIS applications can be resource-intensive, making React’s performance optimization features like useMemo, useCallback, and React.memo particularly important.

11.3. Building React Components for Maps#

11.3.1. Basic Map Component Structure#

Creating a robust, reusable Map component forms the foundation of any React-based Web GIS application.

import React, { useEffect, useRef, useState, useCallback } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

interface MapProps {
  style?: string;
  center?: [number, number];
  zoom?: number;
  onMapLoad?: (map: maplibregl.Map) => void;
  onMapClick?: (event: maplibregl.MapMouseEvent) => void;
  children?: React.ReactNode;
  className?: string;
}

interface MapContextValue {
  map: maplibregl.Map | null;
  isLoaded: boolean;
}

const MapContext = React.createContext<MapContextValue>({
  map: null,
  isLoaded: false
});

export const useMap = () => {
  const context = React.useContext(MapContext);
  if (!context) {
    throw new Error('useMap must be used within a MapProvider');
  }
  return context;
};

export const Map: React.FC<MapProps> = ({
  style = 'https://demotiles.maplibre.org/style.json',
  center = [0, 0],
  zoom = 2,
  onMapLoad,
  onMapClick,
  children,
  className = ''
}) => {
  const mapContainer = useRef<HTMLDivElement>(null);
  const mapRef = useRef<maplibregl.Map | null>(null);
  const [isLoaded, setIsLoaded] = useState(false);

  // Initialize map
  useEffect(() => {
    if (!mapContainer.current) return;

    const map = new maplibregl.Map({
      container: mapContainer.current,
      style,
      center,
      zoom,
      antialias: true
    });

    mapRef.current = map;

    const handleLoad = () => {
      setIsLoaded(true);
      onMapLoad?.(map);
    };

    const handleClick = (e: maplibregl.MapMouseEvent) => {
      onMapClick?.(e);
    };

    map.on('load', handleLoad);
    map.on('click', handleClick);

    return () => {
      map.off('load', handleLoad);
      map.off('click', handleClick);
      map.remove();
      mapRef.current = null;
      setIsLoaded(false);
    };
  }, [style, center, zoom, onMapLoad, onMapClick]);

  const contextValue: MapContextValue = {
    map: mapRef.current,
    isLoaded
  };

  return (
    <MapContext.Provider value={contextValue}>
      <div 
        ref={mapContainer} 
        className={`map-container ${className}`}
        style={{ width: '100%', height: '100%' }}
      />
      {isLoaded && children}
    </MapContext.Provider>
  );
};

// Usage example
const MapExample: React.FC = () => {
  const handleMapLoad = useCallback((map: maplibregl.Map) => {
    console.log('Map loaded successfully');
    
    // Add navigation controls
    map.addControl(new maplibregl.NavigationControl(), 'top-right');
    
    // Add scale control
    map.addControl(new maplibregl.ScaleControl(), 'bottom-left');
  }, []);

  const handleMapClick = useCallback((event: maplibregl.MapMouseEvent) => {
    console.log('Map clicked at:', event.lngLat);
  }, []);

  return (
    <div style={{ height: '500px' }}>
      <Map
        center={[-74.006, 40.7128]}
        zoom={12}
        onMapLoad={handleMapLoad}
        onMapClick={handleMapClick}
      >
        {/* Child components can access map via context */}
      </Map>
    </div>
  );
};

11.3.2. Layer Management Components#

Building reusable components for different layer types enables modular map construction.

// Base Layer Component
interface LayerProps {
  id: string;
  visible?: boolean;
  beforeId?: string;
}

const useLayerEffect = (
  layerFn: (map: maplibregl.Map) => void,
  cleanupFn: (map: maplibregl.Map, layerId: string) => void,
  dependencies: React.DependencyList,
  layerId: string
) => {
  const { map, isLoaded } = useMap();

  useEffect(() => {
    if (!map || !isLoaded) return;

    layerFn(map);

    return () => {
      if (map && map.getLayer(layerId)) {
        cleanupFn(map, layerId);
      }
    };
  }, [map, isLoaded, ...dependencies]);
};

// GeoJSON Layer Component
interface GeoJSONLayerProps extends LayerProps {
  data: GeoJSON.FeatureCollection;
  paint?: any;
  layout?: any;
  type?: 'fill' | 'line' | 'circle' | 'symbol';
  onFeatureClick?: (feature: GeoJSON.Feature) => void;
}

export const GeoJSONLayer: React.FC<GeoJSONLayerProps> = ({
  id,
  data,
  paint = {},
  layout = {},
  type = 'fill',
  visible = true,
  beforeId,
  onFeatureClick
}) => {
  const { map } = useMap();

  useLayerEffect(
    (map) => {
      // Add source
      map.addSource(id, {
        type: 'geojson',
        data
      });

      // Add layer
      map.addLayer({
        id,
        type,
        source: id,
        paint,
        layout: {
          ...layout,
          visibility: visible ? 'visible' : 'none'
        }
      }, beforeId);

      // Add click handler if provided
      if (onFeatureClick) {
        const handleClick = (e: maplibregl.MapMouseEvent) => {
          const features = map.queryRenderedFeatures(e.point, {
            layers: [id]
          });
          
          if (features.length > 0) {
            onFeatureClick(features[0] as GeoJSON.Feature);
          }
        };

        map.on('click', id, handleClick);

        // Store handler for cleanup
        (map as any)[`_${id}_clickHandler`] = handleClick;
      }
    },
    (map, layerId) => {
      // Cleanup click handler
      const handler = (map as any)[`_${layerId}_clickHandler`];
      if (handler) {
        map.off('click', layerId, handler);
        delete (map as any)[`_${layerId}_clickHandler`];
      }

      // Remove layer and source
      if (map.getLayer(layerId)) {
        map.removeLayer(layerId);
      }
      if (map.getSource(layerId)) {
        map.removeSource(layerId);
      }
    },
    [data, paint, layout, visible, beforeId, onFeatureClick],
    id
  );

  // Update visibility
  useEffect(() => {
    if (map && map.getLayer(id)) {
      map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
    }
  }, [map, id, visible]);

  return null;
};

// Marker Component
interface MarkerProps {
  coordinates: [number, number];
  children?: React.ReactNode;
  onClick?: () => void;
  className?: string;
}

export const Marker: React.FC<MarkerProps> = ({
  coordinates,
  children,
  onClick,
  className
}) => {
  const { map, isLoaded } = useMap();
  const markerRef = useRef<maplibregl.Marker | null>(null);
  const elementRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!map || !isLoaded || !elementRef.current) return;

    const marker = new maplibregl.Marker({
      element: elementRef.current
    })
      .setLngLat(coordinates)
      .addTo(map);

    markerRef.current = marker;

    return () => {
      marker.remove();
      markerRef.current = null;
    };
  }, [map, isLoaded, coordinates]);

  useEffect(() => {
    if (markerRef.current) {
      markerRef.current.setLngLat(coordinates);
    }
  }, [coordinates]);

  if (!isLoaded) return null;

  return (
    <div
      ref={elementRef}
      className={className}
      onClick={onClick}
      style={{ cursor: onClick ? 'pointer' : 'default' }}
    >
      {children}
    </div>
  );
};

// Popup Component
interface PopupProps {
  coordinates: [number, number];
  children: React.ReactNode;
  onClose?: () => void;
  closeButton?: boolean;
  className?: string;
}

export const Popup: React.FC<PopupProps> = ({
  coordinates,
  children,
  onClose,
  closeButton = true,
  className
}) => {
  const { map, isLoaded } = useMap();
  const popupRef = useRef<maplibregl.Popup | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!map || !isLoaded || !containerRef.current) return;

    const popup = new maplibregl.Popup({
      closeButton,
      closeOnClick: false
    })
      .setLngLat(coordinates)
      .setDOMContent(containerRef.current)
      .addTo(map);

    popupRef.current = popup;

    const handleClose = () => {
      onClose?.();
    };

    popup.on('close', handleClose);

    return () => {
      popup.off('close', handleClose);
      popup.remove();
      popupRef.current = null;
    };
  }, [map, isLoaded, coordinates, closeButton, onClose]);

  if (!isLoaded) return null;

  return (
    <div ref={containerRef} className={className}>
      {children}
    </div>
  );
};

11.3.3. Advanced Map Interactions#

Building sophisticated interaction patterns requires careful state management and event handling.

// Custom hook for map interactions
interface UseMapInteractionsOptions {
  enableDrawing?: boolean;
  enableMeasurement?: boolean;
  enableSelection?: boolean;
}

interface MapInteractionsState {
  mode: 'pan' | 'draw' | 'measure' | 'select';
  isDrawing: boolean;
  selectedFeatures: GeoJSON.Feature[];
  measurements: Array<{
    id: string;
    type: 'distance' | 'area';
    value: number;
    coordinates: number[][];
  }>;
}

export const useMapInteractions = (options: UseMapInteractionsOptions = {}) => {
  const { map, isLoaded } = useMap();
  const [state, setState] = useState<MapInteractionsState>({
    mode: 'pan',
    isDrawing: false,
    selectedFeatures: [],
    measurements: []
  });

  // Drawing functionality
  const startDrawing = useCallback(() => {
    if (!map) return;

    setState(prev => ({ ...prev, mode: 'draw', isDrawing: true }));
    map.getCanvas().style.cursor = 'crosshair';

    let drawingCoordinates: number[][] = [];
    let currentLine: maplibregl.Marker[] = [];

    const handleClick = (e: maplibregl.MapMouseEvent) => {
      const coords = [e.lngLat.lng, e.lngLat.lat];
      drawingCoordinates.push(coords);

      // Add marker for visual feedback
      const marker = new maplibregl.Marker({ color: 'red' })
        .setLngLat(e.lngLat)
        .addTo(map);
      
      currentLine.push(marker);

      // Update line if we have more than one point
      if (drawingCoordinates.length > 1) {
        updateDrawingLine(drawingCoordinates);
      }
    };

    const handleDoubleClick = (e: maplibregl.MapMouseEvent) => {
      e.preventDefault();
      finishDrawing();
    };

    const updateDrawingLine = (coordinates: number[][]) => {
      const lineData: GeoJSON.FeatureCollection = {
        type: 'FeatureCollection',
        features: [{
          type: 'Feature',
          geometry: {
            type: 'LineString',
            coordinates
          },
          properties: {}
        }]
      };

      if (map.getSource('drawing-line')) {
        (map.getSource('drawing-line') as maplibregl.GeoJSONSource).setData(lineData);
      } else {
        map.addSource('drawing-line', {
          type: 'geojson',
          data: lineData
        });

        map.addLayer({
          id: 'drawing-line',
          type: 'line',
          source: 'drawing-line',
          paint: {
            'line-color': '#ff0000',
            'line-width': 2
          }
        });
      }
    };

    const finishDrawing = () => {
      setState(prev => ({ ...prev, mode: 'pan', isDrawing: false }));
      map.getCanvas().style.cursor = '';

      // Clean up
      map.off('click', handleClick);
      map.off('dblclick', handleDoubleClick);
      
      currentLine.forEach(marker => marker.remove());
      
      if (map.getLayer('drawing-line')) {
        map.removeLayer('drawing-line');
        map.removeSource('drawing-line');
      }

      // Create final feature
      if (drawingCoordinates.length > 1) {
        const feature: GeoJSON.Feature = {
          type: 'Feature',
          geometry: {
            type: 'LineString',
            coordinates: drawingCoordinates
          },
          properties: {
            id: Date.now().toString(),
            createdAt: new Date().toISOString()
          }
        };

        onFeatureDrawn?.(feature);
      }
    };

    map.on('click', handleClick);
    map.on('dblclick', handleDoubleClick);

    // Store cleanup function
    (map as any)._drawingCleanup = () => {
      map.off('click', handleClick);
      map.off('dblclick', handleDoubleClick);
      currentLine.forEach(marker => marker.remove());
      if (map.getLayer('drawing-line')) {
        map.removeLayer('drawing-line');
        map.removeSource('drawing-line');
      }
    };
  }, [map]);

  // Selection functionality
  const startSelection = useCallback(() => {
    if (!map) return;

    setState(prev => ({ ...prev, mode: 'select' }));

    const handleClick = (e: maplibregl.MapMouseEvent) => {
      const features = map.queryRenderedFeatures(e.point);
      
      if (features.length > 0) {
        const feature = features[0] as GeoJSON.Feature;
        
        setState(prev => {
          const isSelected = prev.selectedFeatures.some(
            f => f.properties?.id === feature.properties?.id
          );

          if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
            // Multi-select
            return {
              ...prev,
              selectedFeatures: isSelected
                ? prev.selectedFeatures.filter(f => f.properties?.id !== feature.properties?.id)
                : [...prev.selectedFeatures, feature]
            };
          } else {
            // Single select
            return {
              ...prev,
              selectedFeatures: isSelected ? [] : [feature]
            };
          }
        });
      } else {
        // Clear selection when clicking empty space
        setState(prev => ({ ...prev, selectedFeatures: [] }));
      }
    };

    map.on('click', handleClick);

    // Store cleanup function
    (map as any)._selectionCleanup = () => {
      map.off('click', handleClick);
    };
  }, [map]);

  // Measurement functionality
  const startMeasurement = useCallback(() => {
    if (!map) return;

    setState(prev => ({ ...prev, mode: 'measure' }));
    
    let measurementPoints: number[][] = [];
    let markers: maplibregl.Marker[] = [];

    const handleClick = (e: maplibregl.MapMouseEvent) => {
      const coords = [e.lngLat.lng, e.lngLat.lat];
      measurementPoints.push(coords);

      const marker = new maplibregl.Marker({ color: 'blue' })
        .setLngLat(e.lngLat)
        .addTo(map);
      
      markers.push(marker);

      if (measurementPoints.length === 2) {
        // Calculate distance
        const distance = calculateDistance(measurementPoints[0], measurementPoints[1]);
        
        const measurement = {
          id: Date.now().toString(),
          type: 'distance' as const,
          value: distance,
          coordinates: measurementPoints
        };

        setState(prev => ({
          ...prev,
          measurements: [...prev.measurements, measurement],
          mode: 'pan'
        }));

        // Show measurement result
        const popup = new maplibregl.Popup()
          .setLngLat(e.lngLat)
          .setHTML(`<div>Distance: ${(distance / 1000).toFixed(2)} km</div>`)
          .addTo(map);

        // Clean up
        setTimeout(() => {
          popup.remove();
        }, 3000);

        // Reset for next measurement
        measurementPoints = [];
        markers.forEach(m => m.remove());
        markers = [];
      }
    };

    map.on('click', handleClick);

    // Store cleanup function
    (map as any)._measurementCleanup = () => {
      map.off('click', handleClick);
      markers.forEach(m => m.remove());
    };
  }, [map]);

  // Cleanup on unmount or mode change
  useEffect(() => {
    return () => {
      if (map) {
        const cleanup = (map as any)._drawingCleanup;
        if (cleanup) cleanup();
        
        const selectionCleanup = (map as any)._selectionCleanup;
        if (selectionCleanup) selectionCleanup();
        
        const measurementCleanup = (map as any)._measurementCleanup;
        if (measurementCleanup) measurementCleanup();
      }
    };
  }, [map, state.mode]);

  return {
    ...state,
    startDrawing: options.enableDrawing ? startDrawing : undefined,
    startSelection: options.enableSelection ? startSelection : undefined,
    startMeasurement: options.enableMeasurement ? startMeasurement : undefined,
    clearSelection: () => setState(prev => ({ ...prev, selectedFeatures: [] })),
    clearMeasurements: () => setState(prev => ({ ...prev, measurements: [] }))
  };
};

// Helper function for distance calculation
const calculateDistance = (coord1: number[], coord2: number[]): number => {
  const R = 6371e3; // Earth's radius in meters
  const φ1 = coord1[1] * Math.PI/180;
  const φ2 = coord2[1] * Math.PI/180;
  const Δφ = (coord2[1]-coord1[1]) * Math.PI/180;
  const Δλ = (coord2[0]-coord1[0]) * Math.PI/180;

  const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
          Math.cos(φ1) * Math.cos(φ2) *
          Math.sin(Δλ/2) * Math.sin(Δλ/2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));

  return R * c;
};

11.4. State Management for Web GIS#

11.4.1. Context-Based State Management#

For smaller to medium-sized applications, React Context provides an effective solution for managing geospatial state.

// Types for our geospatial state
interface MapState {
  viewState: {
    longitude: number;
    latitude: number;
    zoom: number;
    bearing: number;
    pitch: number;
  };
  layers: Layer[];
  selectedFeatures: GeoJSON.Feature[];
  filters: Record<string, any>;
  isLoading: boolean;
  error: string | null;
}

interface Layer {
  id: string;
  name: string;
  type: 'geojson' | 'raster' | 'vector';
  visible: boolean;
  opacity: number;
  data?: any;
  style?: any;
}

interface MapContextValue {
  state: MapState;
  actions: {
    updateViewState: (viewState: Partial<MapState['viewState']>) => void;
    addLayer: (layer: Layer) => void;
    removeLayer: (layerId: string) => void;
    toggleLayerVisibility: (layerId: string) => void;
    updateLayerOpacity: (layerId: string, opacity: number) => void;
    selectFeatures: (features: GeoJSON.Feature[]) => void;
    clearSelection: () => void;
    setFilters: (filters: Record<string, any>) => void;
    setLoading: (loading: boolean) => void;
    setError: (error: string | null) => void;
  };
}

// Initial state
const initialState: MapState = {
  viewState: {
    longitude: 0,
    latitude: 0,
    zoom: 2,
    bearing: 0,
    pitch: 0
  },
  layers: [],
  selectedFeatures: [],
  filters: {},
  isLoading: false,
  error: null
};

// Context creation
const MapContext = React.createContext<MapContextValue | null>(null);

// Custom hook for using map context
export const useMapState = () => {
  const context = React.useContext(MapContext);
  if (!context) {
    throw new Error('useMapState must be used within a MapProvider');
  }
  return context;
};

// Context provider component
export const MapProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, setState] = useState<MapState>(initialState);

  const actions = useMemo(() => ({
    updateViewState: (viewState: Partial<MapState['viewState']>) => {
      setState(prev => ({
        ...prev,
        viewState: { ...prev.viewState, ...viewState }
      }));
    },

    addLayer: (layer: Layer) => {
      setState(prev => ({
        ...prev,
        layers: [...prev.layers, layer]
      }));
    },

    removeLayer: (layerId: string) => {
      setState(prev => ({
        ...prev,
        layers: prev.layers.filter(layer => layer.id !== layerId)
      }));
    },

    toggleLayerVisibility: (layerId: string) => {
      setState(prev => ({
        ...prev,
        layers: prev.layers.map(layer =>
          layer.id === layerId
            ? { ...layer, visible: !layer.visible }
            : layer
        )
      }));
    },

    updateLayerOpacity: (layerId: string, opacity: number) => {
      setState(prev => ({
        ...prev,
        layers: prev.layers.map(layer =>
          layer.id === layerId
            ? { ...layer, opacity }
            : layer
        )
      }));
    },

    selectFeatures: (features: GeoJSON.Feature[]) => {
      setState(prev => ({
        ...prev,
        selectedFeatures: features
      }));
    },

    clearSelection: () => {
      setState(prev => ({
        ...prev,
        selectedFeatures: []
      }));
    },

    setFilters: (filters: Record<string, any>) => {
      setState(prev => ({
        ...prev,
        filters
      }));
    },

    setLoading: (loading: boolean) => {
      setState(prev => ({
        ...prev,
        isLoading: loading
      }));
    },

    setError: (error: string | null) => {
      setState(prev => ({
        ...prev,
        error
      }));
    }
  }), []);

  const contextValue: MapContextValue = {
    state,
    actions
  };

  return (
    <MapContext.Provider value={contextValue}>
      {children}
    </MapContext.Provider>
  );
};

11.4.2. Redux Integration for Complex Applications#

For larger, more complex applications, Redux provides powerful state management capabilities.

// Redux store setup
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';

// Map slice
const mapSlice = createSlice({
  name: 'map',
  initialState,
  reducers: {
    updateViewState: (state, action: PayloadAction<Partial<MapState['viewState']>>) => {
      state.viewState = { ...state.viewState, ...action.payload };
    },
    
    addLayer: (state, action: PayloadAction<Layer>) => {
      state.layers.push(action.payload);
    },
    
    removeLayer: (state, action: PayloadAction<string>) => {
      state.layers = state.layers.filter(layer => layer.id !== action.payload);
    },
    
    toggleLayerVisibility: (state, action: PayloadAction<string>) => {
      const layer = state.layers.find(l => l.id === action.payload);
      if (layer) {
        layer.visible = !layer.visible;
      }
    },
    
    updateLayerOpacity: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
      const layer = state.layers.find(l => l.id === action.payload.layerId);
      if (layer) {
        layer.opacity = action.payload.opacity;
      }
    },
    
    selectFeatures: (state, action: PayloadAction<GeoJSON.Feature[]>) => {
      state.selectedFeatures = action.payload;
    },
    
    setFilters: (state, action: PayloadAction<Record<string, any>>) => {
      state.filters = action.payload;
    },
    
    setLoading: (state, action: PayloadAction<boolean>) => {
      state.isLoading = action.payload;
    },
    
    setError: (state, action: PayloadAction<string | null>) => {
      state.error = action.payload;
    }
  }
});

// Data slice for managing geospatial data
interface DataState {
  datasets: Record<string, GeoJSON.FeatureCollection>;
  loading: Record<string, boolean>;
  errors: Record<string, string | null>;
  cache: Record<string, { data: any; timestamp: number; ttl: number }>;
}

const dataSlice = createSlice({
  name: 'data',
  initialState: {
    datasets: {},
    loading: {},
    errors: {},
    cache: {}
  } as DataState,
  reducers: {
    startLoading: (state, action: PayloadAction<string>) => {
      state.loading[action.payload] = true;
      state.errors[action.payload] = null;
    },
    
    loadSuccess: (state, action: PayloadAction<{ id: string; data: GeoJSON.FeatureCollection }>) => {
      state.datasets[action.payload.id] = action.payload.data;
      state.loading[action.payload.id] = false;
      state.errors[action.payload.id] = null;
    },
    
    loadError: (state, action: PayloadAction<{ id: string; error: string }>) => {
      state.loading[action.payload.id] = false;
      state.errors[action.payload.id] = action.payload.error;
    },
    
    cacheData: (state, action: PayloadAction<{ key: string; data: any; ttl: number }>) => {
      state.cache[action.payload.key] = {
        data: action.payload.data,
        timestamp: Date.now(),
        ttl: action.payload.ttl
      };
    },
    
    clearCache: (state, action: PayloadAction<string>) => {
      delete state.cache[action.payload];
    }
  }
});

// Async thunks for data fetching
import { createAsyncThunk } from '@reduxjs/toolkit';

export const fetchGeoJSONData = createAsyncThunk(
  'data/fetchGeoJSON',
  async ({ id, url }: { id: string; url: string }) => {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Failed to fetch data: ${response.statusText}`);
    }
    const data = await response.json();
    return { id, data };
  }
);

// Store configuration
export const store = configureStore({
  reducer: {
    map: mapSlice.reducer,
    data: dataSlice.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['map/selectFeatures'],
        ignoredPaths: ['map.selectedFeatures']
      }
    })
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// Export actions
export const mapActions = mapSlice.actions;
export const dataActions = dataSlice.actions;

// Typed hooks
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T>(selector: (state: RootState) => T) => useSelector(selector);

// Custom hooks for common operations
export const useMapData = () => {
  return useAppSelector(state => ({
    viewState: state.map.viewState,
    layers: state.map.layers,
    selectedFeatures: state.map.selectedFeatures,
    filters: state.map.filters,
    isLoading: state.map.isLoading,
    error: state.map.error
  }));
};

export const useDatasets = () => {
  return useAppSelector(state => state.data.datasets);
};

export const useLayerData = (layerId: string) => {
  return useAppSelector(state => ({
    data: state.data.datasets[layerId],
    loading: state.data.loading[layerId] || false,
    error: state.data.errors[layerId] || null
  }));
};

11.5. Next.js for Web GIS#

Next.js provides powerful features that are particularly beneficial for Web GIS applications, including server-side rendering, API routes, and performance optimizations.

11.5.1. Next.js Project Setup for Web GIS#

# Create Next.js project with TypeScript
npx create-next-app@latest webgis-app --typescript --tailwind --eslint --app

# Install Web GIS dependencies
cd webgis-app
npm install maplibre-gl @types/maplibre-gl
npm install @deck.gl/core @deck.gl/layers @deck.gl/react
npm install @turf/turf @types/geojson
npm install swr axios

# Install additional UI dependencies
npm install @headlessui/react @heroicons/react
npm install react-hot-toast react-hook-form

11.5.2. App Router Structure#

// app/layout.tsx
import './globals.css';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Inter } from 'next/font/google';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'WebGIS Application',
  description: 'Modern Web GIS built with Next.js',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

// app/providers.tsx
'use client';

import { Provider } from 'react-redux';
import { store } from '@/lib/store';
import { Toaster } from 'react-hot-toast';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <Provider store={store}>
      {children}
      <Toaster position="top-right" />
    </Provider>
  );
}

// app/page.tsx
import { MapContainer } from '@/components/map/MapContainer';
import { LayerPanel } from '@/components/ui/LayerPanel';
import { Toolbar } from '@/components/ui/Toolbar';

export default function Home() {
  return (
    <main className="h-screen flex">
      <div className="flex-1 relative">
        <MapContainer />
        <Toolbar />
      </div>
      <LayerPanel />
    </main>
  );
}

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { DashboardMap } from '@/components/dashboard/DashboardMap';
import { StatsPanel } from '@/components/dashboard/StatsPanel';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';

export default function Dashboard() {
  return (
    <div className="h-screen grid grid-cols-4 gap-4 p-4">
      <div className="col-span-3">
        <Suspense fallback={<LoadingSpinner />}>
          <DashboardMap />
        </Suspense>
      </div>
      <div className="col-span-1">
        <Suspense fallback={<LoadingSpinner />}>
          <StatsPanel />
        </Suspense>
      </div>
    </div>
  );
}

11.5.3. API Routes for GeoJSON#

// app/api/geojson/[dataset]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function GET(
  request: NextRequest,
  { params }: { params: { dataset: string } }
) {
  try {
    const { searchParams } = new URL(request.url);
    const bbox = searchParams.get('bbox');
    const limit = parseInt(searchParams.get('limit') || '1000');
    
    let whereClause = {
      dataset: params.dataset
    };

    // Add bounding box filter if provided
    if (bbox) {
      const [minLng, minLat, maxLng, maxLat] = bbox.split(',').map(Number);
      whereClause = {
        ...whereClause,
        AND: [
          { longitude: { gte: minLng, lte: maxLng } },
          { latitude: { gte: minLat, lte: maxLat } }
        ]
      };
    }

    const features = await prisma.geoFeature.findMany({
      where: whereClause,
      take: limit,
      select: {
        id: true,
        properties: true,
        geometry: true
      }
    });

    const featureCollection: GeoJSON.FeatureCollection = {
      type: 'FeatureCollection',
      features: features.map(feature => ({
        type: 'Feature',
        id: feature.id,
        properties: feature.properties as any,
        geometry: feature.geometry as GeoJSON.Geometry
      }))
    };

    return NextResponse.json(featureCollection);
  } catch (error) {
    console.error('Error fetching GeoJSON:', error);
    return NextResponse.json(
      { error: 'Failed to fetch data' },
      { status: 500 }
    );
  }
}

export async function POST(
  request: NextRequest,
  { params }: { params: { dataset: string } }
) {
  try {
    const featureCollection: GeoJSON.FeatureCollection = await request.json();

    const features = featureCollection.features.map(feature => ({
      dataset: params.dataset,
      properties: feature.properties,
      geometry: feature.geometry,
      longitude: getCoordinateValue(feature.geometry, 'longitude'),
      latitude: getCoordinateValue(feature.geometry, 'latitude')
    }));

    const result = await prisma.geoFeature.createMany({
      data: features
    });

    return NextResponse.json({ 
      message: `Created ${result.count} features`,
      count: result.count 
    });
  } catch (error) {
    console.error('Error creating features:', error);
    return NextResponse.json(
      { error: 'Failed to create features' },
      { status: 500 }
    );
  }
}

// Helper function to extract coordinate values
function getCoordinateValue(geometry: GeoJSON.Geometry, type: 'longitude' | 'latitude'): number {
  if (geometry.type === 'Point') {
    return type === 'longitude' ? geometry.coordinates[0] : geometry.coordinates[1];
  }
  // For other geometry types, return centroid or first coordinate
  // This is a simplified implementation
  return 0;
}

// app/api/tiles/[z]/[x]/[y]/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(
  request: NextRequest,
  { params }: { params: { z: string; x: string; y: string } }
) {
  const { z, x, y } = params;
  
  try {
    // Generate or fetch vector tile
    const tile = await generateVectorTile(
      parseInt(z),
      parseInt(x),
      parseInt(y)
    );

    return new NextResponse(tile, {
      headers: {
        'Content-Type': 'application/x-protobuf',
        'Cache-Control': 'public, max-age=3600',
        'Access-Control-Allow-Origin': '*'
      }
    });
  } catch (error) {
    console.error('Error generating tile:', error);
    return NextResponse.json(
      { error: 'Failed to generate tile' },
      { status: 500 }
    );
  }
}

async function generateVectorTile(z: number, x: number, y: number): Promise<Buffer> {
  // Implement vector tile generation logic
  // This could use libraries like @mapbox/vector-tile or custom logic
  return Buffer.from([]);
}

11.5.4. Server-Side Rendering for SEO#

// app/map/[id]/page.tsx
import { notFound } from 'next/navigation';
import { MapViewer } from '@/components/map/MapViewer';
import { ShareButtons } from '@/components/ui/ShareButtons';

interface MapPageProps {
  params: { id: string };
  searchParams: { [key: string]: string | string[] | undefined };
}

// Metadata generation for SEO
export async function generateMetadata({ params }: MapPageProps) {
  const mapData = await getMapData(params.id);
  
  if (!mapData) {
    return {
      title: 'Map Not Found'
    };
  }

  return {
    title: `${mapData.title} | WebGIS`,
    description: mapData.description,
    openGraph: {
      title: mapData.title,
      description: mapData.description,
      images: [`/api/map/${params.id}/preview`],
      type: 'website'
    },
    twitter: {
      card: 'summary_large_image',
      title: mapData.title,
      description: mapData.description,
      images: [`/api/map/${params.id}/preview`]
    }
  };
}

export default async function MapPage({ params, searchParams }: MapPageProps) {
  const mapData = await getMapData(params.id);
  
  if (!mapData) {
    notFound();
  }

  // Parse view parameters from URL
  const initialView = {
    longitude: parseFloat(searchParams.lng as string) || mapData.defaultView.longitude,
    latitude: parseFloat(searchParams.lat as string) || mapData.defaultView.latitude,
    zoom: parseFloat(searchParams.zoom as string) || mapData.defaultView.zoom
  };

  return (
    <div className="h-screen flex flex-col">
      <header className="bg-white shadow-sm border-b p-4">
        <div className="flex justify-between items-center">
          <div>
            <h1 className="text-2xl font-bold">{mapData.title}</h1>
            <p className="text-gray-600">{mapData.description}</p>
          </div>
          <ShareButtons mapId={params.id} />
        </div>
      </header>
      
      <main className="flex-1">
        <MapViewer 
          mapConfig={mapData.config}
          initialView={initialView}
          layers={mapData.layers}
        />
      </main>
    </div>
  );
}

async function getMapData(id: string) {
  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/maps/${id}`, {
      next: { revalidate: 3600 } // Cache for 1 hour
    });
    
    if (!response.ok) {
      return null;
    }
    
    return await response.json();
  } catch (error) {
    console.error('Error fetching map data:', error);
    return null;
  }
}

// Static generation for popular maps
export async function generateStaticParams() {
  const maps = await getPopularMaps();
  
  return maps.map((map) => ({
    id: map.id
  }));
}

async function getPopularMaps() {
  // Fetch list of popular maps to pre-generate
  return [];
}

11.5.5. Performance Optimization#

// lib/performance.ts
import { NextRequest } from 'next/server';

// Middleware for request optimization
export function middleware(request: NextRequest) {
  // Add CORS headers for tile requests
  if (request.nextUrl.pathname.startsWith('/api/tiles/')) {
    const response = NextResponse.next();
    response.headers.set('Access-Control-Allow-Origin', '*');
    response.headers.set('Cache-Control', 'public, max-age=86400');
    return response;
  }
  
  // Compress GeoJSON responses
  if (request.nextUrl.pathname.startsWith('/api/geojson/')) {
    const response = NextResponse.next();
    response.headers.set('Content-Encoding', 'gzip');
    return response;
  }
}

export const config = {
  matcher: ['/api/tiles/:path*', '/api/geojson/:path*']
};

// Custom hook for data fetching with SWR
import useSWR from 'swr';

interface UseGeoJSONOptions {
  bbox?: string;
  limit?: number;
  refreshInterval?: number;
}

export const useGeoJSON = (dataset: string, options: UseGeoJSONOptions = {}) => {
  const { bbox, limit = 1000, refreshInterval } = options;
  
  const params = new URLSearchParams();
  if (bbox) params.set('bbox', bbox);
  params.set('limit', limit.toString());
  
  const url = `/api/geojson/${dataset}?${params.toString()}`;
  
  const { data, error, isLoading, mutate } = useSWR(
    url,
    async (url: string) => {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error('Failed to fetch data');
      }
      return response.json();
    },
    {
      refreshInterval,
      revalidateOnFocus: false,
      dedupingInterval: 60000, // 1 minute
    }
  );

  return {
    data: data as GeoJSON.FeatureCollection | undefined,
    error,
    isLoading,
    refresh: mutate
  };
};

// Image optimization for map previews
import Image from 'next/image';

interface MapPreviewProps {
  mapId: string;
  width: number;
  height: number;
  alt: string;
}

export const MapPreview: React.FC<MapPreviewProps> = ({
  mapId,
  width,
  height,
  alt
}) => {
  return (
    <Image
      src={`/api/map/${mapId}/preview`}
      alt={alt}
      width={width}
      height={height}
      priority
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
    />
  );
};

// Bundle optimization
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    optimizePackageImports: ['maplibre-gl', '@deck.gl/core']
  },
  webpack: (config, { isServer }) => {
    // Optimize MapLibre GL for client-side
    if (!isServer) {
      config.resolve.fallback = {
        fs: false,
        path: false
      };
    }
    
    return config;
  },
  images: {
    domains: ['example.com'], // Add domains for external images
    formats: ['image/webp', 'image/avif']
  }
};

module.exports = nextConfig;

11.6. Summary#

React and Next.js provide powerful foundations for building modern Web GIS applications. React’s component-based architecture enables modular, reusable mapping interfaces, while its state management capabilities handle the complex data flows common in geospatial applications.

Key benefits include component reusability that accelerates development, robust state management for complex geospatial data, excellent performance optimization features, and a rich ecosystem of complementary libraries. The declarative nature of React makes it easier to reason about complex mapping interfaces and their interactions.

Next.js adds production-ready features like server-side rendering for better SEO, API routes for backend functionality, automatic code splitting for performance, and built-in optimization features. These capabilities are particularly valuable for Web GIS applications that need to handle large datasets, provide good user experiences, and rank well in search engines.

The combination of React and Next.js creates a solid foundation for building everything from simple map viewers to complex GIS analysis platforms. Understanding these patterns prepares you for the backend development concepts covered in the next chapter.

11.7. Exercises#

11.7.1. Exercise 11.1: React Component Architecture#

Objective: Build a comprehensive component library for Web GIS applications using React.

Instructions:

  1. Create reusable map components:

    • Build a base Map component with context integration

    • Create specialized layer components (GeoJSON, Raster, Vector)

    • Implement interactive components (Markers, Popups, Controls)

    • Add animation components for data transitions

  2. Implement component composition patterns:

    • Design higher-order components for common functionality

    • Create render props for flexible data visualization

    • Build compound components for complex interactions

    • Add polymorphic components for different map libraries

  3. Add comprehensive TypeScript support:

    • Define strict interfaces for all props and state

    • Create generic components for type safety

    • Add proper error boundaries and fallbacks

    • Implement comprehensive unit tests

Deliverable: A fully-typed React component library for Web GIS applications with documentation and tests.

11.7.2. Exercise 11.2: Advanced State Management#

Objective: Implement sophisticated state management patterns for complex Web GIS applications.

Instructions:

  1. Build Redux-based state management:

    • Create slices for map state, data, and user preferences

    • Implement async thunks for data fetching and caching

    • Add middleware for logging and persistence

    • Create selectors for computed state values

  2. Implement real-time state synchronization:

    • Add WebSocket integration for live data updates

    • Create optimistic updates for user interactions

    • Implement conflict resolution for concurrent edits

    • Add offline state management with sync capabilities

  3. Add performance optimizations:

    • Implement state normalization for large datasets

    • Create efficient selectors with reselect

    • Add state persistence and hydration

    • Optimize re-renders with proper memoization

Deliverable: A production-ready state management system demonstrating advanced Redux patterns and real-time capabilities.

11.7.3. Exercise 11.3: Next.js Integration#

Objective: Build a full-featured Web GIS application using Next.js with server-side rendering and API routes.

Instructions:

  1. Set up Next.js App Router structure:

    • Create pages for different map views and dashboards

    • Implement dynamic routing for map sharing

    • Add proper metadata and SEO optimization

    • Create error pages and loading states

  2. Build API routes for geospatial data:

    • Create endpoints for GeoJSON data management

    • Implement vector tile serving

    • Add authentication and authorization

    • Create data validation and error handling

  3. Optimize for performance and SEO:

    • Implement server-side rendering for map previews

    • Add proper caching strategies

    • Optimize images and static assets

    • Create sitemap and robots.txt

Deliverable: A complete Next.js Web GIS application with SSR, API routes, and production optimizations.

11.7.4. Exercise 11.4: Interactive Dashboard Development#

Objective: Create a comprehensive GIS dashboard with multiple coordinated views and real-time data.

Instructions:

  1. Design dashboard layout:

    • Create responsive grid layouts for different screen sizes

    • Implement resizable and draggable panels

    • Add customizable widget system

    • Create export and sharing capabilities

  2. Build coordinated visualizations:

    • Implement linked maps with synchronized interactions

    • Add charts and graphs connected to map data

    • Create filtering systems that update all views

    • Implement brushing and linking between components

  3. Add real-time features:

    • Integrate WebSocket connections for live data

    • Create animated transitions for data updates

    • Add real-time notifications and alerts

    • Implement data streaming with backpressure handling

Deliverable: A fully-featured GIS dashboard with real-time capabilities and coordinated visualizations.

11.7.5. Exercise 11.5: Performance Optimization#

Objective: Optimize a complex React Web GIS application for production performance.

Instructions:

  1. Implement performance monitoring:

    • Add React Profiler integration

    • Create performance metrics collection

    • Implement error tracking and reporting

    • Add user experience monitoring

  2. Optimize rendering performance:

    • Implement proper memoization strategies

    • Add virtualization for large data lists

    • Optimize map rendering with viewport culling

    • Create efficient data update patterns

  3. Optimize bundle size and loading:

    • Implement code splitting for routes and features

    • Add lazy loading for map components

    • Optimize dependencies and tree shaking

    • Create progressive loading strategies

Deliverable: Performance analysis report and optimized application with measurable improvements.

11.7.6. Exercise 11.6: Testing Strategy#

Objective: Implement comprehensive testing for React Web GIS applications.

Instructions:

  1. Unit and integration testing:

    • Test React components with React Testing Library

    • Create mocks for map libraries and external APIs

    • Test Redux state management with comprehensive scenarios

    • Add snapshot testing for component consistency

  2. End-to-end testing:

    • Implement Playwright tests for user workflows

    • Test map interactions and data loading

    • Create tests for different device sizes and capabilities

    • Add visual regression testing for map rendering

  3. Performance and accessibility testing:

    • Implement automated performance testing

    • Add accessibility audits and WCAG compliance tests

    • Test keyboard navigation and screen reader compatibility

    • Create cross-browser compatibility tests

Deliverable: Comprehensive testing suite with high coverage and automated CI/CD integration.

11.7.7. Exercise 11.7: Advanced Features Implementation#

Objective: Build advanced Web GIS features using React patterns and modern browser APIs.

Instructions:

  1. Implement offline capabilities:

    • Add service worker for caching map tiles and data

    • Create offline data synchronization

    • Implement background sync for user edits

    • Add offline indicators and graceful degradation

  2. Add collaborative features:

    • Implement real-time collaborative editing

    • Create user presence indicators

    • Add conflict resolution for simultaneous edits

    • Build commenting and annotation systems

  3. Integrate modern browser APIs:

    • Add geolocation and device orientation support

    • Implement Web Workers for heavy calculations

    • Use IndexedDB for client-side data storage

    • Add WebRTC for peer-to-peer data sharing

Deliverable: Advanced Web GIS application showcasing modern browser capabilities and collaborative features.

Reflection Questions:

  • How does React’s component model improve the maintainability of Web GIS applications?

  • What are the key considerations when managing state in complex geospatial applications?

  • How do Next.js features like SSR and API routes benefit Web GIS applications?

  • What strategies work best for optimizing performance in data-heavy mapping applications?

11.8. Further Reading#