@phantomstudios/sheets-react
v0.1.0
Published
Dynamic Google Sheets data-capture utility: schema-driven form, Next.js route helper, server client, and mock client for credential-free development.
Readme
@phantomstudios/sheets-react
Schema-driven data-capture utility for AE / Phantom campaigns. A campaign passes a list of field definitions; the package provides a React <SheetsForm>, a Next.js App Router route handler, a server-side Google Sheets client, and a credential-free mock so you can build the full flow before any keys exist.
campaign repo ─► <SheetsForm schema endpoint />
│ POST /api/submit (JSON)
▼
createSheetsRoute({ client, schema })
│ validates + normalises
▼
createSheetsClient (real) or createMockSheetsClient (dev)
│
▼
Google Sheet (one row per submission)Why this design
- Dependency injection: the util never reads
process.env. The campaign decides where credentials come from. - Split server / client / mock entries:
googleapisonly ever ships in server bundles. - Schema-driven: the same
FormSchemavalidates client + server and shapes sheet headers. - Dynamic headers: a new field becomes a new column on first write (configurable strict mode).
- Per-campaign rotation: env vars only. No code changes when keys, sheets, or environments change.
Install
From a campaign or app repo (public @phantomstudios package on npm):
npm install @phantomstudios/sheets-reactUse a semver range in package.json (e.g. ^0.1.0). Do not point campaigns at this monorepo via file: paths.
Entry points
| Import path | Use from | Description |
|-------------|----------|-------------|
| @phantomstudios/sheets-react | Client + Server | SheetsForm, types, errors |
| @phantomstudios/sheets-react/server | Server only | createSheetsClient, parseServiceAccount |
| @phantomstudios/sheets-react/mock | Server (or tests) | createMockSheetsClient |
| @phantomstudios/sheets-react/route | Server (Next route) | createSheetsRoute |
Quick start (no Google credentials yet)
// app/api/submit/route.ts
import { createSheetsRoute } from "@phantomstudios/sheets-react/route";
import { createMockSheetsClient } from "@phantomstudios/sheets-react/mock";
const sheets = createMockSheetsClient({
defaultTab: "submissions",
persistTo: ".sheets-mock.json", // optional: writes appear in this file
onAppend: ({ tab, record }) => console.log("[mock]", tab, record),
});
export const POST = createSheetsRoute({
client: sheets,
source: "demo",
});// app/page.tsx
"use client";
import { SheetsForm, type FormSchema } from "@phantomstudios/sheets-react";
const schema: FormSchema = [
{ name: "email", label: "Email", type: "email", required: true },
{ name: "firstName", label: "First name", type: "text" },
{ name: "consent", label: "I agree to be contacted", type: "checkbox", mustBeChecked: true },
];
export default function Page() {
return <SheetsForm schema={schema} endpoint="/api/submit" />;
}Submit the form: a row appears in console and in .sheets-mock.json. No Google credentials needed.
Swap to real Google Sheets
// lib/sheets.ts (server only)
import "server-only";
import {
createSheetsClient,
parseServiceAccount,
} from "@phantomstudios/sheets-react/server";
export const sheets = createSheetsClient({
credentials: parseServiceAccount(process.env.GOOGLE_SERVICE_ACCOUNT_BASE64),
sheetId: process.env.SHEETS_TARGET_ID!,
defaultTab: process.env.SHEETS_DEFAULT_TAB ?? "submissions",
headerMode: "extend",
});// app/api/submit/route.ts
import { createSheetsRoute } from "@phantomstudios/sheets-react/route";
import { sheets } from "@/lib/sheets";
export const POST = createSheetsRoute({
client: sheets,
source: "project-rock",
});That's the whole swap — the form, schema, and route options stay the same.
Setting up a sample (PoC) credential
Google Cloud Console → create or open a project (e.g.
Side-Quest).APIs & Services → Library → enable Google Sheets API.
APIs & Services → Credentials → Create credentials → Service account. Name it
campaign-utils-poc.Open the service account → Keys → Add key → Create new key → JSON. Save the file securely.
Create a Google Sheet (e.g. Campaign Utils PoC); rename the first tab
submissions.Share the sheet with the service account email (
*.iam.gserviceaccount.com) as Editor.Copy the sheet ID from the URL:
/spreadsheets/d/{ID}/edit.Encode the JSON for env use:
base64 -i your-service-account.json | pbcopy # macOSAdd to
.env.local:GOOGLE_SERVICE_ACCOUNT_BASE64=<paste base64 here> SHEETS_TARGET_ID=<sheet id> SHEETS_DEFAULT_TAB=submissions
Swapping keys per campaign
Recommended pattern across multiple Phantom campaigns:
| Env var | Scope | Owner |
|---------|-------|-------|
| GOOGLE_SERVICE_ACCOUNT_BASE64 | Shared Phantom service account | DevOps / one rotation point |
| SHEETS_TARGET_ID | One sheet per campaign | Campaign team |
| SHEETS_DEFAULT_TAB | Optional, defaults to Sheet1 | Campaign team |
The shared service account just needs to be shared (as editor) with each campaign's sheet — sheets it isn't shared with stay invisible.
Vercel-specific:
- Set
GOOGLE_SERVICE_ACCOUNT_BASE64at the Team level so all projects inherit it. - Set
SHEETS_TARGET_IDper Project, distinct values for Preview vs Production.
To rotate: regenerate the service account JSON in GCP, replace GOOGLE_SERVICE_ACCOUNT_BASE64 in Vercel, redeploy. No campaign code changes.
Field types
| type | UI | Sheet value |
|--------|----|-------------|
| text | text input | string |
| email | email input (regex-validated) | string |
| number | number input | number |
| select | <select> from options | string |
| checkbox | checkbox | "yes" / "no" |
| textarea | textarea | string |
| date | date input | ISO string |
| hidden | not rendered | injected value |
Built-in metadata columns (configurable):
submittedAt— server timestampsource— value ofcreateSheetsRoute({ source })userAgent— request header (set automatically by the route)
Header behaviour
headerMode: "extend"(default) — new fields append new columns at the end.headerMode: "strict"— fields the sheet doesn't already have raise aSheetsError("schema")→ HTTP 400.
Columns are never reordered or removed. Schema evolution should be additive.
Schema-strict route (reject unknown fields in the request body)
headerMode controls the sheet → request comparison. To reject any field that isn't declared in your schema (regardless of what columns the sheet has), set strict: true on the route helper:
export const POST = createSheetsRoute({
client: sheets,
schema: leadSchema,
strict: true,
});When strict: true, any request body key outside schema raises a SheetsError("schema") → HTTP 400 with details: { extras, declared }. Default behaviour (strict: false) silently drops unknown keys. Use strict mode once the schema is locked so monitoring catches drift.
Error mapping (route helper)
| SheetsError.code | HTTP |
|--------------------|------|
| validation, schema | 400 |
| quota | 429 |
| auth, permission, not_found, unknown | 500 |
| network | 502 |
Response shape:
{ "ok": false, "error": { "code": "validation", "message": "..." } }Worked example app
See examples/next-showcase in the monorepo for a runnable Next.js app that uses the mock client by default and the real client when env vars are present.
