Amazon S3 Tables with Next.js and Vercel

This demo uses Amazon S3 Tables with Next.js to compare movie-catalog snapshots stored as an Apache Iceberg table. It is able to securely connect to S3 Tables without using hardcoded access tokens through Vercel's OIDC Federation.
Getting Started:
Click the "Deploy" button to clone this repo, create a new Vercel project, set up the AWS integration, and provision a new S3 Tables table bucket:
Once the process is complete, you can clone the newly created GitHub repository and start making changes locally.
Deploying provisions everything. The build creates the namespace and Iceberg table inside the table bucket and loads the catalog data, so there is nothing to run by hand.
vercel env pull
vercel env pull reads the project's Development environment. The Deploy button connects
the table bucket to every environment, but connecting it from the dashboard leaves Development
unchecked by default. If .env.local has no AWS_* variables, open the S3 Tables resource in
the Vercel dashboard, edit the project connection, enable Development, and pull again.
npm install
npm run db:setup
npm run dev
Other commands:
npm run db:check # report table bucket and table statenpm run db:append # append the next monthly snapshotnpm run db:migrate # create the namespace and table onlynpm run db:seed # write the baseline snapshots only
Next.js on Vercel→ Vercel OIDC federation→ @aws-sdk/client-s3tables (create namespace and table)→ DuckDB + Iceberg REST endpoint (read and write rows)→ catalog.movie_catalog_snapshots
The Vercel AWS integration provisions the S3 Tables table bucket. npm run db:setup
creates the namespace and the Iceberg table inside it and loads the baseline data. Every step
is idempotent, so re-running is safe.
Two clients are involved because they do different jobs. @aws-sdk/client-s3tables covers
table and schema management, but has no row-level API. Rows move through an Iceberg client,
so reads and writes use DuckDB against the S3 Tables Iceberg REST endpoint:
ATTACH '<table-bucket-arn>' AS s3t (TYPE ICEBERG, ENDPOINT_TYPE 'S3_Tables');
The table stores one row per title per snapshot, and the first seed writes two, one month apart. Presence is the signal — a retired title simply has no row in the later snapshot — so the catalog changes view derives Added, Removed, and Licence renewed by diffing the two most recent snapshots rather than reading a status column.
Which snapshots are compared is read from the table, not configured, so appending a snapshot moves the comparison forward on its own.
That is the point of the demo: catalog history stays queryable over time without the application team managing Parquet files, table metadata, compaction, or snapshot cleanup.
Each of the three views is a fixed, preset query rather than an open-ended query workspace:
| View | Query |
|---|---|
| Catalog changes | FULL OUTER JOIN between the two most recent snapshots |
| License exposure | Rights ending within 90 days of the current snapshot |
| Metadata quality | Titles with incomplete metadata in the current snapshot |
Reads are cached for 60 seconds. A cache hit never opens DuckDB, which keeps the native binding and Iceberg extension off the request path. The window is deliberately short so a newly appended snapshot shows up promptly.
All S3 Tables access is server-side. No table identifier or credential is exposed to Client Components.
Supplied by the Vercel AWS integration:
AWS_ACCOUNT_IDAWS_REGIONAWS_RESOURCE_ARNAWS_ROLE_ARNS3_TABLE_BUCKET_NAME
If you connected the table bucket with the aws_s3_tables_ prefix, the prefixed names
(aws_s3_tables_AWS_ROLE_ARN and so on) are read first.
Identifies the catalog table:
S3_TABLES_NAMESPACE=catalogS3_TABLES_TABLE_NAME=movie_catalog_snapshots
GET /api/status reports whether the required variables are present, and the names of any
that are missing. It never returns account IDs, role ARNs, resource ARNs, or bucket names.
GET /api/catalog returns the same catalog data the page renders, uncached.
vercel.json runs provisioning before the build:
"buildCommand": "npm run db:setup && npm run build"
db:setup creates the namespace and Iceberg table if absent, then writes the baseline
snapshots if the table is empty. Both halves are idempotent, so every deployment runs it and
only the first does any work. It never fails the build — if the AWS integration is not
connected yet, the site still deploys and the page explains what is missing.
DuckDB ships a native binding per platform, so next.config.ts keeps it out of the bundle
and traces the binding into every route that queries the catalog. Both are required for
deployment to work.
The page has two controls for exploring how the table changes over time:
The derivation is deterministic, so the same starting table always produces the same next snapshot. Appending stops at six snapshots; reset to continue.
The same operations are available from the command line:
npm run db:append
Reset has no in-place equivalent, because DuckDB's Iceberg support implements neither DELETE
nor snapshot rollback. The reset control drops the table and rebuilds it, which is also what
to do by hand:
aws s3tables delete-table \--table-bucket-arn "$AWS_RESOURCE_ARN" \--namespace catalog --name movie_catalog_snapshotsnpm run db:setup
app/ Next.js App Router entry points and global stylesapp/page.tsx Server component; reads the cached catalog snapshotapp/api/status/route.ts Configuration readiness endpointapp/api/catalog/route.ts Catalog data as JSON, uncachedcomponents/catalog-insights.tsx Single-page catalog snapshot experiencelib/catalog.ts Shared typeslib/s3-tables-config.ts Server-only S3 Tables environment-variable contractlib/s3-tables-client.ts S3 Tables client using Vercel OIDC credentialslib/s3-tables-metadata.ts Table bucket and table state querieslib/catalog-table-setup.ts Idempotent namespace/table creation and Iceberg schemalib/db/duckdb.ts DuckDB connection, Iceberg attach, local snapshot copylib/db/queries.ts The three preset catalog querieslib/db/catalog-data.ts Cached loader used by the pagelib/db/setup.ts Build-time provisioning: migrate then seedlib/db/migrate.ts Creates the namespace and Iceberg tablelib/db/seed.ts Writes the baseline snapshotslib/db/append.ts Appends the next monthly snapshotlib/db/catalog-mutations.ts Shared seed/append/reset logiclib/db/snapshot-io.ts Row writing and next-snapshot derivationlib/db/actions.ts Server actions behind the page controlslib/db/seed-data.ts Baseline snapshot definitionslib/db/env.ts Loads local .env files (including vercel pull output) for the CLI scriptslib/db/check.ts Reports table bucket and table state.env.example Variable names only—no secrets