docs
    commerce-kit: Memberships
    v1.0.1.md

    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

    // 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.

    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:

    // 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
    })
    // app/api/members/verify/route.ts
    import { members } from '@/lib/members'
    export const GET = members.handleVerify
    // app/api/members/logout/route.ts
    import { members } from '@/lib/members'
    export const POST = members.handleLogout

    The sign-in page:

    // 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:

    // 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:

    <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:

    // 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:

    // 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

    ExportFromWhat it does
    MembershipCheckoutButtonmembershipsButton that starts Square checkout for an offering
    MemberSignInFormmembershipsEmail sign-in form
    startMembershipCheckout(options)membershipsThe checkout call, for custom markup
    requestMemberSignIn({ email, next, verifyPath })membershipsThe sign-in call, for custom markup
    DEFAULT_MEMBER_VERIFY_PATHmemberships, memberships/server/api/members/verify, where the sign-in link lands unless you pass verifyPath
    createMembers(config)memberships/servergetMember, getRequestMember(request) for a route handler, requireMember, cancelMembership, handleVerify, handleLogout, getPlans
    getMembershipPlans()memberships/serverThe plans this site sells, cheapest first
    getMembershipByEmail(email)memberships/serverA verified user's membership, for sites with their own sign-in
    cancelMembershipByEmail(email)memberships/serverCancel at the end of the paid period, for sites with their own sign-in
    MEMBER_SIGN_IN_ERRORSmemberships/serverA 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.