Building a Payment System with Stripe and Remix Run in 2025

Published on
Written byAbhishek Anand
Building a Payment System with Stripe and Remix Run in 2025

Modern web applications often require robust payment processing capabilities. Stripe has long been the go-to solution for developers seeking a reliable and developer-friendly payment API. Meanwhile, Remix Run has emerged as a powerful React-based framework that provides an excellent foundation for building dynamic, server-rendered applications. Combining these technologies creates an optimal stack for implementing payment systems in your web applications.

This guide walks you through integrating Stripe with Remix Run to create a secure, performant payment system in 2025.


Why Choose Stripe with Remix Run?

Before diving into implementation details, let's explore why this combination works so well:

  • Server-side security: Remix's server-side rendering approach helps keep sensitive payment information secure by processing Stripe API calls on the server.
  • Progressive enhancement: Remix's approach to JavaScript ensures your payment forms work even if JavaScript fails to load.
  • Loader/action pattern: Remix's data handling patterns work perfectly with Stripe's API structure.
  • Error handling: Both technologies provide robust error management, crucial for payment processing.

Setting Up Your Environment

Let's start by setting up a new Remix project with Stripe integration:

npx create-remix@latest my-stripe-app
cd my-stripe-app
npm install stripe @stripe/stripe-js @stripe/react-stripe-js

Configuring Stripe Keys

Create a .env file in your project root to store your Stripe API keys:

STRIPE_PUBLIC_KEY=pk_test_your_public_key
STRIPE_SECRET_KEY=sk_test_your_secret_key

Remember to add this file to your .gitignore to keep your keys secure.

Creating a Stripe Provider Component

First, let's create a Stripe provider component to initialize the Stripe instance:

// app/components/StripeProvider.jsx
import { useState, useEffect } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { useLoaderData } from '@remix-run/react';

export default function StripeProvider({ children }) {
  const { stripePublicKey } = useLoaderData();
  const [stripePromise, setStripePromise] = useState(null);

  useEffect(() => {
    if (stripePublicKey) {
      setStripePromise(loadStripe(stripePublicKey));
    }
  }, [stripePublicKey]);

  return (
    <Elements stripe={stripePromise}>
      {children}
    </Elements>
  );
}

Setting Up the Payment Route

Create a payment route in your Remix application:

// app/routes/checkout.jsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { redirect } from '@remix-run/node';
import Stripe from 'stripe';
import StripeProvider from '~/components/StripeProvider';
import PaymentForm from '~/components/PaymentForm';

export const loader = async () => {
  return json({
    stripePublicKey: process.env.STRIPE_PUBLIC_KEY,
  });
};

export const action = async ({ request }) => {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  const formData = await request.formData();
  const paymentMethodId = formData.get('paymentMethodId');
  const amount = formData.get('amount');
  const currency = formData.get('currency') || 'usd';

  try {
    // Create a payment intent
    const paymentIntent = await stripe.paymentIntents.create({
      amount: Math.round(parseFloat(amount) * 100), // Convert to cents
      currency,
      payment_method: paymentMethodId,
      confirm: true,
      return_url: `${new URL(request.url).origin}/payment-success`,
    });

    // Handle next steps based on PaymentIntent status
    if (paymentIntent.status === 'succeeded') {
      return redirect('/payment-success');
    } else if (paymentIntent.status === 'requires_action') {
      return json({ clientSecret: paymentIntent.client_secret, requiresAction: true });
    } else {
      return json({ error: 'Payment failed' }, { status: 400 });
    }
  } catch (error) {
    return json({ error: error.message }, { status: 400 });
  }
};

export default function Checkout() {
  const data = useLoaderData();

  return (
    <StripeProvider>
      <div className="checkout-container">
        <h1>Complete Your Payment</h1>
        <PaymentForm />
      </div>
    </StripeProvider>
  );
}

Creating a Payment Form Component

Now, let's create a payment form component:

// app/components/PaymentForm.jsx
import { useState } from 'react';
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
import { Form, useSubmit, useActionData } from '@remix-run/react';

export default function PaymentForm() {
  const stripe = useStripe();
  const elements = useElements();
  const submit = useSubmit();
  const actionData = useActionData();
  const [error, setError] = useState(null);
  const [processing, setProcessing] = useState(false);

  const handleSubmit = async (event) => {
    event.preventDefault();
    
    if (!stripe || !elements) {
      return;
    }

    setProcessing(true);
    setError(null);

    const cardElement = elements.getElement(CardElement);
    
    // Create payment method
    const { error, paymentMethod } = await stripe.createPaymentMethod({
      type: 'card',
      card: cardElement,
    });

    if (error) {
      setError(error.message);
      setProcessing(false);
      return;
    }

    // Create form data to submit to action
    const formData = new FormData();
    formData.append('paymentMethodId', paymentMethod.id);
    formData.append('amount', '29.99'); // Replace with dynamic amount
    formData.append('currency', 'usd');

    submit(formData, { method: 'post' });
  };

  // Handle required additional actions (3D Secure, etc.)
  const handleRequiresAction = async () => {
    if (actionData?.requiresAction && actionData?.clientSecret) {
      setProcessing(true);
      const { error } = await stripe.confirmCardPayment(actionData.clientSecret);
      if (error) {
        setError(error.message);
      }
      setProcessing(false);
    }
  };

  // Call handleRequiresAction when actionData changes
  useEffect(() => {
    if (actionData?.requiresAction) {
      handleRequiresAction();
    }
  }, [actionData]);

  return (
    <Form onSubmit={handleSubmit} className="payment-form">
      <div className="form-row">
        <label htmlFor="card-element">Credit or debit card</label>
        <CardElement id="card-element" options={{
          style: {
            base: {
              fontSize: '16px',
              color: '#424770',
              '::placeholder': {
                color: '#aab7c4',
              },
            },
            invalid: {
              color: '#9e2146',
            },
          },
        }} />
      </div>

      {error && <div className="error-message">{error}</div>}
      {actionData?.error && <div className="error-message">{actionData.error}</div>}

      <button 
        type="submit" 
        disabled={!stripe || processing}
        className="payment-button"
      >
        {processing ? 'Processing...' : 'Pay $29.99'}
      </button>
    </Form>
  );
}

Creating Success and Error Pages

Don't forget to create routes for successful payments and handling errors:

// app/routes/payment-success.jsx
export default function PaymentSuccess() {
  return (
    <div className="success-container">
      <h1>Payment Successful!</h1>
      <p>Thank you for your purchase. You will receive a confirmation email shortly.</p>
      <a href="/" className="button">Return to Home</a>
    </div>
  );
}

Advanced: Implementing Webhooks

For a production-ready system, you'll want to implement Stripe webhooks to handle asynchronous events:

// app/routes/api/stripe-webhook.jsx
import { json } from '@remix-run/node';
import Stripe from 'stripe';

export const action = async ({ request }) => {
  if (request.method !== 'POST') {
    return json({ error: 'Method not allowed' }, { status: 405 });
  }

  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  const signature = request.headers.get('stripe-signature');
  
  if (!signature) {
    return json({ error: 'Missing Stripe signature' }, { status: 400 });
  }

  try {
    const text = await request.text();
    const event = stripe.webhooks.constructEvent(
      text,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );

    // Handle different event types
    switch (event.type) {
      case 'payment_intent.succeeded':
        const paymentIntent = event.data.object;
        // Update your database, send confirmation emails, etc.
        console.log(`PaymentIntent ${paymentIntent.id} succeeded`);
        break;
      case 'payment_intent.payment_failed':
        const failedPayment = event.data.object;
        console.log(`Payment failed: ${failedPayment.last_payment_error?.message}`);
        // Handle the failure
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return json({ received: true });
  } catch (err) {
    return json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
  }
};

Best Practices for Stripe Integration in Remix

When integrating Stripe with Remix, keep these best practices in mind:

  1. Never log full payment details - Even in development, avoid logging card numbers or sensitive information.
  2. Use Stripe's test mode - During development, use Stripe's test API keys and test card numbers.
  3. Implement idempotency - For production systems, use Stripe's idempotency keys to prevent duplicate charges.
  4. Leverage Remix's error boundaries - Create specific error boundaries for payment-related components:
export function ErrorBoundary() {
  return (
    <div className="error-container">
      <h1>Payment Error</h1>
      <p>There was an error processing your payment. Please try again or contact support.</p>
    </div>
  );
}
  1. Follow PCI compliance guidelines - Using Stripe Elements helps with PCI compliance, but ensure your implementation follows current security standards.

Enhancing the User Experience

Consider these enhancements to improve the payment experience:

  • Save payment methods for returning customers using Stripe Customer API
  • Implement dynamic payment methods based on customer location
  • Add Apple Pay and Google Pay support for mobile users
  • Create a subscription management interface for recurring payments

Troubleshooting Common Issues

CORS Errors

If you encounter CORS errors, ensure your Remix server is properly configured to handle Stripe's requests.

Payment Confirmation Delays

If payment confirmations seem delayed, check that your webhook implementation is correctly set up and your server can receive Stripe's webhook events.

Test Mode vs. Live Mode

Remember that test mode and live mode in Stripe use completely different sets of API keys. Issues can arise when mixing these environments.

Conclusion

Integrating Stripe with Remix Run creates a powerful foundation for handling payments in modern web applications. The server-side rendering capabilities of Remix paired with Stripe's robust API offer numerous advantages for creating secure, user-friendly payment experiences.

By following this guide, you've learned how to set up a basic payment system with Stripe in a Remix application. As you build out your application, remember to consult Stripe's documentation for the latest best practices and new features that can enhance your payment system.

Happy coding!


Ready to get started?

Start your journey with us today and get access to our resources and tools.