# Memberships: `@sonordev/commerce-kit/memberships`

Sell a membership on your site and keep members-only pages for the people who pay. A membership is a recurring charge (weekly to yearly, with an optional free trial) that runs on the business's own Square account. Square takes the card and bills every period. Sonor keeps the list of members, books each payment, and tells the site who's paid up.

Members sign in with a link emailed to the address they joined with. There are no passwords. Whether someone can see members-only pages is checked with Sonor, never remembered by your site, and your site reuses an answer for at most a minute. So when a membership ends or a renewal fails, access stops within a minute of Sonor learning of it.

Requires site-kit 8 or newer. Memberships is new with commerce-kit 1.0 and never shipped in a site-kit release, so there's nothing to move from site-kit 7: install `@sonordev/commerce-kit` and import from the paths below.

## Before you start

In Sonor:

1. Connect Square under **Payment Processor** in **Commerce Settings** (the gear in the Commerce module). A connection made before memberships existed shows a *Reconnect Square* prompt: reconnect once, so Square can grant the subscription permissions.
2. Turn on **Memberships** under **Enabled Offering Types** in the project's **Commerce Options**, then create a membership offering with its price, how often it bills and any free trial. Sonor creates the matching subscription plan in Square.

On the site, memberships need nothing beyond `SiteKitLayout` and `SONOR_API_KEY`, which every Sonor site already has.

## Sell it

```tsx
// app/membership/page.tsx
import { getMembershipPlans } from '@sonordev/commerce-kit/memberships/server'
import { MembershipCheckoutButton } from '@sonordev/commerce-kit/memberships'

export default async function MembershipPage() {
  const plans = await getMembershipPlans()
  return plans.map((plan) => (
    <section key={plan.offeringId}>
      <h2>{plan.name}</h2>
      <p>{plan.price}</p>
      <MembershipCheckoutButton offeringId={plan.offeringId} redirectTo="/membership/welcome" className="btn">
        Join
      </MembershipCheckoutButton>
    </section>
  ))
}
```

`MembershipCheckoutButton` sends the visitor to Square's secure checkout and back to `redirectTo` afterwards (a page on your site; the current page when you leave it out). The new member shows up in Sonor on their own, with a contact and their first payment. If checkout can't start, the button shows the reason under itself (`errorClassName` styles it). `getMembershipPlans()` returns each plan's `offeringId`, `name`, `description`, `amountCents`, `currency`, `cadence`, `trialDays` and a ready-to-show `price` such as `$9.99 / month`. It's cached for five minutes, and Sonor refreshes it sooner when a plan changes on a site that serves the [revalidation route](https://sonor.dev/site-kit/live-updates).

For custom markup, call `startMembershipCheckout({ offeringId, email, redirectTo })` yourself. It resolves with `{ ok: false, error }` when checkout can't start; when it can, the browser is already on its way to Square.

## Let members in

Three files:

```ts
// lib/members.ts
import { createMembers } from '@sonordev/commerce-kit/memberships/server'

export const members = createMembers({
  loginPath: '/members/sign-in', // the page with <MemberSignInForm>
  joinPath: '/membership', // where someone without an active membership goes
})
```

```ts
// app/api/members/verify/route.ts
import { members } from '@/lib/members'
export const GET = members.handleVerify
```

```ts
// app/api/members/logout/route.ts
import { members } from '@/lib/members'
export const POST = members.handleLogout
```

The sign-in page:

```tsx
// app/members/sign-in/page.tsx
import { MemberSignInForm } from '@sonordev/commerce-kit/memberships'
import { MEMBER_SIGN_IN_ERRORS } from '@sonordev/commerce-kit/memberships/server'

type Props = { searchParams: Promise<{ next?: string; error?: string }> }

export default async function SignIn({ searchParams }: Props) {
  const { next, error } = await searchParams
  return (
    <>
      {error && MEMBER_SIGN_IN_ERRORS[error] ? <p role="alert">{MEMBER_SIGN_IN_ERRORS[error]}</p> : null}
      <MemberSignInForm next={next} className="stack" inputClassName="input" buttonClassName="btn" />
    </>
  )
}
```

When a sign-in link has expired or Sonor can't be reached, `handleVerify` sends the person back to this page with `?error=expired`, `unavailable` or `config` (and the `next` page they were headed for). `MEMBER_SIGN_IN_ERRORS` maps those codes, and `inactive`, to a plain-language message. A link for a membership that isn't active sends the person to your join page with `?status=inactive` instead.

Every redirect these routes send is a path on your own site, never a full URL, so a proxy or a host that reports a different hostname to the server can't send a member somewhere else. A successful sign-in lands on the `next` page; when that page has no query of its own, it gets `?signed_in=1`, so a host that copies the link's query into the redirect can't carry the one-time token into the address bar.

The form is unstyled. Pass `className`, `labelClassName`, `inputClassName`, `buttonClassName` and `messageClassName`, and change its words with `label`, `buttonLabel` and `sentMessage`. If your verify route lives somewhere other than `/api/members/verify`, pass its path as `verifyPath`.

And every members-only page starts with one line:

```tsx
// app/workouts/page.tsx
import { members } from '@/lib/members'

export default async function Workouts() {
  const member = await members.requireMember('/workouts')
  return <h1>Welcome back{member.name ? `, ${member.name}` : ''}</h1>
}
```

`requireMember(returnTo)` returns the member when their membership is active. Otherwise it redirects: to the sign-in page (coming back to `returnTo` afterwards) when nobody's signed in, or to the join page with `?status=inactive` when the membership has ended. Members-only pages read a cookie, so they render per request; don't cache them.

`getMember()` returns the signed-in member (active or not) or `null`, for showing "Sign in" or "My membership". It reads the member's cookie, so any page or layout that calls it renders per request. Keep it out of a root layout that cached pages share: link to the sign-in page from a header like that, and call `getMember()` on the pages that are members-only anyway. A member is `{ email, name, active, plan, renewsOn, endsOn }`.

A sign-out button is a form that posts to the logout route:

```tsx
<form action="/api/members/logout" method="post">
  <button>Sign out</button>
</form>
```

## Canceling

Members cancel on your site; Square doesn't give subscribers a page of its own. Put a confirm step in front of it and say when access ends:

```ts
// app/members/account/actions.ts
'use server'
import { members } from '@/lib/members'

export async function cancel() {
  const result = await members.cancelMembership()
  return result.canceled ? { endsOn: result.endsOn } : { error: 'There’s no active membership to cancel.' }
}
```

`cancelMembership()` resolves `{ canceled: true, endsOn }`, or `{ canceled: false, reason }` where `reason` is `no_membership` or `unavailable`. A canceled membership keeps working until the end of the period that's already paid for (`endsOn`), then `requireMember` starts sending them to the join page.

## Using your own sign-in

If your site already has accounts (its own auth library and database), keep them and let Sonor answer one question: has this person paid? Look the membership up by the email your auth has verified, on the server:

```tsx
// app/workouts/page.tsx
import { redirect } from 'next/navigation'
import { getMembershipByEmail } from '@sonordev/commerce-kit/memberships/server'
import { getSession } from '@/lib/auth' // your auth

export default async function Workouts() {
  const session = await getSession()
  if (!session) redirect('/sign-in?next=/workouts')
  const membership = await getMembershipByEmail(session.user.email)
  if (!membership?.active) redirect('/membership')
  return <WorkoutLibrary />
}
```

- Pass the signed-in user's email to `MembershipCheckoutButton` (`email={session.user.email}`) so Square's checkout starts with it. A member is matched by the email they pay with, so ask them to keep it the same.
- `getMembershipByEmail` and `cancelMembershipByEmail` use the site's server key, so they only run on the server. Only pass an email your auth has verified, never one a visitor typed: whoever's email you pass is who you're asking about.
- `getMembershipByEmail` returns `null` when Sonor can't be reached. Treat that as "can't tell" (show a retry), not as "not a member".
- You don't need `createMembers`, `MemberSignInForm` or the verify and logout routes.

## How sign-in works

1. `<MemberSignInForm>` sends the address straight from the visitor's browser to Sonor, along with your verify route's URL. Sonor only accepts a URL on one of the project's own domains.
2. When the address belongs to an active member, Sonor emails a link to the verify route. The form says the same thing either way, so it can't be used to find out who's a member. The link works for 20 minutes.
3. `handleVerify` trades the link for a 30-day session and stores it in an httpOnly `__Host-sonor-member` cookie. Your site holds no secret: Sonor signs the session and only Sonor can read it.
4. Each `getMember()` asks Sonor whether that session's membership is still active, reusing an answer for up to a minute.

## Reference

| Export                                             | From                                | What it does                                                                                                                                  |
| -------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `MembershipCheckoutButton`                         | `memberships`                       | Button that starts Square checkout for an offering                                                                                            |
| `MemberSignInForm`                                 | `memberships`                       | Email sign-in form                                                                                                                            |
| `startMembershipCheckout(options)`                 | `memberships`                       | The checkout call, for custom markup                                                                                                          |
| `requestMemberSignIn({ email, next, verifyPath })` | `memberships`                       | The sign-in call, for custom markup                                                                                                           |
| `DEFAULT_MEMBER_VERIFY_PATH`                       | `memberships`, `memberships/server` | `/api/members/verify`, where the sign-in link lands unless you pass `verifyPath`                                                              |
| `createMembers(config)`                            | `memberships/server`                | `getMember`, `getRequestMember(request)` for a route handler, `requireMember`, `cancelMembership`, `handleVerify`, `handleLogout`, `getPlans` |
| `getMembershipPlans()`                             | `memberships/server`                | The plans this site sells, cheapest first                                                                                                     |
| `getMembershipByEmail(email)`                      | `memberships/server`                | A verified user's membership, for sites with their own sign-in                                                                                |
| `cancelMembershipByEmail(email)`                   | `memberships/server`                | Cancel at the end of the paid period, for sites with their own sign-in                                                                        |
| `MEMBER_SIGN_IN_ERRORS`                            | `memberships/server`                | A message for each `?error=` code the verify route can send to your sign-in page                                                              |

`createMembers` options: `loginPath` (default `/members/sign-in`), `joinPath` (default `/membership`), `homePath` where a sign-in lands with no `next` (default `/`), and `cookieName` (default `__Host-sonor-member`).

Types: `Member`, `MembershipPlan`, `MembershipStatus`, `CancelMembershipResult`, `MembersConfig` and `Members` come from `memberships/server`, and `Member`, `MembershipPlan`, `MembershipCheckoutResult`, `MemberSignInResult`, `StartMembershipCheckoutOptions`, `RequestMemberSignInOptions`, `MembershipCheckoutButtonProps` and `MemberSignInFormProps` from `memberships`.
