SF

Next.js · Payload CMS · Neon

Solo SaaS: from Payload CMS to a real client

Designed, built and deployed alone: a website-builder and business-management platform, live for a real client.

1
client in production
8
business modules
0
passwords stored

The problem

Deliver a complete SaaS platform for a real client — an editable public site and a business back-office — alone, with no team, a minimal infrastructure budget and a fast path to production.

The approach

Decoupled architecture: a headless Payload CMS backend exposes REST APIs consumed by a Next.js frontend. Authentication runs on passwordless magic links instead of passwords, and PostgreSQL runs serverless on Neon to remove infrastructure management. The back-office covers an Elementor-like site builder, quotes and invoices, appointments, partner files, a client area, an agent area and internal messaging.

Code excerptauth/magicLink.ts
// Passwordless by design: the client base for this platform is small
// business owners, and every password reset was a support call. A magic
// link removes the credential entirely — there is nothing to leak, forget
// or reuse across sites.
export const requestMagicLink = async (email: string) => {
  // Single-use, short-lived, and stored hashed: a leaked database row
  // cannot be replayed as a login.
  const token = crypto.randomUUID();
  const hash = await sha256(token);

  await payload.create({
    collection: 'login-tokens',
    data: {
      email,
      hash,
      expiresAt: new Date(Date.now() + 10 * 60_000),
      consumedAt: null,
    },
  });

  await sendEmail(email, buildLoginUrl(token));

  // Always the same response, whether or not the address exists.
  // Returning "unknown email" would turn this endpoint into a way to
  // enumerate the customer list.
  return { ok: true };
};

export const consumeMagicLink = async (token: string) => {
  const hash = await sha256(token);
  const record = await findValidToken(hash);
  if (!record) return null;

  // Consume before issuing the session, so a replayed link fails even if
  // two requests arrive at the same moment.
  await payload.update({
    collection: 'login-tokens',
    id: record.id,
    data: { consumedAt: new Date() },
  });

  return createSession(record.email);
};

The result

The platform is live for a real client, Déménagement Tunisie, used daily by its team to edit the public site and run the business — not a showcase project, a tool that runs.

Multi-tenant · EF Core · JWTMulti-tenant isolation: isolating without slowing the APIPostgreSQL · SignalR · TestcontainersOptimistic concurrency: the conflict you never see