npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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: googleapis only ever ships in server bundles.
  • Schema-driven: the same FormSchema validates 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-react

Use 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

  1. Google Cloud Console → create or open a project (e.g. Side-Quest).

  2. APIs & Services → Library → enable Google Sheets API.

  3. APIs & Services → CredentialsCreate credentials → Service account. Name it campaign-utils-poc.

  4. Open the service account → Keys → Add key → Create new key → JSON. Save the file securely.

  5. Create a Google Sheet (e.g. Campaign Utils PoC); rename the first tab submissions.

  6. Share the sheet with the service account email (*.iam.gserviceaccount.com) as Editor.

  7. Copy the sheet ID from the URL: /spreadsheets/d/{ID}/edit.

  8. Encode the JSON for env use:

    base64 -i your-service-account.json | pbcopy   # macOS
  9. Add 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_BASE64 at the Team level so all projects inherit it.
  • Set SHEETS_TARGET_ID per 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 timestamp
  • source — value of createSheetsRoute({ 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 a SheetsError("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.