Building a Stripe Checkout Process with Astro and Fly.io

📘 Technical

📆 2024-12-02

How to leverage cutting-edge web frameworks and APIs to build eCommerce sites with minimal cost or fuss

Building a Stripe Checkout Process with Astro and Fly.io

Recently I’ve been helping my brother build a website for a hot sauce he’s been making. Incidentally, the hot sauce is quite good. Though I’ll admit to my biases, I can say objectively that the hot sauce has a thick, smoky quality to it that I’ve not had in any of the (admittedly few) hot sauces I’ve tried in the past. It’s a critical part of my daily routine at this point, as it’s a mandatory ingredient in my morning eggs alongside Trader Joe’s unimpeachable everything bagel seasoning. One day, wanting to see if my limited culinary palate was blinding me to better alternatives, I went to the grocery store and bought every hot sauce I could find on the shelves for a taste comparison. Several dozen bottle later, there’s still only one hot sauce I put on my eggs, and it’s Antonito’s. If you are interested in trying it (and you should!) I recommend checking out the site at https://antonitos.shop.

This, however, is not an article about hot sauces, a subject on which I know precious little, but about web development, on which I know marginally more. More specifically, I want to write a little bit about how to get a basic eCommerce site built using the following technologies:

  • Stripe
  • Astro
  • Fly.io

Most of what I’ll be covering can be pieced together through reading the docs for these respective platforms. My reasons for writing the steps down nonetheless are twofold:

  1. To serve as a resource for myself when I inevitably forget some of these steps in the distant future (aka 6 months from now), as well as to anyone else looking to build a project using the aforementioned stack.
  2. When I searched this topic at the onset of the project, the top Google result proposed saving your Stripe secret API key in your website’s HTML. At the risk of stating the obvious, this is absolutely not a recommended practice unless you enjoy giving strangers access to your financial information.

You’ll find relevant code snippets throughout, but hang on till the end for a link to the full repo. With no further ado, let’s get into it.

The Key: Astro Endpoints

As previously noted, storing private API keys in public HTML is a horrendous idea. The very existence of such an ill-advised solution, however, points to a challenge that you’ll run into somewhat often when building with Astro or a related frontend-focused framework.

By focusing primarily on frontend functionality, Astro and its compatriots (Next, Remix, Nuxt, etc), differ from the “old guard” of web frameworks (Rails, Django, Laravel, etc), which emphasize server-side solutions. This frontend focus comes with its fair share of upsides, including better frontend performance and oftentimes lower hosting costs. One downside of such an approach, however is that, and this is strictly a subjective observation, but adding backend functionality to frontend frameworks oftentimes requires more steps than adding frontend functionality to a backend framework.

When confronted with these additional steps, one can be tempted to take shortcuts and add logic to the frontend that’d be more well suited to a server. Take this approach to its extreme, however, and you end up storing API keys in places where anybody with a basic knowledge of HTML can access. Not great.

Fortunately, Astro offers an accessible solution to these kinds of problems: Endpoints. By creating a JavaScript file (.ts or .js, not .astro) in your pages directory and filling it with functions corresponding to the HTTP methods you are looking to service, you can add backend functions on an as-needed basis.

For the particular use case of interacting with the Stripe API, we can keep our endpoint functions very lightweight since the majority of the billing logic is handled on Stripe’s end.

Here’s the entirety of the logic in my shop’s endpoint creating a Stripe Checkout session:

import type { APIRoute } from "astro";
import { createCheckoutSession } from "../../api/stripe";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
    try {
        const body = await request.json();
        const productPrice = body.productPrice;

        const session = await createCheckoutSession(productPrice);

        if (!session?.url) {
            return new Response(
                JSON.stringify({ message: "Failed to create checkout session" }), 
                { status: 400 }
            );
        }

        return new Response(
            JSON.stringify({ checkoutUrl: session.url }), 
            { status: 200 }
        );
    } catch (error) {
        return new Response(
            JSON.stringify({ message: "Something went wrong. Please try again." }), 
            { status: 500 }
        );
    }
};

Most of the logic for talking to Stripe is tucked away inside a createCheckoutSession function which we’ll look at in a minute. The other thing to note here is the export const prerender = false near the top of the file. This export speaks directly to the frontend/backend tension from before; we want to add API support to our app in a way that doesn’t negate the benefits we get from using a frontend framework.

By default, Astro builds our entire website during the build process in a process called prerendering, so that there’s virtually no work left to be done by the time our users visit. This gives us major performance enhancements virtually for free, and we want to do everything possible to maintain those advantages.

However, because our API routes need to take in data from the frontend to work correctly, we don’t have the option of running this code in advance. We can’t very well create a checkout session without knowing what a customer is checking out!

To make things work as we expect while not giving up the benefits of prerendering, we need to do two things:

  1. Edit astro.config.js to set output to hybrid, which tells Astro we will have both prerendered and on-demand routes in our app. I also added the node adapter in standalone mode — for more info on what that means, check the Astro docs here
  2. Add export const prerender = false to any pages that should not be prerendered. While the hybrid output mode will add all pages to our prerendering process by default, Astro allows us to add the prerender = false export to any page to exclude it from this process. In our website, this is all of our API routes, but no other routes. If we had routes requiring authentication, we’d probably add this export to some of our frontend files as well, but in this case we are leaving the authentication to Stripe.

Once created, these API routes offer the perfect amount of separation between our frontend and the Stripe API. They receive data about the customer’s selection from the frontend, formats and sends that to Stripe, then handles the response from Stripe and returns either confirmation or a user-friendly error message to display in the UI.

From here, there’s nothing in the checkout process that’s specific to Astro in particular. We use our API routes to pass along the relevant checkout information to Stripe, and in return we receive a link to our newly-created checkout session, which we can use to redirect our users one step closer to hot sauce nirvana. As promised, I’m including the relevant checkout functions below, but there’s not much here that you couldn’t find in the Stripe docs or some other similar example. There’s some additional steps I had to take because I’m not using the Stripe SDK, but that’s only because I am A) stubborn bordering on lazy and B) reluctant to add any dependencies where a couple extra lines of code will do the trick. If you are slightly more reasonable than I and end up using the SDK, your solution will be even simpler than this:

// add this to your meta env using Fly.io build secrets (discussed in the next section) 
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_API_KEY;
const STRIPE_API_URL = "https://api.stripe.com/v1";

async function postToStripe(endpoint: string, params: Record<string, string> = {}, body: Record<string, any> = {}) {
    const url = new URL(`${STRIPE_API_URL}${endpoint}`);

    Object.entries(params).forEach(([key, value]) => {
        url.searchParams.append(key, value);
    });

    const response = await fetch(url.toString(), {
        method: "POST",
        headers: {
            Authorization: `Bearer ${STRIPE_SECRET_KEY}`,
            "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams(body),
    });

    return await response.json();
}

async function createCheckoutSession(productPrice: string) {
    const response = await postToStripe("/checkout/sessions", {}, {
        mode: "payment",
        success_url: "https://antonitos.shop/thank-you",
        cancel_url: "https://antonitos.shop",
        'line_items[0][price]': productPrice,
        'line_items[0][quantity]': "1",
        'shipping_address_collection[allowed_countries][0]': "US",
    });

    return response;
}

Build Secrets

Depending on the hosting provider you are using, the above may be enough to get your Stripe integration working with Astro. However, when deploying my website for the first time I ran into a number of smaller issues. After some debugging I learned each of them were symptoms of a broader issue concerning how the tools of my stack were interacting with each other.

Basically, the crux of the issue came down to this:

  1. I needed to access Stripe product data during build time, so that I could create one product page per product using Astro’s getStaticPaths function (more on that here).
  2. Because I needed to access Stripe during build time, I needed to access my Stripe API key during build time. However:
  3. Fly.io’s Secrets API only works for runtime secrets. Therefore, even though I had properly added my API key prior to the first deployment, my builds were still failing with an error message stating I had not provided a Stripe API key.

Fortunately, once I had diagnosed the error, following Fly.io’s documentation on Build Secrets was enough to get things working as intended. To add secrets to Fly’s build process with Astro, take the following two steps:

  1. Modify the RUN command of your Dockerfile to look something like this:
  2. RUN --mount=type=secret,id=STRIPE_API_KEY \
        export STRIPE_API_KEY=$(cat /run/secrets/STRIPE_API_KEY) && \
        npm run build
  3. When deploying your application, change your command to look something like this:
  4. fly deploy --build-secret STRIPE_API_KEY=`YOUR_API_KEY`

Conclusion

With the steps above, you too can create your own basic eCommerce shop using Astro & Stripe. You won’t even need to expose your bank account to anyone with an Internet connection!

Should you have further questions or want to work from an existing baseline, the code to Antonito’s shop can be found at this GitHub link. And don’t forget to check out the hot sauce on the Antonito’s website if you’re in the market for a game-changing hot sauce!