Skip to main content

Architecture Overview

This document describes how the unhide-ui codebase is structured and how its parts work together.


The big picture

The application has two sides that live in the same Next.js project:

  • The UI — React pages and components that the user interacts with in the browser.
  • The API — A set of server-side API routes (/api/*) that the UI calls. These routes query the OpenSearch backend and return structured data.
Browser  ──→  Next.js Pages (UI)


Next.js API Routes ──→ OpenSearch Cluster

The UI never talks to OpenSearch directly. All data goes through the API routes.


Directory structure

src/
├── app/
│ ├── api/ # Server-side API route handlers
│ │ ├── search/ # POST /api/search
│ │ ├── categories/ # GET /api/categories
│ │ ├── details/ # GET /api/details/[documentType]/[id]
│ │ │ └── related/ # POST /api/details/related
│ │ ├── filters/ # POST /api/filters/search
│ │ ├── sources/ # POST /api/sources
│ │ ├── health/ # GET /api/health
│ │ └── utils/ # Shared server-side utilities
│ │ ├── client.ts # OpenSearch client singleton
│ │ ├── queries.ts # Query builder functions
│ │ ├── transformers.ts # Response transformer functions
│ │ ├── enrichers.ts # Data enrichment (logos, sources)
│ │ ├── helpers.ts # Helper functions
│ │ └── defaults.ts # Constants and configuration
│ │
│ └── [locale]/ # Internationalised pages (en / de)
│ ├── page.tsx # Landing page (/)
│ ├── results/
│ │ ├── page.tsx # Search results page (/results)
│ │ ├── details/ # Detail view (/results/details)
│ │ └── overview/ # Overview charts (/results/overview)
│ ├── privacy-policy/ # Privacy policy page
│ └── layout.tsx # Root layout (header, footer, providers)

├── components/
│ ├── app/ # Feature components
│ │ ├── HeroSection.tsx
│ │ ├── SearchSection.tsx
│ │ ├── InfoCards/
│ │ └── results/ # Search results components
│ │ ├── BackBar.tsx
│ │ ├── CategoryBar.tsx
│ │ └── ListResults/ # Result list, filters, pagination
│ └── layout/ # Structural components
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── Announcements.tsx
│ ├── Error.tsx
│ └── Spinner.tsx

├── contexts/ # React Context providers
│ ├── CategoryContext.tsx # Category counts and active category state
│ └── FilterDialogContext.tsx

├── hooks/ # Custom React hooks
│ ├── useRelatedResults.ts # Related items fetching with LRU cache
│ ├── useLocalStorage.ts # Persist state to localStorage
│ └── useDebounce.ts # Debounce a value

├── types/ # TypeScript type definitions
│ ├── common.ts # Entity types and shared types
│ ├── api.ts # Zod schemas for API requests
│ ├── opensearch.ts # OpenSearch query/response types
│ ├── ui.ts # UI component prop types
│ ├── related.ts # Related items types
│ └── source.ts # Source types

├── utils/ # Client-side utility functions
│ ├── apiCall.ts # Functions for calling the API routes
│ ├── dateFormatter.ts
│ ├── textUtils.ts
│ ├── displayFormatters.tsx
│ └── typeGuards.ts

├── i18n/ # Internationalisation config
│ ├── routing.ts # Locale routing settings
│ └── request.ts # Per-request locale handler

└── locales/ # Translation files
├── en.json
├── de.json
└── constants.ts

The eight entity types

Everything in this app revolves around eight types of research entity, each stored in its own OpenSearch index:

Entity typeOpenSearch indexDisplay label
datasethkg-datasetDatasets
documenthkg-documentDocuments
personhkg-personExperts
institutionhkg-institutionInstitutions
softwarehkg-softwareSoftware
eventhkg-eventTrainings
instrumenthkg-instrumentInstruments
datacataloghkg-datacatalogData Catalog

All entity types share a common base (SchemaOrgBase in src/types/common.ts), aligned with schema.org vocabulary. Some types extend a CreativeWorkBase (datasets, documents, software, data catalogs).


How the API layer works

Every API route follows the same four-step pipeline:

Request → Validate → Query → Transform → Respond
  1. Validate — The request body is parsed and validated against a Zod schema (defined in src/types/api.ts). Invalid requests are rejected with a 400 error before any query runs.

  2. Query — A pure function in src/app/api/utils/queries.ts builds the OpenSearch query object. These functions take parameters and return a plain query body — they do not execute anything themselves.

  3. Execute — The query is sent to OpenSearch via the client singleton in src/app/api/utils/client.ts. The singleton ensures only one connection is created per server process.

  4. Transform — A pure function in src/app/api/utils/transformers.ts converts the raw OpenSearch response into clean, typed data. Enrichment (logos, favicons, source metadata) is added by src/app/api/utils/enrichers.ts.

This separation makes each step independently testable.


How data flows through the UI

Search flow

  1. Initial search
  • The user types a query and selects a category in SearchSection.tsx.
  • The browser navigates to /results?searchText=...&category=....
  • On mount, CategoryContext fetches counts for all 8 entity types and ListResults fetches the first page of results.
  1. Search update
  • When the user changes the search text, CategoryContext re-fetches category counts with the new term (it watches searchText via a useEffect)
  • ListResults re-fetches results
  • Filters are reset.
  1. Filter or page change
    • Updating a filter or navigating to a new page only updates the URL params.
    • ListResults re-fetches with the new params; category counts are not re-fetched.

Details flow

  1. The user clicks a result card and navigates to /results/details?id=...&category=....
  2. The page fetches the full record from GET /api/details/[documentType]/[id].
  3. The useRelatedResults hook checks its module-level LRU cache. On a cache miss, it calls POST /api/details/related, loading all 8 categories in parallel.
  4. Results are cached (up to 50 entries). Pagination per category is supported.

Filter search flow

  1. As the user types in a filter search box, useDebounce delays the request until typing stops.
  2. POST /api/filters/search is called with the document type, the filter field name, and the current search term.
  3. The API runs an aggregation query with regex matching, scoped to the currently active filters.
  4. Matching filter values are returned and shown in the dropdown.

State management

The app uses three complementary mechanisms for state:

MechanismUsed forWhere
URL search paramsSearch text, active category, filters, paginationAll results pages
React ContextCategory counts and the active categoryCategoryContext.tsx
Custom hooksRelated items (with LRU cache), debounced values, localStoragesrc/hooks/

URL params are the source of truth for everything search-related. This means search state is bookmarkable and shareable.


Internationalisation (i18n)

The app currently supports English only. But there are plans to add support for other languages in the future, starting with German. As such part of the app is already structured to support multiple locales. All pages live under a [locale] route segment:

  • / → English (default locale is served without prefix)
  • /de/ → German

Translation strings are in src/locales/en.json and src/locales/de.json. The next-intl library handles routing and provides a useTranslations() hook for components.

Locale detection is automatic based on browser settings. The default locale is en.


Key conventions

  • Pure functions for queries and transformersqueries.ts and transformers.ts contain no side effects and are easy to unit test.
  • Zod for all API input validation — All request schemas are co-located in src/types/api.ts.
  • TypeScript strict mode — All code is strongly typed. The SchemaOrgResultItem union type in src/types/common.ts is the central data type for all entities.
  • NEXT_PRIVATE_* vs NEXT_PUBLIC_* — Variables starting with NEXT_PRIVATE_ are server-only; they are never sent to the browser. Variables starting with NEXT_PUBLIC_ are available client-side.
  • pnpm only — The project enforces pnpm as the package manager via the packageManager field in package.json.

Further reading