Get a quote

Building SaaS Onboarding Flows with Go and PostgreSQL That Actually Scale

Onboarding is where most SaaS products lose users before they see the value. The engineering behind it is more complex than it looks: workspace isolation, async initialization, email verification, and trial state all need to be modeled correctly from day one.

Onboarding is the first real interaction a user has with your backend. It is also where most SaaS products quietly lose users, not because the product is bad, but because the onboarding flow drops state, sends duplicate emails, or creates a workspace in an inconsistent state that quietly corrupts later.

When we build SaaS products for teams in Lebanon and the MENA region, onboarding engineering is one of the areas where we invest the most upfront design time. A well-structured onboarding system in Go and PostgreSQL is not complicated, but it requires making the right decisions about where state lives, what is synchronous, and what is deferred.

What onboarding actually involves

A typical SaaS onboarding flow for a B2B product covers:

  1. User submits registration form (email, name, company, password)
  2. System creates an account and a workspace
  3. System sends an email verification email
  4. User verifies their email
  5. System activates the account and starts the trial
  6. User completes setup steps (inviting teammates, connecting integrations, etc.)
  7. System marks onboarding as complete

Each of these steps has failure modes. The email might not send. The user might close the tab midway. The workspace initialization might fail after the user record was already created. The trial might start before the email is verified. Handling all of these correctly is what separates production-grade onboarding from a quick prototype.

Data model: separating account, workspace, and onboarding state

The first design decision is how to model the entities. A common mistake is to stuff too much onto the user record:

-- Common mistake: overloaded user table
CREATE TABLE users (
    id          UUID PRIMARY KEY,
    email       TEXT NOT NULL,
    email_verified BOOLEAN DEFAULT FALSE,
    trial_ends_at TIMESTAMPTZ,
    onboarding_step INTEGER DEFAULT 0,
    workspace_id UUID
);

This works for simple cases but becomes a problem when:

  • One user can belong to multiple workspaces (invited by another team)
  • Onboarding steps differ per workspace, not per user
  • Trial state is per-workspace, not per-user

The cleaner model separates concerns:

CREATE TABLE accounts (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at    TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE email_verifications (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    token      TEXT NOT NULL UNIQUE,
    expires_at TIMESTAMPTZ NOT NULL,
    verified_at TIMESTAMPTZ
);

CREATE TABLE workspaces (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    plan        TEXT NOT NULL DEFAULT 'trial',
    trial_ends_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE workspace_members (
    workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
    account_id   UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    role         TEXT NOT NULL DEFAULT 'owner',
    joined_at    TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (workspace_id, account_id)
);

CREATE TABLE onboarding_progress (
    workspace_id   UUID PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
    completed_steps TEXT[] DEFAULT '{}',
    completed_at   TIMESTAMPTZ
);

This gives you clean separation. Onboarding state lives on the workspace. Trial state lives on the workspace. Email verification lives on the account. A user who is invited to a second workspace does not re-trigger onboarding.

Registration: the transactional boundary

Registration must be atomic. If any step fails, nothing should be persisted. In Go with database/sql, this means wrapping the entire registration in a transaction:

func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*RegisterResult, error) {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()

    // 1. Create account
    accountID, err := createAccount(ctx, tx, input.Email, input.PasswordHash)
    if err != nil {
        return nil, err
    }

    // 2. Create workspace
    workspaceID, err := createWorkspace(ctx, tx, input.WorkspaceName)
    if err != nil {
        return nil, err
    }

    // 3. Add owner membership
    if err := addWorkspaceMember(ctx, tx, workspaceID, accountID, "owner"); err != nil {
        return nil, err
    }

    // 4. Initialize onboarding progress
    if err := initOnboarding(ctx, tx, workspaceID); err != nil {
        return nil, err
    }

    // 5. Create email verification token
    token, err := createVerificationToken(ctx, tx, accountID)
    if err != nil {
        return nil, err
    }

    if err := tx.Commit(); err != nil {
        return nil, err
    }

    // Email is sent outside the transaction
    s.mailer.SendVerification(ctx, input.Email, token)

    return &RegisterResult{AccountID: accountID, WorkspaceID: workspaceID}, nil
}

Note that the email send happens after tx.Commit(). Sending email inside a transaction is a classic mistake: if the email send succeeds but the commit fails, you have sent a verification email for an account that does not exist. If the commit succeeds but the email send fails, you handle that separately.

Email verification: token design and resend handling

Verification tokens should have a clear expiry and a one-time-use guarantee. The common approach is to store the token hash and mark the row as used on verification:

func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
    row := s.db.QueryRowContext(ctx,
        `UPDATE email_verifications
         SET verified_at = now()
         WHERE token = $1
           AND verified_at IS NULL
           AND expires_at > now()
         RETURNING account_id`,
        token,
    )

    var accountID string
    if err := row.Scan(&accountID); err == sql.ErrNoRows {
        return ErrInvalidToken
    } else if err != nil {
        return err
    }

    // Activate trial for all workspaces owned by this account
    return s.activateTrial(ctx, accountID)
}

The UPDATE ... RETURNING is atomic: it checks and consumes the token in one operation. No separate check-then-update race condition.

For resend handling, rate-limit by account and invalidate old tokens before issuing a new one:

func (s *AuthService) ResendVerification(ctx context.Context, accountID string) error {
    // Check last send time (rate limit: 60 seconds)
    var lastSent time.Time
    s.db.QueryRowContext(ctx,
        `SELECT MAX(created_at) FROM email_verifications WHERE account_id = $1`,
        accountID,
    ).Scan(&lastSent)

    if time.Since(lastSent) < 60*time.Second {
        return ErrRateLimited
    }

    // Invalidate old tokens and create new one
    // (single transaction)
    // ...
}

Trial management: when does the clock start

A common debate in SaaS products is whether the trial starts at registration or at email verification. Starting at verification is almost always the right choice: a user who never verifies their email is not really using the product.

func (s *AuthService) activateTrial(ctx context.Context, accountID string) error {
    _, err := s.db.ExecContext(ctx,
        `UPDATE workspaces w
         SET trial_ends_at = now() + INTERVAL '14 days'
         FROM workspace_members wm
         WHERE wm.workspace_id = w.id
           AND wm.account_id = $1
           AND wm.role = 'owner'
           AND w.trial_ends_at IS NULL`,
        accountID,
    )
    return err
}

The AND w.trial_ends_at IS NULL guard is important. If this function runs twice (idempotency), it does not reset the trial clock.

Tracking onboarding steps

For multi-step onboarding, the cleanest approach is storing completed steps as an array in PostgreSQL and adding to it atomically:

const (
    StepProfileComplete   = "profile_complete"
    StepFirstIntegration  = "first_integration"
    StepInvitedTeammate   = "invited_teammate"
    StepFirstDataImport   = "first_data_import"
)

func (s *OnboardingService) CompleteStep(ctx context.Context, workspaceID, step string) error {
    _, err := s.db.ExecContext(ctx,
        `UPDATE onboarding_progress
         SET completed_steps = array_append(
             array_remove(completed_steps, $2), $2
         )
         WHERE workspace_id = $1`,
        workspaceID, step,
    )
    if err != nil {
        return err
    }

    return s.checkIfComplete(ctx, workspaceID)
}

The array_remove before array_append makes the operation idempotent: if a step is already in the array, it is removed and re-added, so no duplicates accumulate.

checkIfComplete queries whether all required steps are present and marks the onboarding as done if they are.

Async workspace initialization

Some workspaces require heavy initialization: creating default projects, importing template data, or provisioning resources. This should not happen in the registration HTTP handler.

Use a background job enqueued at commit time:

// After tx.Commit() in Register
if err := s.jobQueue.Enqueue(ctx, "workspace_init", map[string]string{
    "workspace_id": workspaceID,
}); err != nil {
    // Log but do not fail registration
    // User can still log in; init will retry
    log.Printf("failed to enqueue workspace init: %v", err)
}

The worker retries on failure. The UI shows a loading state until the workspace initialized_at field is set. The user is not blocked from accessing the product during initialization; they just see a friendly loading screen.

The edge cases that break onboarding in production

Duplicate registrations. A user submits the form twice because the first request was slow. Your UNIQUE constraint on accounts.email catches this, but your error handling needs to return a user-friendly message, not a raw database error.

Invitation to an existing account. A user is invited to a workspace but already has an account. The flow needs to detect this, skip account creation, and just add the membership.

Expired token reuse. Users often click an old verification link they find in their inbox. The AND expires_at > now() guard handles this, but your error message should distinguish between "invalid token" and "expired token" so users know to request a new one.

Browser back navigation. Users go back during onboarding and re-submit. Idempotent operations throughout prevent double-creation bugs.


Key lessons from production

Separate account, workspace, and onboarding state into different tables from the start. Wrap registration in a single transaction but send email outside the transaction. Make every step idempotent. Start the trial at verification, not at registration. Use a background job for heavy workspace initialization. Handle duplicate registrations and expired tokens explicitly.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

Voxire designs and builds Go and PostgreSQL SaaS backends for teams launching products across Lebanon and the MENA region. If you are starting a new SaaS product or refactoring an existing onboarding flow, reach out.

https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp