This repository contains the frontend for all websites operated by the Digital Public Library of America. A single Next.js 15 codebase, backed by a custom Express server, serves 12 distinct sites across three site types — the main public search portal, a professionals portal, and ten regional hub sites for DPLA's partner network.
Multi-tenancy is handled entirely through environment variables (NEXT_PUBLIC_SITE_ENV, NEXT_PUBLIC_LOCAL_ID) and Next.js routing rules. There is a single codebase, but each site is built into its own Docker image — NEXT_PUBLIC_* variables are baked into the browser bundle at build time, so a separate image is required per site.
Browser → AWS WAF → AWS ALB → ECS Fargate (Express + Next.js)
│
┌───────────────┼────────────────┐
▼ ▼ ▼
DPLA Search API WordPress Mailchimp
(api.dp.la) (dpla.wpengine.com)
│
CloudFront CDN
(thumbnails)
- Architecture Overview
- Sites
- Getting Started
- Environment Variables
- Scripts
- External Services
- Deployment
- Special Features
- Security
- Accessibility
- Known Issues and Deficiencies
- Adding a New Local Hub Site
- Other Documentation
The application is a Next.js 15 app wrapped in a custom Express server. The Express layer handles:
- Multi-CPU clustering (always on in production)
- Custom routes:
GET /healthcheck,GET /robots.txt,POST /mailchimp,POST /g/contact,POST /g/feedback,GET /wp-content/*(redirects to WordPress) - All other requests are passed through to Next.js
Key architectural decisions:
- Server-side API calls only. The DPLA API key is never sent to the browser. All search queries run through
getServerSidePropsorgetStaticProps. - WordPress for editorial content. News, blog posts, Primary Source Sets copy, and site-wide settings are managed in a hosted WordPress instance. Responses are cached in-memory with a 90-second TTL to absorb WP Engine maintenance windows.
- Single codebase, multiple sites.
NEXT_PUBLIC_SITE_ENVswitches betweenuser,pro,local, andcqa(staging). For local hub sites,NEXT_PUBLIC_LOCAL_IDselects the hub's configuration fromconstants/local.js.
dp.la — main public-facing site at https://dp.la (NEXT_PUBLIC_SITE_ENV=user). Offers search across DPLA's full 50M-item collection, item detail pages, collection browsing, topic browsing, Primary Source Sets, exhibitions, curated lists, news, and donate pages. Staging: https://staging-internal.dp.la (requires DPLA_INTERNAL_ACCESS header).
pro.dp.la — portal for library professionals and DPLA partners at https://pro.dp.la (NEXT_PUBLIC_SITE_ENV=pro). Shares the same codebase and page tree as the user site but has its own branding, navigation, and WordPress content sections.
Local hub sites are partner-operated regional portals that scope the search interface to a subset of the DPLA collection (by provider.@id or by tag) and apply custom branding. Each hub has its own subdomain, logo, color palette, and optional static content pages (About, For Contributors, etc.).
NEXT_PUBLIC_LOCAL_ID |
Name | URL | Filter type |
|---|---|---|---|
aviation |
Cleared for Takeoff | aviation.dp.la | tag: aviation |
florida |
Sunshine State Digital Network | ssdn.dp.la | provider: florida |
illinois |
Illinois Digital Heritage Hub | idhh.dp.la | provider: il |
njde |
NJ/DE Digital Collective | njde.dp.la | provider: njde |
nwdh |
Northwest Digital Heritage | nwdh.dp.la | tag: nwdh |
oklahoma |
OKHUB | oklahoma.dp.la | provider: oklahoma |
plains2peaks |
Plains to Peaks Collective | ppc.dp.la | provider: p2p |
texas |
TxHub | texas.dp.la | tag: texas |
vermont |
Vermont Green Mountain Digital Archive | vt.dp.la | provider: vt |
wisconsin |
Recollection Wisconsin | recollectionwisconsin.dp.la | provider: wisconsin |
Hubs that filter by tag (aviation, nwdh, texas) show items from across all DPLA providers that carry that tag, rather than restricting by contributing organization. The full configuration for each hub — routes, facets, feature flags, branding assets — lives in constants/local.js.
See LOCAL.md for step-by-step instructions on adding or configuring a Local hub instance.
cd dpla-frontend
mise settings add idiomatic_version_file_enable_tools node # enables .node-version detection
mise use # installs and activates correct versionnpm install --global yarn
yarncp .env.example .env
# Edit .env — see Environment Variables section belowyarn run devOpen http://localhost:3000. By default the server runs as the user site (dp.la). To run a local hub, set NEXT_PUBLIC_SITE_ENV=local and NEXT_PUBLIC_LOCAL_ID=wisconsin (for example) in your .env before starting.
The Docker container requires environment variables at both build time and run time: Next.js bakes NEXT_PUBLIC_* variables into the browser bundle at build time, while the Express server reads the rest at runtime.
docker build -t dpla-frontend .
docker run --env-file .env -p 3000:3000 dpla-frontendSee package.json for additional docker:* helper scripts.
| Variable | Description |
|---|---|
NODE_ENV |
development or production |
NEXT_PUBLIC_SITE_ENV |
Site type: user, pro, local, or cqa (staging/QA) |
NEXT_PUBLIC_LOCAL_ID |
Hub ID for local sites — must match a key in constants/local.js (e.g. wisconsin) |
NEXT_PUBLIC_USER_BASE_URL |
User site URL for cross-site links (e.g. https://dp.la) |
NEXT_PUBLIC_PRO_BASE_URL |
Pro site URL for cross-site links (e.g. https://pro.dp.la) |
| Variable | Description |
|---|---|
API_KEY |
DPLA Search API key — server-side only, never sent to the browser |
API_URL |
DPLA Search API base URL (e.g. https://api.dp.la/v2 or https://api-internal.dp.la/v2 inside the VPC) |
DPLA_INTERNAL_ACCESS |
Shared secret injected as an ALB header for internal API access (production only) |
| Variable | Description |
|---|---|
NEXT_PUBLIC_WORDPRESS_URL |
WordPress base URL (e.g. https://dpla.wpengine.com) |
WP_PREVIEW_SECRET |
Draft preview secret — must match the dpla-preview-redirect mu-plugin (optional) |
WP_PREVIEW_USER |
WordPress username for draft preview (optional) |
WP_PREVIEW_APP_PASSWORD |
WordPress app password for draft preview (optional) |
| Variable | Description |
|---|---|
NEXT_PUBLIC_GA_TRACKING_ID |
The site's own GA4 measurement ID (G-…) (optional) |
NEXT_PUBLIC_GA_NETWORK_ID |
Shared GA4 measurement ID that every site also reports to (optional) |
NEXT_PUBLIC_GA_LEGACY_EVENTS |
Set to false to stop the legacy GA event. On by default. Set through the GA_LEGACY_EVENTS build arg (optional) |
SENTRY_DSN |
Server-side Sentry DSN. Set by the ECS task (optional) |
SENTRY_ENVIRONMENT |
Server-side environment tag, default development. Set by the ECS task (optional) |
NEXT_PUBLIC_SENTRY_DSN |
Browser-side Sentry DSN. Set through the SENTRY_DSN build arg (optional) |
NEXT_PUBLIC_SENTRY_ENVIRONMENT |
Browser-side environment tag, default development. Set through the SENTRY_ENVIRONMENT build arg (optional) |
SENTRY_AUTH_TOKEN |
Sentry auth token for source map upload at build time (optional) |
SENTRY_PROJECT |
Sentry project the source maps go to, default dpla-frontend. Build-time only. Set through the SENTRY_PROJECT build arg (optional) |
| Variable | Description |
|---|---|
MAILCHIMP_KEY |
Mailchimp API key for newsletter signups (optional) |
MAILCHIMP_PREFIX |
Mailchimp server prefix (e.g. us4) (optional) |
| Variable | Description |
|---|---|
MUST_FORK |
Set to true to force multi-CPU clustering in dev mode (always on in production) (optional) |
PS_COUNT |
Worker process count (defaults to number of available CPUs) (optional) |
NEXT_PUBLIC_* variables are embedded in the browser bundle at build time. All others are available only on the server.
| Command | Description |
|---|---|
yarn run dev |
Start development server |
yarn run build |
Production build (Next.js build + post-build sitemap generation) |
yarn run start |
Start production server |
yarn run lint |
Run ESLint (next lint) |
yarn run test |
Run unit tests and favicon integrity checks |
yarn run clean |
Remove node_modules, .next, and other build artifacts |
yarn run analyze |
Build with bundle analyzer (ANALYZE=true next build) |
yarn run docker:build |
Build Docker image |
yarn run docker:run |
Run Docker container |
The DPLA Search API at api.dp.la powers all search, item detail, facet browsing, and collection pages. The API key (API_KEY) is injected server-side on every request — it is never exposed to the browser.
In production, requests are routed through api-internal.dp.la (internal VPC endpoint) rather than the public API, via the DPLA_INTERNAL_ACCESS header mechanism described in Internal API Header.
The frontend also exposes a proxied API route at GET /api/items/{id[,id2,...]} that lets callers retrieve raw DPLA item JSON by ID without their own API key. See pages/api/items/ITEMS-API.md for full documentation.
Item thumbnails are served through CloudFront CDN. The thumbnail URL for any item is https://dp.la/thumb/{item-id}. Items without a thumbnail fall back to a type-based SVG placeholder.
Editorial content — news and blog posts, Primary Source Sets copy, about pages, and home page features — is managed in a hosted WordPress instance at https://dpla.wpengine.com.
Menus come from the dpla/v2/menus/<menu-slug> route in the DPLA mu-plugin on that host, not from a menu plugin. The user site reads the user-site menu and the pro site reads pro-site.
Custom fields arrive through ACF's built-in REST support, so every field group the site reads must have "Show in REST API" turned on. Requests that read the acf key pass acf_format=standard (see wpAcfUrl in lib/safeFetch.js) so image fields come back as URLs or objects instead of attachment IDs. The home, pro home, and guides page IDs live in constants/content-pages.js.
WordPress responses are cached in-memory with a 90-second TTL. If WordPress is unreachable (e.g. during WP Engine's hourly maintenance windows), the server falls back to the last cached response and logs a warning. External links in WordPress content are tagged with a .external CSS class.
Newsletter signups are handled by POST /mailchimp on the Express server. The route requires MAILCHIMP_KEY and MAILCHIMP_PREFIX. The list ID is 4c517f4bd0, with interest groups for NEWS, EDUCATION, and GENEALOGY.
Properties. Each site has its own GA4 property. Its G- measurement ID is the GA_TRACKING_ID value in the site's workflow under .github/workflows/. Six hubs leave it empty: aviation, njde, oklahoma, texas, transcribe, vermont. With GA_NETWORK_ID also empty, they send nothing. A second ID, GA_NETWORK_ID, adds a shared property. Set the same value on every site and that one property sees the whole network, with the site parameter telling sites apart. This covers hub reports that span dp.la and the hub's own site, and it covers the six hubs with no property. A build with neither ID loads no GA script. The Content Security Policy already allows google-analytics.com and googletagmanager.com.
Page views. The config call on load sends the first page view. After that, lib/gtag.js sends one page_view event per client-side navigation, with the new title and URL. GA4's Enhanced Measurement can also send a page view on every history change. Turn that off in each property (Admin → Data streams → Enhanced measurement → "Page changes based on browser history events"), or each navigation counts twice. Leave "Site search" on: the analytics dashboard reads its view_search_results events. After the change, confirm in DebugView that a search still produces view_search_results.
lib/buildGaEvent.js builds item events and lib/gtag.js sends them. Each action sends two events: a GA4-shaped one and the legacy one existing reports use.
| Event | Fired from |
|---|---|
item_view |
Item page load and prev/next navigation |
click_through |
Click on an item's link to the partner site (item page, search results, Browse by Topic, lists, primary source pages) |
browse_item |
Click on an item in Browse by Topic |
exhibition_item_view |
Exhibition viewer and exhibition home page |
primary_source_view |
Primary source page |
Every event carries the parameters below, each cut to GA4's 100-character limit. GA4 drops a blank field, and reports show it as (not set).
| Parameter | Value |
|---|---|
dpla_id |
DPLA item ID. Named to stay clear of GA4's own item_id dimension |
item_title |
Item title. On Browse by Topic, the curated WordPress title |
partner |
Hub (provider.name) |
contributor |
Contributing institution (dataProvider) |
collection |
sourceResource.collection title(s), when the record has one |
site |
user, pro, cqa, or the local hub ID (such as nwdh) |
exhibition, exhibition_section |
Exhibition slug and section path (home for the landing page). Exhibition pages only |
source_set |
Primary source set slug. On every event from a primary source page |
topic, subtopic |
Topic browse slugs. On every event from a Browse by Topic page |
GA4 shows a parameter in reports only after you register it as an event-scoped custom dimension (Admin → Custom definitions, 50 per property). Register the ones you need. The dp.la property has had the legacy event_category and event_label dimensions since July 18, 2025. dpla_id and item_title have millions of distinct values, so standard reports fold most of them into (other). Use Explorations or the BigQuery export for per-item questions.
Legacy event. With each event above, lib/gtag.js also sends the old shape. The event name is the contributing institution. event_category is "View Item : {partner}" (or Click Through, Browse Item, View Exhibition Item, View Primary Source). event_label is "{dpla_id} : {title}". This matches what shipped before, with two fixes. On Browse by Topic, event_label now carries the DPLA item id instead of the WordPress post id. On primary source pages, the events now carry the real hub and institution, looked up from the item record. The PSS data often left them unknown. The old shape has limits. GA4 cuts the event name at 40 characters, names with spaces break its rules, and it drops labels past 100 characters. The new events fix all three. Two systems read the old shape from the dp.la property: the analytics dashboard (dpla/dashboard-analytics) and ingestion3's generate_hub_stats.py. Move both to the new events first. Then set GA_LEGACY_EVENTS: "false" in a site's deploy workflow to stop sending it.
Client, server, and edge runtimes are all instrumented with @sentry/nextjs. Sentry org: dpla. Source maps are uploaded at build time when SENTRY_AUTH_TOKEN is present and are not exposed to the browser (hideSourceMaps: true). Request profiling is enabled at 100% (profilesSampleRate: 1.0).
The server reads SENTRY_DSN and SENTRY_ENVIRONMENT from the ECS task at start. The browser bundle is fixed at build time, so it takes the same two values from the SENTRY_DSN and SENTRY_ENVIRONMENT build args, which each deploy workflow passes and the Dockerfile exposes as NEXT_PUBLIC_SENTRY_DSN and NEXT_PUBLIC_SENTRY_ENVIRONMENT. Keep the workflow values in step with the ECS task, or a site's browser and server events land under different labels. Without a DSN the SDK sends nothing, so local dev and CI builds stay out of Sentry.
Two Sentry projects take events. dpla-frontend-staging takes the staging and pro-staging labels; dpla-frontend takes production, pro-production, and the hub id (texas, illinois, …) for each local hub. Source maps must go to the project that gets the events, or its stack traces stay minified. A build uploads to dpla-frontend unless SENTRY_PROJECT names another project, which the two staging workflows do. Change a workflow's DSN and you change its SENTRY_PROJECT too.
The browser side drops some events before they reach Sentry, namely scripts we did not ship (e.g. iOS translation engines, userscript managers, browser extensions). These filters live in lib/sentryClientFilters.js.
The Dockerfile uses a multi-stage build on node:22-slim:
- Builder stage — installs all dependencies, runs
next build, generatessitemap-pages.xml - Runtime stage — copies only
.next/, production-onlynode_modules/, and source files; runs as the non-rootnodeuser; usestinias PID 1
Health check endpoint: GET /healthcheck → HTTP 200
All sites run on AWS ECS Fargate with blue/green deployments managed by AWS CodeDeploy. The deployment sequence for each site:
- Trigger the Build ECR GitHub Actions workflow to push a new Docker image to ECR
- Squash-merge the PR to
main - Start the CodePipeline manually — the auto-trigger webhook is stale (see Known Issues)
- CodeBuild pulls the new image and registers a new ECS task definition (~2 min)
- CodeDeploy performs the blue/green traffic shift (~5–8 min)
ECS clusters:
| Site | Cluster | Tasks |
|---|---|---|
| dp.la | frontend-user-prod |
16 |
| pro.dp.la | frontend-pro-production |
2 |
| Staging | frontend-user-staging |
1 |
| Each local hub | One cluster per hub | varies |
There are seventeen workflows in .github/workflows/:
| Workflow | Trigger | Purpose |
|---|---|---|
node.js.yml |
PR / push | Lint check |
user-production.yml |
Manual | Build ECR image for dp.la |
user-staging.yml |
Manual | Build ECR image for staging |
pro-production.yml |
Manual | Build ECR image for pro.dp.la |
pro-staging.yml |
Manual | Build ECR image for pro staging |
local-{hub}.yml × 10 |
Manual | Build ECR image for each local hub |
generate-hub-sitemaps.yml |
Scheduled | Generate XML sitemaps for all hubs |
dpla-ingest-update.yml |
External trigger | Notify on DPLA data ingest completion |
Users can save items to named lists stored locally in the browser via localforage (IndexedDB). Lists are capped at 1,000 items (MAX_LIST_ITEMS in constants/site.js). When viewing a list, the page fetches full item metadata from GET /api/items/{ids} at runtime — only IDs are stored locally, not metadata. There is no server-side list storage or user account system.
See pages/api/items/ITEMS-API.md for the full API route specification.
Primary Source Sets (PSS) and Exhibitions are statically generated at build time from data in exhibitions-data/. PSS page copy is also fetched from WordPress. Sets are shared with the Pro site for educational use and integrated with the National History Day program.
Topic browsing uses a two-level hierarchy (topic → subtopic). Subtopic pages query a custom DPLA API endpoint (/100_per_page). See Known Issues for a caveat about this endpoint's long-term stability. Users cannot navigate directly between adjacent topics on the subtopic page — they must return to the topic listing to switch topics.
Draft posts and pages can be previewed before publishing. Requires the dpla-preview-redirect WordPress mu-plugin and the WP_PREVIEW_* environment variables. Preview requests use the WordPress REST API with HTTP Basic auth to fetch unpublished content.
robots.txt is generated dynamically by server.js based on NEXT_PUBLIC_SITE_ENV:
| Site | Behavior |
|---|---|
user (dp.la) |
Disallows /search and /qa; sets a crawl delay; specific spiders may be explicitly allowed |
pro (pro.dp.la) |
Allows all crawlers; sets a 2-second crawl delay |
local (hub sites) |
Disallows /search; sets a crawl delay; includes the hub's sitemap URLs |
cqa (staging) |
Disallows all crawlers |
AI crawlers are blocked on all sites: GPTBot, ClaudeBot, anthropic-ai, CCBot, Google-Extended, Amazonbot, Bytespider, PerplexityBot, meta-externalagent, AhrefsBot, SemrushBot, MJ12bot, DotBot.
CSP is enforced via HTTP response headers (not <meta> tags) to avoid conflicting with AWS WAF challenge pages. The policy uses sha256 hashes to allowlist the specific inline scripts injected by Next.js at bootstrap.
Important maintenance note: The sha256 hashes in next.config.js are tied to the exact content of Next.js's bootstrap scripts and must be updated manually whenever the Next.js version is bumped. The hashes are in the script-src directive. There is an open TODO to migrate to nonce-based CSP via Next.js middleware, which would eliminate this maintenance burden.
The CSP allows: Google Analytics/Tag Manager, Sentry, AWS WAF, Wikimedia, YouTube iframes, and the CloudFront CDN for fonts and media. Exception: /static/pdfjs/* paths are served with 'unsafe-eval' in script-src due to PDF.js requiring it for Type3 font compilation.
To verify that a new external URL is covered by the CSP before shipping, run node scripts/check-csp.js.
Contact forms and newsletter signups include a honeypot field (i_prefer_usps_mail). Submissions that populate this field are silently discarded server-side. AI and SEO crawlers are also blocked via robots.txt (see robots.txt above).
Production deployments set a DPLA_INTERNAL_ACCESS environment variable. The instrumentation.js Next.js lifecycle hook injects this value as a request header when calling api-internal.dp.la. An ALB listener rule checks for this header to gate access to the internal VPC endpoint. This value is never exposed to the browser.
Two VPAT 2.4 conformance reports are maintained in accessibility/:
accessibility/vpat-dpla.md— dp.laaccessibility/vpat-pro-dpla.md— pro.dp.la
These are AI-generated drafts and have not been reviewed by a human accessibility specialist. They must not be used for procurement or distributed externally.
Known accessibility gaps identified in the reports and codebase:
| Issue | WCAG criterion | Status |
|---|---|---|
Empty alt text on search result thumbnails |
1.1.1 Non-text Content | Fail |
Missing <header> and <nav> landmark regions |
1.3.6 Identify Purpose | Fail |
| Carousel navigation not keyboard-accessible | 2.1.1 Keyboard | Fail |
| No captions on video content | 1.2.2 Captions | Fail |
| Contact form validation errors not announced to screen readers | 4.1.3 Status Messages | Fail |
Newsletter signup checkbox has no <label> |
1.3.1 Info and Relationships | Fail |
| Footer icon links (Bluesky, YouTube) lack accessible names | 4.1.2 Name, Role, Value | Fail |
Skip-to-content link target missing tabindex="-1" |
2.4.1 Bypass Blocks | Partial |
role="main" used instead of <main> element |
1.3.1 Info and Relationships | Partial |
autocomplete attribute missing on email fields |
1.3.5 Identify Input Purpose | Partial |
Primary link color (#ca4316) borderline contrast on white (~4.6:1) |
1.4.3 Contrast | Borderline |
No formal accessibility statement is published on dp.la or pro.dp.la.
-
Stale CodePipeline webhook. All pipelines require a manual start after merging — the auto-trigger is broken due to an AWS CodeStar Connections migration. See AWS ECS / CodeDeploy for the fix.
-
CSP hash maintenance. The
sha256hashes innext.config.jsmust be updated on every Next.js version bump. See Content Security Policy for details and the migration path. -
Topic browse endpoint. The subtopic browse feature relies on a custom
/100_per_pageDPLA API endpoint that is not part of the public API contract and may not be supported in future API versions. -
No cross-topic navigation. Users cannot navigate from the last subtopic of one topic directly to the next topic without returning to the topic listing page.
-
JSON-LD language codes.
item.languagein JSON-LD markup returns only a human-readable language name, not an ISO 639 language code. -
No formal accessibility statement. The VPAT reports document significant unresolved accessibility issues. No accessibility statement is published on either public site.
- Add a new entry to
constants/local.jswith the hub's ID, name, filters, facets, routes, and feature flags. - Create
stylesheets/themes/{id}/theme.csswith the hub's color variables (follow WCAG contrast requirements). - Add static content to
public/static/local/{id}/— logo, favicon, hero image, and any Markdown page files referenced byroutesin step 1. - Add the subdomain mapping to
LOCAL_SUBDOMAINSinconstants/local.js. - Update
next.config.jsrewrites if the hub needs any non-standard routing. - Add a GitHub Actions workflow at
.github/workflows/local-{id}.yml(copy an existing one and setGA_TRACKING_ID, or leave it empty to skip the site property). - Provision an ECS cluster, ECR repository, CodePipeline, and CodeDeploy deployment group in AWS.
See LOCAL.md for detailed instructions on steps 1–4.
| Document | Contents |
|---|---|
| LOCAL.md | How to configure a new Local hub instance: env vars, static files, CSS theming, constants/local.js |
| pages/api/items/ITEMS-API.md | Items proxy API route: URL structure, authentication, response shape, use in the Lists feature |
| accessibility/vpat-dpla.md | VPAT 2.4 draft for dp.la (AI-generated, unreviewed) |
| accessibility/vpat-pro-dpla.md | VPAT 2.4 draft for pro.dp.la (AI-generated, unreviewed) |
| SECURITY.md | Security policy and vulnerability reporting contact |