How to implement a Custom Newsletter form with ConvertKit and Effect

Sandro Maglione

Sandro Maglione

Web development

Learn how to use Effect to perform a request to subscribe a user to your newsletter on ConvertKit:

  • Define component to collect user email
  • Perform API request to sign up the user to the newsletter
  • Implement the code to subscribe the user using Effect

Project configuration

The project uses nextjs.

Note: nextjs is not required, but it is convenient since it allows to implement both client code and API routes

Run the create-next-app command:

npx create-next-app@latest

Note: The project on Github has no styling (no tailwindcss). This is intentional, since the focus is more on Effect and ConvertKit.

I suggest you to then switched to use pnpm instead of npm as package manager.

You can install pnpm from homebrew on my Mac:

brew install pnpm

You can add a preinstall script to ensure that all command will be run using pnpm instead of npm:

"scripts": {
  "preinstall": "npx only-allow pnpm",
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}

Install dependencies

The dependencies for this project are effect, @effect/schema and @effect/platform:

  • effect: Core of the Effect ecosystem
  • @effect/schema: Define and use schemas to validate and transform data
  • @effect/platform: API interfaces for platform-specific services with Effect (used for HTTP requests in the project)

Run the install command:

pnpm install effect @effect/schema @effect/platform

Folder structure

The project contains 2 main folders:

  • app: nextjs's page and layout
  • lib: Implementation of the API to subscribe to the newsletter
  • test: Tests definition and configuration

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Starting point: Services in Effect

The first step in all Effect projects is defining a Service.

A Service in Effect is a Typescript interface defining the API of the application.

Our application has only one method addSubscriber that allows to subscribe an email to ConvertKit:

ConvertKit.ts
import { Effect } from "effect";
 
export interface ConvertKitService {
  /**
   * Add new subscriber with given `email`.
   */
  readonly addSubscriber: (
    email: string
  ) => Effect.Effect<never, never, string>;
}

addSubscriber returns an Effect type. Effect.Effect<never, never, string> has 3 type parameters:

  1. Requirements (never)
  2. Errors (never)
  3. Return value (string)

We are going to define these 3 types more specifically later on, for now we can leave the definition as Effect.Effect<never, never, string>

Context for a Service

Every service in Effect is assigned a tag using Context.

A Tag serves as a representation of the ConvertKitService service. It allows Effect to locate and use this service at runtime:

ConvertKit.ts
import { Context, Effect } from "effect";
 
export interface ConvertKitService {
  /**
   * Add new subscriber with given `email`.
   */
  readonly addSubscriber: (
    email: string
  ) => Effect.Effect<never, never, string>;
}
 
export const ConvertKitService = Context.Tag("@app/ConvertKitService");

Note: We did not define yet a concrete implementation of the service. This is intentional. A service is just the definition of the API, we are going to implement it below using Layer

Config: Environmental variables

A request to the ConvertKit API requires 3 configuration values:

  • API URL (https://api.convertkit.com/v3)
  • Form ID
  • API key
curl -X POST https://api.convertkit.com/v3/forms/<form_id>/subscribe\
     -H "Content-Type: application/json; charset=utf-8"\
     -d '{ \
           "api_key": "<your_public_api_key>",\
           "email": "jonsnow@example.com"\
         }'

We define these 3 parameters using Config from Effect.

First we define a class ConvertKitConfig that exposes the parameters required for the request:

Config.ts
export class ConvertKitConfig {
  constructor(
    readonly apiKey: string,
    readonly url: string,
    readonly formId: string
  ) {}
 
  public get fetchUrl(): string {
    return `${this.url}forms/${this.formId}/subscribe`;
  }
 
  public get headers() {
    return {
      "Content-Type": "application/json",
      charset: "utf-8",
    };
  }
}

We then use Config to collect apiKey, url and formId and create an instance of ConvertKitConfig:

  • Config.all collects all the configuration values (in this case 3 strings defined using Config.string)
  • Config.map extracts the values and converts them to a valid instance of ConvertKitConfig
Config.ts
import { Config } from "effect";
 
export const config = Config.all([
  Config.string("CONVERTKIT_API_KEY"),
  Config.string("CONVERTKIT_API_URL"),
  Config.string("CONVERTKIT_FORM_ID"),
]).pipe(
  Config.map(
    ([apiKey, url, formId]) => new ConvertKitConfig(apiKey, url, formId)
  )
);

Validate response using Schema

The ConvertKit API request when successful returns the information of the new subscribed user in the following format:

{
  "subscription": {
    "id": 1,
    "state": "inactive",
    "created_at": "2016-02-28T08:07:00Z",
    "source": null,
    "referrer": null,
    "subscribable_id": 1,
    "subscribable_type": "form",
    "subscriber": {
      "id": 1
    }
  }
}

We use Schema to validate the response type. In our example we collect only the two id:

Schema.ts
import * as Schema from "@effect/schema/Schema";
 
const SubscribeResponse = Schema.struct({
  subscription: Schema.struct({
    id: Schema.number,
    subscriber: Schema.struct({ id: Schema.number }),
  }),
});
 
export interface SubscribeResponse
  extends Schema.Schema.To<typeof SubscribeResponse> {}

Tip: Check out QuickType to convert a JSON definition to its corresponding Schema

The request also requires 2 parameters in the body:

  • api_key
  • email

We define these parameters using Schema as well:

Schema.ts
import * as Schema from "@effect/schema/Schema";
 
export const SubscribeRequest = Schema.struct({
  api_key: Schema.string,
  email: Schema.string,
});

Request implementation: Layer and HttpClient

We are now ready to define the concrete implementation of ConvertKitService using Layer.

Layers are a way of separating implementation details from the service itself.

Layers act as constructors for creating the service.

We use Layer.succeed since ConvertKitService is a simple service without any dependencies.

Layer.succeed requires the service Context as first parameter and a concrete implementation of the service as second parameter (created using ConvertKitService.of):

ConvertKit.ts
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) => // TODO
  })
);

addSubscriber implementation

addSubscriber returns an Effect. We use Effect.gen to create it:

Server.ts
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        // TODO
      }),
  })
);

The first step is collecting the configuration parameters defined previously using Config. We use Effect.config to extract a valid instance of ConvertKitConfig:

ConvertKit.ts
import * as AppConfig from "./Config";
 
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        const convertKitConfig = yield* _(Effect.config(AppConfig.config));
      }),
  })
);

The second step is defining the request. We use HttpClient from @effect/platform:

  • request.post: POST request at the given URL
  • request.setHeaders: Define headers for the request
  • request.schemaBody: Provide the body of the POST request from a Schema (SubscribeRequest)

HttpClient allows to define the request using a declarative style (URL, headers, body)

ConvertKit.ts
import * as Http from "@effect/platform/HttpClient";
import * as AppSchema from "./Schema";
import * as AppConfig from "./Config";
 
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        const convertKitConfig = yield* _(Effect.config(AppConfig.config));
 
        const req = yield* _(
          Http.request.post(convertKitConfig.url),
          Http.request.setHeaders(convertKitConfig.headers),
          Http.request.schemaBody(AppSchema.SubscribeRequest)({
            api_key: convertKitConfig.apiKey,
            email,
          })
        );
      }),
  })
);

We can now use Http.client.fetch() to send a fetch request using the req we defined above:

  • Http.client.fetch() uses fetch to send the given request
  • response.schemaBodyJson validates the response using Schema (SubscribeResponse)
ConvertKit.ts
import * as Http from "@effect/platform/HttpClient";
import * as AppSchema from "./Schema";
import * as AppConfig from "./Config";
 
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        const convertKitConfig = yield* _(Effect.config(AppConfig.config));
 
        const req = yield* _(
          Http.request.post(convertKitConfig.url),
          Http.request.setHeaders(convertKitConfig.headers),
          Http.request.schemaBody(AppSchema.SubscribeRequest)({
            api_key: convertKitConfig.apiKey,
            email,
          })
        );
 
        return yield* _(
          req,
          Http.client.fetch(),
          Effect.flatMap(
            Http.response.schemaBodyJson(AppSchema.SubscribeResponse)
          )
        );
      }),
  })
);

This is it. We can now also update the service interface definition with all the errors and response type:

ConvertKit.ts
export interface ConvertKitService {
  /**
   * Add new subscriber with given `email`.
   */
  readonly addSubscriber: (
    email: string
  ) => Effect.Effect<
    never,
    | ConfigError.ConfigError
    | Http.body.BodyError
    | Http.error.RequestError
    | Http.error.ResponseError
    | ParseResult.ParseError,
    AppSchema.SubscribeResponse
  >;
}

The Type Definition is all you need πŸͺ„ This interface tells you everything you need to know about the API using the Effect type πŸ’πŸΌβ€β™‚οΈ No dependencies ⛔️ List all the possible errors βœ… Returns a SubscribeResponse Type safe, easy to read and maintain πŸš€

Image
Sandro Maglione
Sandro Maglione
@SandroMaglione

Weekly project: @ConvertKit + @nextjs + @EffectTS_ Implement your custom newsletter form using NextJs, the ConvertKit API, and Effect Interested? I will share all open source and on my newsletter πŸ‘‡ sandromaglione.com/newsletter?ref…

15
Reply

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

API route handler

We are going to use ConvertKitService inside a nextjs API route.

A route provides a Request. We now need to validate the request and extract the email sent in the body.

We create a new main function that returns an Response inside Effect:

Server.ts
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    // TODO
  });

The first step is extracting the body from the POST request. We define a new error for this operation using Data.TaggedError:

Server.ts
class RequestJsonError extends Data.TaggedError("RequestJsonError")<{
  error: unknown;
}> {}
 
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
  });

We need to verify that jsonBody contains a valid email. We define a new schema to validate jsonBody:

Schema.ts
import * as Schema from "@effect/schema/Schema";
 
export const RouteRequest = Schema.struct({
  email: Schema.string,
});

We then use the schema to extract a valid email from the body:

Server.ts
import * as AppSchema from "./Schema";
 
class RequestJsonError extends Data.TaggedError("RequestJsonError")<{
  error: unknown;
}> {}
 
class RequestMissingEmailError extends Data.TaggedError(
  "RequestMissingEmailError"
)<{
  jsonBody: any;
  parseError: ParseResult.ParseError;
}> {}
 
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
 
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
  });

Finally we can call addSubscriber from ConvertKitService and return a Response:

Server.ts
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
 
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
 
    const convertKit = yield* _(ConvertKit.ConvertKitService);
    const subscriber = yield* _(convertKit.addSubscriber(body.email));
    return Response.json(subscriber);
  });

Handle errors and dependencies

This code does not work yet. We need to handle all errors and provide all the dependencies to satisfy the return type Effect.Effect<never, never, Response>.

main has a dependency on ConvertKitService. Therefore we use Effect.provide to pass a valid instance of ConvertKitService:

Server.ts
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
 
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
 
    const convertKit = yield* _(ConvertKit.ConvertKitService);
    const subscriber = yield* _(convertKit.addSubscriber(body.email));
    return Response.json(subscriber);
  }).pipe(Effect.provide(ConvertKit.ConvertKitServiceLive));

We then need to handle all errors:

  • Effect.catchTags allows to handle specific errors from their _tag
  • Effect.catchAll allows to handle all (remaining) errors at once
Server.ts
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
 
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
 
    const convertKit = yield* _(ConvertKit.ConvertKitService);
    const subscriber = yield* _(convertKit.addSubscriber(body.email));
    return Response.json(subscriber);
  })
    .pipe(Effect.provide(ConvertKit.ConvertKitServiceLive))
    .pipe(
      Effect.catchTags({
        RequestMissingEmailError: () =>
          Effect.succeed(
            Response.json(
              { error: "Missing email in request" },
              { status: 400 }
            )
          ),
        RequestJsonError: () =>
          Effect.succeed(
            Response.json(
              { error: "Error while decoding request" },
              { status: 400 }
            )
          ),
      })
    )
    .pipe(
      Effect.catchAll(() =>
        Effect.succeed(
          Response.json(
            { error: "Error while performing request" },
            { status: 500 }
          )
        )
      )
    );

That's all! Now we can use and run this Effect using runPromise inside the API route:

app/api/subscribe/route.ts
import { main } from "@/lib/Server";
import { Effect } from "effect";
 
export async function POST(request: Request): Promise<Response> {
  return main(request).pipe(Effect.runPromise);
}

Perform request on the client

The very last step is to implement the component to send the request to the API.

Before doing that we need to create a new Effect. The user provides an email (string) and the Effect is responsible to make the API request:

Client.ts
export const main = (email: string) =>
  Effect.gen(function* (_) {
    // TODO
  });

The implementation is similar to before:

  • Config to collect the endpoint of the API
  • HttpClient to define the request, passing the email in the body
  • Http.client.fetch() to perform the request
Client.ts
import * as AppSchema from "@/lib/Schema";
import * as Http from "@effect/platform/HttpClient";
 
export const main = (email: string) =>
  Effect.gen(function* (_) {
    const apiUrl = yield* _(Effect.config(Config.string("SUBSCRIBE_API")));
    const req = yield* _(
      Http.request.post(apiUrl),
      Http.request.acceptJson,
      Http.request.schemaBody(AppSchema.RouteRequest)({ email })
    );
 
    return yield* _(
      req,
      Http.client.fetch(),
      Effect.flatMap(Http.response.schemaBodyJson(AppSchema.SubscribeResponse))
    );
  });

page component

We can then call main inside a react component, passing the email provided by the user:

page.tsx
"use client"
import { useState } from "react";
 
export default function Page() {
  const [email, setEmail] = useState("");
  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // TODO
  };
 
  return (
    <form onSubmit={onSubmit}>
      <input
        type="email"
        name="email"
        id="email"
        placeholder="Email address"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Inside onSubmit we call main using runPromiseExit, which returns Exit.

Exit is similar to Either: it has either a Failure or Success value.

type Exit<E, A> = Failure<E, A> | Success<E, A>

Using Exit.match we can provide a response to the user for both success and failure:

page.tsx
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  (await Client.main(email).pipe(Effect.runPromiseExit)).pipe(
    Exit.match({
      onFailure: (cause) => {
        console.error(cause);
      },
      onSuccess: (subscriber) => {
        console.log(subscriber);
      },
    })
  );
};

ConvertKit form id

Inside Config we specify the ConvertKit form id (CONVERTKIT_FORM_ID).

Every subscriber on ConvertKit is linked to a form. A form id is therefore required to subscribe a new email.

You need to create a form inside the "Landing Pages & Forms" section:

Create a form to subscribe a new user in your ConvertKit accountCreate a form to subscribe a new user in your ConvertKit account

Create and open the page to edit the form, then click on the "Publish" button. You can find the id of the form you just created inside the popup:

You can find the form id inside the "Publish" popupYou can find the form id inside the "Publish" popup


This is it!

Open the page, add your email, and sign up! All powered by ConvertKit and Effect!

How do we make sure it all works as expected? Testing!

Turns out testing becomes easy and natural when using Effect. We can inject custom Config values for testing and we can use Mock Service Worker to mock HTTP requests.

πŸ‘‰ You can read the next article on how to use vitest and msw to test and Effect app

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.