One user profile for every Profullstack property. Six fields, in this order, everywhere:
| column | what it holds | example |
|---|---|---|
email |
the contact address, lower-cased | ada@acme.com |
first_name |
as typed, case kept | Ada |
last_name |
as typed, case kept | Lovelace |
company_domain |
bare domain, no scheme or www. |
acme.com |
job_title |
as typed, whitespace cleaned | VP Engineering |
linkedin_url |
canonical profile URL | https://www.linkedin.com/in/ada-lovelace |
The order is the CSV column order Explee, Apollo and cli-tools user-export --full read, so an export from any property imports anywhere without remapping.
What the package gives a property:
- Validation and normalization.
acme.com/about,https://www.Acme.comand@acme.comall becomeacme.com. Any LinkedIn URL,in/xor bare handle becomes the canonical URL. A link in a name box is refused as spam. Names are never "corrected" (McDonald stays McDonald). - Company from a work email. A blank company domain is filled from the signup email unless it is a personal or relay mailbox (gmail, proton, passmail and so on).
- Signup form fields. As an HTML string for server-rendered pages, and as a React component, with the right
autocompletetokens so browsers fill them. - Storage on whatever the property already runs: postgres.js, Bun SQL, node-postgres, libSQL/Turso, @profullstack/libsql-pg, bun:sqlite, better-sqlite3, or supabase-js on the server. Either its own
user_profilestable, or the columns added to an existingprofiles/userstable. GET/PUT/PATCH /api/v1/me/profilehandlers on Web-standard Request/Response, for Next and Hono alike.- A CLI and an MCP server (
profile), includingprofile exportin contract order.
npm i @profullstack/profileIt has no dependencies. postgres, @libsql/client and react are optional peers; the property already has whichever it uses.
By default a signup must give email, first_name and last_name; the other three are offered and marked optional, because every required box costs signups. A property that sells to companies passes LEAD_REQUIRED (adds company_domain and job_title, the fields an outbound tool will not import a lead without).
import { validateProfile, LEAD_REQUIRED } from "@profullstack/profile";
validateProfile(form, { required: LEAD_REQUIRED });- Schema. Call
migrate()on boot or in the migration runner. It createsuser_profiles, or adds only the missing columns to the table you name. Postgres takes an advisory lock, so concurrent boots are safe. On Supabase (no DDL through supabase-js), putprofile schemaoutput in a migration file. - Signup form. Add the fields beside the email box.
- Signup handler. Validate before creating the user, save after.
- Profile page. Mount the handlers and let people fill in what they skipped.
- Backfill. Copy any name or company columns the property already has, using
fillBlanksso nothing typed is overwritten.
// lib/profile.js
import sql from "@/lib/db";
import { createProfileStore } from "@profullstack/profile";
export const profiles = createProfileStore(sql); // or { table: "profiles", key: "id" }// app/signup/page.jsx (a server component posting to a route handler or a server action)
import { ProfileFields } from "@profullstack/profile/react";
<form action={signup}>
<ProfileFields />
<button>Send me a sign-in link</button>
</form>// the signup action / POST handler
import { validateProfile, saveSignupProfile } from "@profullstack/profile";
const check = validateProfile(formData);
if (!check.ok) return { errors: check.errors };
const user = await createUserAndSendMagicLink(check.profile.email);
await saveSignupProfile(profiles, user.id, formData, { email: user.email });// app/api/v1/me/profile/route.js
import { createProfileHandlers } from "@profullstack/profile";
import { profiles } from "@/lib/profile";
import { currentUser } from "@/lib/session";
export const { GET, PUT, PATCH } = createProfileHandlers({
store: profiles,
getUser: (req) => currentUser(req), // { id, email } or null
});On a signed-in profile settings form, enable URL prefill without adding another API route:
<ProfileFields
values={values}
onChange={setValues}
importOpenProfile
/>The importer reads an OpenProfile.md URL through the authenticated
GET /api/v1/me/profile?openprofile=<url> handler, then fills only matching
fields. It does not save; the person reviews and submits the existing form.
The server follows only public HTTPS hosts, rechecks redirects, caps the
document at 256 KB, and returns normalized contact fields rather than the
source document. Server-rendered forms can pass importOpenProfile: true to
renderProfileFields and include PROFILE_ENHANCE_SCRIPT on the page.
After an account connection, server code can save the linked profile into blank fields while preserving anything the person already entered:
await saveOpenProfileContact(profileStore, user.id, coinpayUser.openprofile_url);import { createClient } from "@libsql/client";
import { createProfileStore, mountProfileRoutes, renderProfileFields, PROFILE_ENHANCE_SCRIPT } from "@profullstack/profile";
const profiles = createProfileStore(createClient({ url, authToken }));
await profiles.migrate();
mountProfileRoutes(app, { store: profiles, getUser: (req) => sessionUser(req) });
app.get("/signup", (c) =>
c.html(`<form method="post" action="/signup">
${renderProfileFields()}
<button>Sign up</button>
</form><script>${PROFILE_ENHANCE_SCRIPT}</script>`),
);Browsers talk only to /api, so the store runs on the server with the service client:
const profiles = createProfileStore(supabaseAdmin, { table: "profiles", key: "id" });npx @profullstack/profile schema --table profiles --key id > supabase/migrations/20261026000000_profile_fields.sql(For an existing table, write the alter table ... add column if not exists lines yourself, or run profile migrate with DATABASE_URL pointing at the database.)
const profiles = createProfileStore(db, { table = "user_profiles", key = "user_id" });
await profiles.migrate(); // { created, added: [columns] }
await profiles.save(userId, { job_title: "CTO" }); // only given fields; null clears
await profiles.fillBlanks(userId, enriched); // never overwrites what a person typed
await profiles.get(userId); await profiles.getByEmail(email);
await profiles.list({ limit, after, since }); // keyset pages: { profiles, next }
for await (const p of profiles.all()) { ... }
await profiles.remove(userId);A hand-written adapter is { dialect: "postgres" | "sqlite", query(text, params) } and receives ? placeholders. A Postgres client wrapped to look like @libsql/client (anything with execute() that is not @profullstack/libsql-pg) is taken for SQLite: pass dialect: "postgres".
There is no foreign key to a users table: the host's may be named anything or live elsewhere. Deleting a user is the host's job, so call remove() there.
export DATABASE_URL=postgres://... # or libsql://..., file:app.db (TURSO_AUTH_TOKEN for Turso)
profile migrate
profile get <user-id>
profile find ada@acme.com
profile set <user-id> --job-title CTO --linkedin in/ada-lovelace
profile check --company-domain https://www.acme.com/about
profile export --complete > leads.csv # only rows with every lead field
profile export --format tsv --since 2026-10-01
profile mcp # tools: get_profile, update_profile, validate_profile, export_profiles--table and --key (or PROFILE_TABLE, PROFILE_KEY) point it at an existing table.
MIT