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

@profullstack/contact-form

v0.4.0

Published

The Profullstack contact form: customizable questions with generic defaults, form-guard spam protection (signed proof-of-render token, honeypot, fill time, rate limit), validation, Resend delivery, HTML and React rendering, one handler for JSON and plain

Readme

@profullstack/contact-form

The contact form every Profullstack site uses. One object knows the questions, mints and checks the spam token, validates, sends the email and answers, so the page and the route can never disagree.

npm i @profullstack/contact-form
  • Questions you can change. Generic defaults (name, email, message); a site with a custom form passes its own list or adjusts the defaults.
  • Spam protection on by default through @profullstack/form-guard: a signed proof-of-render token (the check that catches bots that POST straight at the handler, which a honeypot cannot), a honeypot, a fill-time floor and 5 messages per hour per address. Anything caught is answered like a success, so a bot learns nothing.
  • Delivery through Resend (RESEND_API_KEY), a custom send, or an @profullstack/emailer. The email carries the labelled answers, Reply-To the sender, a [spam? N] subject tag when content scoring flags it, and a provenance block (IP, user agent, fill time, signals). A flagged message is still delivered.
  • Any framework. POST takes a standard Request (Next App Router, Hono, Bun, Workers, SvelteKit). JSON in gets JSON out; a plain <form> post gets a 303 back to the page with ?sent=1 or ?error=....
  • Markup as an HTML string (render()) or a React component (@profullstack/contact-form/react). Plain cf-* classes, no inline styles, so it works under a strict CSP and takes each site's own CSS.

Use

One module both the page and the route import:

// lib/contact.js
import { createContactForm } from '@profullstack/contact-form';

export const contact = createContactForm({
  site: 'example.com', // token binding: "example.com:contact"
  to: '[email protected]',
  from: 'Example <[email protected]>',
  brandTerms: ['example'], // words a scraper echoes back
});

The secret is FORM_GUARD_SECRET, or RESEND_API_KEY if that is unset (sending needs it anyway). It must be the same on every instance serving the form. With neither set the form logs a warning and keeps only its honeypot.

Next.js (App Router)

// app/api/contact/route.js
import { contact } from '@/lib/contact';
export const POST = contact.POST;
// app/contact/page.jsx (a server component)
import { ContactForm } from '@profullstack/contact-form/react';
import { contact } from '@/lib/contact';

export const dynamic = 'force-dynamic'; // a token per render, never a cached page

export default async function Page() {
  return <ContactForm {...(await contact.props())} />;
}

A page that is a client component all the way down can fetch the props: export GET = contact.GET from the route and render <ContactForm src="/api/contact" />.

With cacheComponents (Next 16) on, export const dynamic is refused at build time. Render the form inside <Suspense> from an async component that calls await connection() (from next/server) before contact.props(), so the token is minted per request:

async function FormSlot() {
  await connection();
  return <ContactForm {...(await contact.props())} />;
}
export default function Page() {
  return (
    <Suspense fallback={null}>
      <FormSlot />
    </Suspense>
  );
}

<ContactForm> also takes onSubmit(body) (return false to cancel; for an analytics "submit" event), onError(message, response), onSent(body, response), defaultValues, resetLabel and children (shown under the success message).

Hono, Express-style, SvelteKit, any template string

app.get('/contact', async (c) =>
  c.html(layout(await contact.render({ action: '/contact', query: c.req.url }))),
);
app.post('/contact', (c) => contact.POST(c.req.raw));

A plain <form> post is answered with a 303 back to the page: ?sent=1, or ?error=<code> (invalid, retry, limited, sendFailed, error) plus &field=&reason= for a validation failure. The URL never carries free text, because a page that prints ?error= would print whatever a crafted link held. render({ query }) turns it back into this form's own message, or call contact.errorMessage(query) / contact.sentMessage(query) yourself. Never print the raw query value.

Serve the page with cache-control: no-store: a cached page shares one token between visitors and expires it for all of them.

A framework that has already parsed the body (Primate's request.body, Express with body-parser) passes the fields: contact.handleFields(body, { headers, ip, referer, html: true }). Same checks, same answers, no Request adapter in the site.

A static page loaded by a client-side router can't mint a token on the server. Export GET = contact.GET from the route and mount the guard in the browser:

import { mountGuard } from '@profullstack/contact-form/browser';
await mountGuard(document.querySelector('form.contact'), '/api/contact');

It adds the token and the hidden honeypot (class cf-hp, so hide it in your CSS), and replaces them if you mount again.

The module is ESM and needs Node 20 or later. An app on an older runtime has to upgrade first.

Questions

import { createContactForm, withQuestions } from '@profullstack/contact-form';

createContactForm({
  to: '[email protected]',
  questions: withQuestions({
    add: [
      { name: 'company', label: 'Company / Project' },
      {
        name: 'budget',
        type: 'select',
        options: ['Under $5k', '$5k-$25k', '$25k+'],
        required: true,
      },
    ],
    override: { message: { label: 'Tell us about the project' } },
  }),
});

Or a whole list: questions: [{ name: 'email', required: true }, { name: 'idea', type: 'textarea', required: true }].

| Field | | | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | name | body key and input name (letters, digits, _, -) | | label | shown on the form and in the email; default from the name | | type | text email textarea select url tel number checkbox hidden | | required, maxLength (500, textarea 5000), placeholder, rows, autocomplete, help | | | options | for select: strings or { value, label }; the answer must be one of them | | pattern, patternMessage | a regex source the answer must match |

Only answers to questions reach the email or persist: an extra key in the body is ignored, so nobody can smuggle fields into what you read.

Since 0.3.0: minLength; pattern must match the whole answer (as in HTML) and is sent to the input with patternMessage as its title, so both are checked in the browser too. A select reads as its label in the email. Messages use the label without a trailing * or :.

Since 0.2.0:

  • type: 'section' is a heading (label, optional help) between groups of fields. It is never validated or emailed.
  • default is the value a field starts with: a select preset (it must be one of the options) or a ticked checkbox (true).
  • A select's placeholder becomes an empty first option that starts selected. On a required select the browser then makes the person choose, instead of sending whichever option is listed first.
  • Per-render starting values, such as a hidden ref from ?ref= or a box the link ticked: contact.props({ values: { ref } }) or contact.render({ values }). Only question names are kept.
  • <ContactForm> also takes defaultValues (these win), children (shown under the success message) and resetLabel (a "Send another" button).

Forms that ask for something specific

An order form, a build request or a bug report asked for something; the stock "I have a few questions about pricing, availability, and the general process of working with you" note answers none of it. requireSubstance names the questions that must say something. A submission with fewer than minSubstance (20) characters left after form-guard's substance() is answered like a success and not delivered (onQuarantine sees it). Never use it on a general contact form, where "please tell me more" is a fair message.

Options

| Option | Default | | | -------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------- | | to | required | who receives the notification | | from | | From header | | site, binding | site:contact | token binding | | questions | DEFAULT_QUESTIONS | | | guard | built from env | form-guard options (minAgeMs, rateLimit, requireToken, ...), a guard instance, or false | | brandTerms | [] | content scoring | | subject | New contact form submission from {name} | string or function of the submission | | send, emailer, emailerConfig | Resend from RESEND_API_KEY | transport | | persist(submission, verdict) | | store it (Supabase etc.); an id it returns is in the response; a throw is a 500 | | confirmation | | { subject, html } emailed to the sender | | verify(body, req), captcha | | extra checks; captcha: { provider: 'turnstile' \| 'hcaptcha', secretKey } | | style | success | response bodies: success ({success:true}/{error}), ok, message | | responses | | override any response | | onSendError | fail | ignore answers success when the email fails | | onMissingEmailer | error in production, else log | | | requireSubstance, minSubstance, onQuarantine | | see above | | redirect | the Referer | where a plain form post is sent back to | | action, submitLabel, successMessage, hpClass (cf-hp) | | rendering | | ip(req) | from proxy headers | the address, where the platform keeps it elsewhere (Hono c.env.ip) |

FORM_GUARD_ENFORCE=0 scores without blocking, if a real sender is ever turned away.

Styling

BASE_CSS hides .cf-hp and lays the fields out; use it, or style .cf-form, .cf-field, .cf-error, .cf-notice, .cf-submit yourself. Hide .cf-hp from your stylesheet whatever you do: a visible honeypot drops the real people who fill it in.

Migrating from @profullstack/stack/email

createContactRoute and createContactGuard are here unchanged (and @profullstack/stack/email re-exports them from here), so existing routes keep working. New code uses createContactForm, which also owns the page.

MIT © Profullstack, Inc.