DEV Community

Daniel
Daniel

Posted on • Originally published at daniel.es

How to create an API endpoint in astro

Introduction

Gone are the days when you had to create a separate API server to serve your frontend application. With Astro, you can create API endpoints directly in your app. Which means you can even create a full-stack application with just one codebase.

Personally, I use these endpoints for simple actions such as:

  • Contact Forms
  • Newsletter subscriptions
  • User registration
  • Even user authentication sometimes

It's also useful for when you need to fetch and process data from a source that requires authentication (I.e. an API key) and you don't want to expose that key in the frontend.

In this article we'll go through the steps to create an API endpoint in Astro.

What's the setup?

In this article we'll create a simple API endpoint that creates a contact in Brevo, which is mostly what I use these endpoints for. You can replace this with any other service you want to interact with.

1. Ready your Astro project

If you're new to Astro or haven't set up an Astro project yet, I recommend you to check out the official documentation.

You can also start with one of their themes.

2. SSR

In order to have a working API endpoint that works at runtime, you need to enable SSR (Server Sider Rendering).

SSR allows you to have the app run on the server before it gets to the client.

2.1 Adapter

For this, you will need an adapter.

What an adapter does is it allows you to run your SSR Astro App in different environments. For example, you can run it in a serverless environment like Vercel or Netlify, or in a Node.js server.

You can see a list of official and community adapters here.

For this article, I'll use the @astrojs/adapter-node adapter since I host my side in a Node docker container.

Super easy to install:

npx astro add node
Enter fullscreen mode Exit fullscreen mode

2.2 server or hybrid

Astro allows you to run SSR in 2 ways:

  • server: On-demand rendered by default. Basically uses the server for everything. Use this when most of your site should be dynamic. You can opt-out of SSR for individual pages or endpoints.

  • hybrid: Pre-rendered to HTML by default. It does not pre-render the page on the server. Use this when most of your site should be static. You can opt-in to SSR for individual pages or endpoints.

For my usecase where most of my site is static (it's a landing page after all) I use hybrid:

// astro.config.mjs

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "hybrid",
  adapter: node({
    mode: "standalone",
  }),
});
Enter fullscreen mode Exit fullscreen mode

2.3 A note on environment variables

If you're used to Astro, you know that you can use environment variables by calling:

const MY_VARIABLE = import.meta.env.VARIABLE_NAME;
Enter fullscreen mode Exit fullscreen mode

However, because Astro is static by default, what this really does is get the environment variable at build time. Then, the variable is hardcoded into the build code, which means that if you change the environment variable after the build, it won't be reflected in the code.

If you use SSR it works differently, import.meta.env won't work since it's available at build time but not at runtime on the server.

You will need to use process.env instead:

const MY_VARIABLE = process.env.VARIABLE_NAME;
Enter fullscreen mode Exit fullscreen mode

BUT WAIT!

There's another catch, and that's that process.env is not available with npm run dev, which means your code will crash when you try to run it locally.

The solution:

const MY_VARIABLE = import.meta.env.VARIABLE_NAME ?? process.env.VARIABLE_NAME;
Enter fullscreen mode Exit fullscreen mode

This code will try to get the environment variable from import.meta.env first, and if it's not available it will try to get it from process.env. This way, your code will work both in development and production.

2.4 A note on console.log

If you're used to using console.log to debug your code, you'll know that it will show up in the browser console when you're running the app in development mode.

When using console.log in an SSR component, because it runs on the server, the logs will show up in the terminal where you're running the app.

So if you're looking for your logs and can't find them, check the terminal where you're running the app.

3. File structure

The full functionality needs 2 files:

  • A .js or .ts API endpoint file that lives in the src/pages/api directory.
  • A form that gets the user input and sends it to the API endpoint. I personally like to do this in a .tsx file because I can then use the full power of react (react-hook-form and zod) to handle the form. Place this form wherever you like, I like having all my forms in src/components/forms.

That's pretty much it! The form will send the data to our API endpoint, which will then process it and send it to Brevo.


The API endpoint

1. Create the file

Let's create the API endpoint that will send the data to Brevo.

You can create this endpoint wherever you want under the src/pages/ directory depending on where you want it to be accessible.

For instance, I like my endpoints to be accessible under /api/ so I create a src/pages/api/ directory.

So my endpoint file will be src/pages/api/create-brevo-contact.ts.

This means that I will be able to access it at http://mydomain.com/api/create-brevo-contact.


2. The code

Your API endpoint code should have a pretty simple structure:

// SSR API endpoint template

// Tell Astro that this component should run on the server
// You only need to specify this if you're using the hybrid output
export const prerender = false;

// Import the APIRoute type from Astro
import type { APIRoute } from "astro";

// This function will be called when the endpoint is hit with a GET request
export const GET: APIRoute = async ({ request }) => {
  // Do some stuff here

  // Return a 200 status and a response to the frontend
  return new Response(
    JSON.stringify({
      message: "Operation successful",
    }),
    {
      status: 200,
    }
  );
};
Enter fullscreen mode Exit fullscreen mode

Following the template above, this is a simple POST API endpoint to create a contact in Brevo. Everything is commented so you can understand what's going on:

// src/pages/api/create-brevo-contact.ts

// Because I chose hybrid, I need to specify that this endpoint should run on the server:
export const prerender = false;

// Import the APIRoute type from Astro
import type { APIRoute } from "astro";

// This is the function that will be called when the endpoint is hit
export const POST: APIRoute = async ({ request }) => {
  // Check if the request is a JSON request
  if (request.headers.get("content-type") === "application/json") {
    // Get the body of the request
    const body = await request.json();

    // Get the email from the body
    const email = body.email;

    // Declares the Brevo API URL
    const BREVO_API_URL = "https://api.brevo.com/v3/contacts";

    // Gets the Brevo API Key from an environment variable
    // Check the note on environment variables in the SSR section of this article to understand what is going on here
    const BREVO_API_KEY =
      import.meta.env.BREVO_API_KEY ?? process.env.BREVO_API_KEY;

    // Just a simple check to make sure the API key is defined in an environment variable
    if (!BREVO_API_KEY) {
      console.error("No BREVO_API_KEY defined");
      return new Response(null, { status: 400 });
    }

    // The payload that will be sent to Brevo
    // This payload will create or update the contact and add it to the list with ID 3
    const payload = {
      updateEnabled: true,
      email: email,
      listIds: [3],
    };

    // Whatever process you want to do in your API endpoint should be inside a try/catch block
    // In this case we're sending a POST request to Brevo
    try {
      // Make a POST request to Brevo
      const response = await fetch(BREVO_API_URL, {
        method: "POST",
        headers: {
          accept: "application/json",
          "api-key": BREVO_API_KEY,
          "content-type": "application/json",
        },
        body: JSON.stringify(payload),
      });

      // Check if the request was successful
      if (response.ok) {
        // Request succeeded
        console.log("Contact added successfully");

        // Return a 200 status and the response to our frontend
        return new Response(
          JSON.stringify({
            message: "Contact added successfully",
          }),
          {
            status: 200,
          }
        );
      } else {
        // Request failed
        console.error("Failed to add contact to Brevo");

        // Return a 400 status to our frontend
        return new Response(null, { status: 400 });
      }
    } catch (error) {
      // An error occurred while doing our API operation
      console.error(
        "An unexpected error occurred while adding contact:",
        error
      );

      // Return a 400 status to our frontend
      return new Response(null, { status: 400 });
    }
  }

  // If the POST request is not a JSON request, return a 400 status to our frontend
  return new Response(null, { status: 400 });
};
Enter fullscreen mode Exit fullscreen mode

That's it, you now have a working API endpoint that will create a contact in Brevo when hit with a POST request!


The form

As a bonus, I also want to show you how I code my forms to make them responsive.

For this example, I'll create a simple form, with only an email field and a submit button, that will send the email a user inputs to the API endpoint we created.

Here's the code:

Note that this code is using shadcn ui components for the HTML, you might need to replace them with your own components.

// WaitlistForm.tsx

// Zod validation stuff
const WaitlistFormSchema = z.object({
  email: z
    .string()
    .min(1, "Please enter a valid email")
    .email("Please enter a valid email"),
});

type WaitlistFormValues = z.infer<typeof WaitlistFormSchema>;

const WaitlistForm = () => {
  // Hooks to check the status of the form
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isSuccess, setIsSuccess] = useState(false);
  const [error, setError] = useState("");

  // React Hook Form stuff
  const form = useForm<WaitlistFormValues>({
    resolver: zodResolver(WaitlistFormSchema),
    defaultValues: {
      email: "",
    },
  });

  // Function that sends the data to the API endpoint when the form is submitted
  const onSubmit = async (data: WaitlistFormValues) => {
    setIsSubmitting(true);

    // Ping out API endpoint
    const response = await fetch("/api/create-brevo-contact", {
      method: "POST",
      headers: {
        "content-type": "application/json",
      },
      body: JSON.stringify(data),
    });

    // If successful, reset the form and show a success message
    if (response.ok) {
      form.reset();
      setIsSuccess(true);
    } else {
      // If failed, show error message
      console.error("Failed to add contact");
      setIsSuccess(false);
      setError("There's been an error. Please try again.");
    }

    setIsSubmitting(false);
  };

  return (
    <>
      {isSuccess && (
        <Alert className="mb-3 md:mb-8 bg-green-100 border-green-300">
          <AlertTitle>Thanks!</AlertTitle>
          <AlertDescription>
            We've added you to the waitlist!
            <br />
          </AlertDescription>
        </Alert>
      )}

      {!isSuccess && error && (
        <Alert className="mb-8 bg-red-100 border-red-300">
          <AlertTitle>Error</AlertTitle>
          <AlertDescription>{error}</AlertDescription>
        </Alert>
      )}

      <Form {...form}>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          className="space-y-4 md:space-y-8"
        >
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input
                    className="bg-transparent"
                    placeholder="email@gmail.com"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <Button type="submit" disabled={isSubmitting}>
            <Loader2
              className={`w-6 h-6 mr-2 animate-spin ${
                isSubmitting ? "block" : "hidden"
              }`}
            />
            Submit
          </Button>
        </form>
      </Form>
    </>
  );
};

export default WaitlistForm;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)