Skip to main content

Overview

The Vouch Remix SDK provides seamless integration with Remix’s action/loader pattern for both client and server-side email validation.
Package: @vouch-in/remix Remix Version: 1.0+ TypeScript: Full type definitions included

Installation

npm install @vouch-in/remix

Server-Side Validation

Action Handler

// app/routes/signup.tsx
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Vouch } from '@vouch-in/remix';

const vouch = new Vouch(
  process.env.VOUCH_PROJECT_ID!,
  process.env.VOUCH_SERVER_KEY!
);

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = formData.get('email') as string;

  const result = await vouch.validate(email, {
    ip: request.headers.get('x-forwarded-for') || undefined,
    userAgent: request.headers.get('user-agent') || undefined,
  });

  // Check the recommendation
  if (result.recommendation !== 'allow') {
    return json({
      error: 'Email validation failed',
      recommendation: result.recommendation
    }, { status: 400 });
  }

  // Check specific validations
  if (!result.checks.syntax?.pass) {
    return json({ error: 'Invalid email format' }, { status: 400 });
  }

  if (!result.checks.disposable?.pass) {
    return json({ error: 'Disposable emails not allowed' }, { status: 400 });
  }

  // Check device history
  if (result.metadata.previousSignups > 10) {
    return json({ error: 'Too many signups from this device' }, { status: 400 });
  }

  // Create user
  const user = await createUser(email);

  return json({ user });
}

Client Component

import { Form, useActionData, useNavigation } from '@remix-run/react';

export default function SignupRoute() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post">
      <input type="email" name="email" required />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Validating...' : 'Sign Up'}
      </button>

      {actionData?.error && (
        <p className="error">{actionData.error}</p>
      )}
    </Form>
  );
}

Client-Side Validation

For client-side validation with device fingerprinting:
import { useState } from 'react';
import { Form } from '@remix-run/react';
import { VouchProvider, useValidateEmail } from '@vouch-in/remix/client';

export default function SignupPage() {
  return (
    <VouchProvider
      projectId={window.ENV.VOUCH_PROJECT_ID}
      apiKey={window.ENV.VOUCH_CLIENT_KEY}
    >
      <SignupForm />
    </VouchProvider>
  );
}

function SignupForm() {
  const { validate, loading, data } = useValidateEmail();
  const [email, setEmail] = useState('');
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setError(null);

    const result = await validate(email);

    if (result.recommendation !== 'allow') {
      if (!result.checks.syntax?.pass) {
        setError('Invalid email format');
      } else if (!result.checks.disposable?.pass) {
        setError('Disposable emails not allowed');
      } else {
        setError('Email validation failed');
      }
      return;
    }

    // Submit the form to the server
    (e.target as HTMLFormElement).submit();
  };

  return (
    <Form method="post" onSubmit={handleSubmit}>
      <input
        type="email"
        name="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Validating...' : 'Sign Up'}
      </button>
      {error && <p className="error">{error}</p>}
    </Form>
  );
}

Hybrid Validation

Combine client-side fingerprinting with server-side validation:
// app/routes/signup.tsx
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
import { useState } from 'react';
import { Vouch } from '@vouch-in/remix';
import { VouchProvider, useValidateEmail } from '@vouch-in/remix/client';

const vouch = new Vouch(
  process.env.VOUCH_PROJECT_ID!,
  process.env.VOUCH_SERVER_KEY!
);

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = formData.get('email') as string;
  const fingerprintHash = formData.get('fingerprintHash') as string;

  const result = await vouch.validate(email, {
    ip: request.headers.get('x-forwarded-for') || undefined,
    userAgent: request.headers.get('user-agent') || undefined,
    fingerprintHash, // Include fingerprint from client
  });

  if (result.recommendation !== 'allow') {
    return json({ error: 'Email validation failed' }, { status: 400 });
  }

  const user = await createUser(email);
  return json({ user });
}

export default function SignupPage() {
  return (
    <VouchProvider
      projectId={window.ENV.VOUCH_PROJECT_ID}
      apiKey={window.ENV.VOUCH_CLIENT_KEY}
    >
      <SignupForm />
    </VouchProvider>
  );
}

function SignupForm() {
  const { validate, loading } = useValidateEmail();
  const [email, setEmail] = useState('');
  const [fingerprintHash, setFingerprintHash] = useState('');
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // Validate client-side and get fingerprint hash
    const result = await validate(email);

    if (result.recommendation !== 'allow') {
      return; // Don't submit if client validation fails
    }

    // Store fingerprint hash for server submission
    setFingerprintHash(result.metadata.fingerprintHash || '');

    // Submit form to server
    (e.target as HTMLFormElement).submit();
  };

  return (
    <Form method="post" onSubmit={handleSubmit}>
      <input
        type="email"
        name="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input type="hidden" name="fingerprintHash" value={fingerprintHash} />
      <button type="submit" disabled={loading || navigation.state === 'submitting'}>
        {loading ? 'Validating...' : 'Sign Up'}
      </button>
      {actionData?.error && <p className="error">{actionData.error}</p>}
    </Form>
  );
}

Environment Variables

Set up environment variables in your Remix app:
# .env
VOUCH_PROJECT_ID=your_project_id
VOUCH_SERVER_KEY=your_server_key
VOUCH_CLIENT_KEY=your_client_key
Expose client-side variables via loader:
// app/root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';

export async function loader({ request }: LoaderFunctionArgs) {
  return json({
    ENV: {
      VOUCH_PROJECT_ID: process.env.VOUCH_PROJECT_ID,
      VOUCH_CLIENT_KEY: process.env.VOUCH_CLIENT_KEY,
    },
  });
}

API Types

interface ValidationResponse {
  checks: Record<string, CheckResult>;
  metadata: {
    fingerprintHash: string | null;
    previousSignups: number;
    totalLatency: number;
  };
  recommendation: 'allow' | 'block' | 'flag';
  signals: string[];
}

interface CheckResult {
  pass: boolean;
  error?: string;
  latency: number;
  metadata?: Record<string, unknown>;
}

Next Steps