ArcGIS Hub → PortalJS
Overview
Migrate an entire ArcGIS Hub open-data site into a PortalJS Arc portal in one pass.
Every Hub site is machine-readable — a DCAT-US catalog at /data.json, with every dataset
backed by an ArcGIS REST FeatureService — so migration is a harvest → export → convert →
publish → verify pipeline that runs almost fully automated on the operator's machine, no
server-side compute. The tooling is the reusable arcgis-to-portaljs migrator: input is one
Hub URL, output is a ready-to-deploy PortalJS catalog plus a parity report.
The skill is an orchestrator: it reuses the DCAT-US harvest from portaljs-migrate, the
ogr2ogr/tippecanoe/duckdb dual-tier conversion from portaljs-add-geo, and the bulk
Git-LFS → R2 push from portaljs-migrate. Its novel parts are the FeatureService REST export
loop (paged features, not just a link) and the source-vs-derived parity report.
Prerequisites
- A scaffolded PortalJS portal whose template ships
components/MapPreview.tsx and
components/GeoQuery.tsx (PR #1647 or later). Run portaljs-new-portal first if none.
- Native CLIs: GDAL (
ogr2ogr, ogrinfo), tippecanoe, duckdb (with spatial),
and jq. macOS: brew install gdal tippecanoe duckdb jq; Debian/Ubuntu:
apt-get install gdal-bin duckdb jq plus tippecanoe (apt or build from source); Windows via
WSL. The skill hard-stops with the install hint if any is missing.
- Arc credentials for the Git-LFS → R2 push (the token
portaljs-deploy resolves), or an OSS
self-hosted Giftless.
Instructions
The canonical, full step-by-step workflow is
.claude/commands/arcgis-to-portaljs.md
— the single source of truth. Read and follow it when executing. Summary:
- Gather input — Hub URL, portal directory, project slug, optional flags (
--limit,
--only, --dry-run, --namespace-mode). Interview if missing; never dead-end.
- Check native tools (
ogr2ogr, tippecanoe, duckdb + spatial, jq). Any missing →
print the per-OS install and stop.
- Validate the portal directory and confirm the geo showcase components exist.
- Harvest the Hub
/data.json (reuse the portaljs-migrate DCAT-US map) and classify each
item: vector (FeatureService), table, or non-data (web map / 3D / imagery → skipped).
Under --namespace-mode owner, resolve namespaces through a publisher-normalization
table with title-prefix fallback for broken {{source}} publishers (multi-publisher
Hubs ship dirty publisher labels). Dedup near-duplicate hosted-view layers — but only
after a mandatory live record-count check on BOTH twins: equal ⇒ dedup (keep the source
layer, log the pair); different ⇒ keep both as distinct datasets. Consolidate per-year
dataset series into one year-partitioned Parquet with legacy per-year view entries. Enrich
from the AGOL item: sanitized metadata (license/description/dates), cleaned display title
(cleanTitle — raw title still drives the slug), category (item categories → meaningful
theme → keyword mapping), and a thumbnail snapshot into public/thumbnails/.
- Export each vector layer through the ArcGIS REST
query API with resultOffset paging
(f=geojson, outSR=4326); fall back to keyset paging on transfer limits; accept a
customer File Geodatabase dump for very large layers.
- Convert each layer to the dual tier via the
portaljs-add-geo recipe (PMTiles +
GeoParquet); tabular items to Parquet. Preserve the native-CRS original.
- Publish — bulk Git-LFS track + one push to R2 through Giftless, then append dual-tier
datasets.json entries (upsert on (namespace, slug)).
- Write
arcgis-parity-report.md — record count, extent, attribute schema, and geometry
validity, source vs derived, per dataset, plus the migrated/skipped/failed accounting.
- Report the inventory, migrated datasets, R2 push, and parity summary.
Output
- Created:
data/<namespace>/<slug>.pmtiles, .parquet, and the original per vector
dataset (all LFS-tracked → R2); Parquet + original per table; arcgis-parity-report.md.
- Modified:
datasets.json (one dual-tier entry per vector dataset, one resource entry
per table); .gitattributes (LFS tracking).
- Verified: the parity report compares each derived artifact to the live FeatureService.
- Result:
/@<namespace>/<slug> renders <MapPreview> + <GeoQuery> for each vector
dataset with no page edits; the catalog lists everything migrated.
Error Handling
| Symptom |
Cause |
Fix |
MISSING_INPUT |
No Hub URL provided |
Pass the site root (e.g. https://hub-lewisville.opendata.arcgis.com) and retry. |
MISSING_TOOLS |
ogr2ogr/tippecanoe/duckdb/jq (or duckdb spatial) absent |
Print the per-OS install line and stop; re-run after installing. |
NOT_A_PORTAL |
Target dir has no datasets.json / geo components |
Run portaljs-new-portal first, then re-run. |
HARVEST_FAILED |
/data.json unreachable or not DCAT-US |
Confirm the site is an ArcGIS Hub and the feed loads in a browser. |
EXPORT_FAILED |
One FeatureService layer errored or hit a hard transfer cap |
Logged and skipped; try keyset paging or a customer FGDB dump for that layer. |
LFS_PUSH_FAILED |
Missing/expired Arc token or unset lfs.url |
Re-mint the JWT (see portaljs-deploy); confirm git config lfs.url. |
Examples
Example 1 — Migrate a City Hub (Lewisville)
/arcgis-to-portaljs https://hub-lewisville.opendata.arcgis.com slug=lewisville
Example 2 — Dry-run inventory + plan only (no writes)
/arcgis-to-portaljs https://streamwaterdata.co.uk --dry-run
Example 3 — Migrate a subset, one namespace per publisher
/arcgis-to-portaljs https://streamwaterdata.co.uk --only sewer-catchments,water-boundaries --namespace-mode owner
Resources
1---2name: arcgis-to-portaljs3description: Migrate a whole ArcGIS Hub site into a PortalJS Arc portal end-to-end. Harvests the Hub /data.json (DCAT-US) inventory, exports every FeatureService layer through the ArcGIS REST query API with resultOffset paging, converts each to the serverless dual tier (PMTiles render + GeoParquet query) with tabular items to Parquet, pushes everything to Cloudflare R2 via Git LFS, appends dual-tier datasets.json entries, and writes a source-vs-derived parity report. Use to move a City or sector ArcGIS Hub open-data portal onto PortalJS with no server-side compute.4license: MIT5---6
7# ArcGIS Hub → PortalJS
8
9## Overview
10
11Migrate an entire **ArcGIS Hub** open-data site into a **PortalJS Arc** portal in one pass.
12Every Hub site is machine-readable — a DCAT-US catalog at `/data.json`, with every dataset
13backed by an ArcGIS REST FeatureService — so migration is a **harvest → export → convert →
14publish → verify** pipeline that runs almost fully automated on the operator's machine, no
15server-side compute. The tooling is the reusable `arcgis-to-portaljs` migrator: input is one
16Hub URL, output is a ready-to-deploy PortalJS catalog plus a parity report.
17
18The skill is an orchestrator: it reuses the DCAT-US harvest from `portaljs-migrate`, the
19`ogr2ogr`/`tippecanoe`/`duckdb` dual-tier conversion from `portaljs-add-geo`, and the bulk
20Git-LFS → R2 push from `portaljs-migrate`. Its novel parts are the FeatureService REST export
21loop (paged features, not just a link) and the source-vs-derived parity report.
22
23## Prerequisites
24
25- A scaffolded PortalJS portal whose template ships `components/MapPreview.tsx` and
26 `components/GeoQuery.tsx` (PR #1647 or later). Run `portaljs-new-portal` first if none.
27- Native CLIs: **GDAL** (`ogr2ogr`, `ogrinfo`), **tippecanoe**, **duckdb** (with `spatial`),
28 and **jq**. macOS: `brew install gdal tippecanoe duckdb jq`; Debian/Ubuntu:
29 `apt-get install gdal-bin duckdb jq` plus tippecanoe (apt or build from source); Windows via
30 WSL. The skill hard-stops with the install hint if any is missing.
31- Arc credentials for the Git-LFS → R2 push (the token `portaljs-deploy` resolves), or an OSS
32 self-hosted Giftless.
33
34## Instructions
35
36The canonical, full step-by-step workflow is
37[`.claude/commands/arcgis-to-portaljs.md`](https://github.com/datopian/portaljs/blob/main/.claude/commands/arcgis-to-portaljs.md)
38— the single source of truth. Read and follow it when executing. Summary:
39
401. Gather input — Hub URL, portal directory, project slug, optional flags (`--limit`,
41 `--only`, `--dry-run`, `--namespace-mode`). Interview if missing; never dead-end.
422. Check native tools (`ogr2ogr`, `tippecanoe`, `duckdb` + `spatial`, `jq`). Any missing →
43 print the per-OS install and stop.
443. Validate the portal directory and confirm the geo showcase components exist.
454. Harvest the Hub `/data.json` (reuse the `portaljs-migrate` DCAT-US map) and classify each
46 item: vector (FeatureService), table, or non-data (web map / 3D / imagery → skipped).
47 Under `--namespace-mode owner`, resolve namespaces through a publisher-normalization
48 table with title-prefix fallback for broken `{{source}}` publishers (multi-publisher
49 Hubs ship dirty publisher labels). Dedup near-duplicate hosted-`view` layers — but only
50 after a mandatory live record-count check on BOTH twins: equal ⇒ dedup (keep the source
51 layer, log the pair); different ⇒ keep both as distinct datasets. Consolidate per-year
52 dataset series into one year-partitioned Parquet with legacy per-year view entries. Enrich
53 from the AGOL item: sanitized metadata (license/description/dates), cleaned display title
54 (`cleanTitle` — raw title still drives the slug), `category` (item categories → meaningful
55 theme → keyword mapping), and a `thumbnail` snapshot into `public/thumbnails/`.
565. Export each vector layer through the ArcGIS REST `query` API with `resultOffset` paging
57 (`f=geojson`, `outSR=4326`); fall back to keyset paging on transfer limits; accept a
58 customer File Geodatabase dump for very large layers.
596. Convert each layer to the dual tier via the `portaljs-add-geo` recipe (PMTiles +
60 GeoParquet); tabular items to Parquet. Preserve the native-CRS original.
617. Publish — bulk Git-LFS track + one push to R2 through Giftless, then append dual-tier
62 `datasets.json` entries (upsert on `(namespace, slug)`).
638. Write `arcgis-parity-report.md` — record count, extent, attribute schema, and geometry
64 validity, source vs derived, per dataset, plus the migrated/skipped/failed accounting.
659. Report the inventory, migrated datasets, R2 push, and parity summary.
66
67## Output
68
69- **Created:** `data/<namespace>/<slug>.pmtiles`, `.parquet`, and the original per vector
70 dataset (all LFS-tracked → R2); Parquet + original per table; `arcgis-parity-report.md`.
71- **Modified:** `datasets.json` (one dual-tier entry per vector dataset, one resource entry
72 per table); `.gitattributes` (LFS tracking).
73- **Verified:** the parity report compares each derived artifact to the live FeatureService.
74- **Result:** `/@<namespace>/<slug>` renders `<MapPreview>` + `<GeoQuery>` for each vector
75 dataset with no page edits; the catalog lists everything migrated.
76
77## Error Handling
78
79| Symptom | Cause | Fix |
80| --- | --- | --- |
81| `MISSING_INPUT` | No Hub URL provided | Pass the site root (e.g. `https://hub-lewisville.opendata.arcgis.com`) and retry. |
82| `MISSING_TOOLS` | `ogr2ogr`/`tippecanoe`/`duckdb`/`jq` (or duckdb `spatial`) absent | Print the per-OS install line and stop; re-run after installing. |
83| `NOT_A_PORTAL` | Target dir has no `datasets.json` / geo components | Run `portaljs-new-portal` first, then re-run. |
84| `HARVEST_FAILED` | `/data.json` unreachable or not DCAT-US | Confirm the site is an ArcGIS Hub and the feed loads in a browser. |
85| `EXPORT_FAILED` | One FeatureService layer errored or hit a hard transfer cap | Logged and skipped; try keyset paging or a customer FGDB dump for that layer. |
86| `LFS_PUSH_FAILED` | Missing/expired Arc token or unset `lfs.url` | Re-mint the JWT (see `portaljs-deploy`); confirm `git config lfs.url`. |
87
88## Examples
89
90### Example 1 — Migrate a City Hub (Lewisville)
91
92```
93/arcgis-to-portaljs https://hub-lewisville.opendata.arcgis.com slug=lewisville
94```
95
96### Example 2 — Dry-run inventory + plan only (no writes)
97
98```
99/arcgis-to-portaljs https://streamwaterdata.co.uk --dry-run
100```
101
102### Example 3 — Migrate a subset, one namespace per publisher
103
104```
105/arcgis-to-portaljs https://streamwaterdata.co.uk --only sewer-catchments,water-boundaries --namespace-mode owner
106```
107
108## Resources
109
110- Full workflow: [`.claude/commands/arcgis-to-portaljs.md`](https://github.com/datopian/portaljs/blob/main/.claude/commands/arcgis-to-portaljs.md)
111- REST export, classification, parity, and phasing details: [`references/reference.md`](references/reference.md)
112- Ongoing sync, parity dashboard, and cutover (Phase 3): [`references/sync-and-cutover.md`](references/sync-and-cutover.md)
113- Related skills: `portaljs-migrate`, `portaljs-add-geo`, `portaljs-add-dataset`, `portaljs-deploy`
114- ArcGIS REST query API: <https://developers.arcgis.com/rest/services-reference/enterprise/query-feature-service-layer/> · tippecanoe: <https://github.com/felt/tippecanoe> · DuckDB spatial: <https://duckdb.org/docs/extensions/spatial>