Build a Stripe App with Node.js and Typescript - Part 1

In this guide, we will build a Stripe App that lets you leave notes on a Customer using Typescript.

Build a Stripe App with Node.js and Typescript - Part 1

This is Part 1 in the series of guides on building a Stripe App.

Introduction

Stripe Apps extend a Stripe Business Owner's capabilities by sharing screen real estate with Stripe Dashboard. Here's a look at a Stripe App we will be building.

In this guide, we will build a Stripe app called CRM Buddy that helps business owners leave notes on Customer profiles.

In Part 1, we will build a Stripe app and create views for the following screens:

  • On the Dashboard: View all recently added notes for all customer
  • On the Customer screen: View all the previously added notes
  • On the Customer screen: Add a new note for that customer

The Backend data will be mocked up. In Part 2, we will replace the mock data with a real Node.js API service.

Prerequisites

Step 0: Prepare the App mockup in Figma

Planning ahead makes development so much more fun. I always try to put myself in the end user's shoes and imagine how they will use the app before starting to build. Creating a mockup is a great way of doing just that.

I've gone ahead and mocked up how our CRM app will look like and behave in Figma. Feel free to duplicate the file here.

Step 1: Create a new Stripe App

We will start off by building just the front-end Stripe app with mocked-up data. We can pretend that the data is coming from a backend API and use it to build the various views. In Part 2, we will build a Node.js backend that will actually serve/save the data.

Create a new folder, call it stripe-crm-app . We can create a boilerplate Stripe App project by opening up a terminal and running:

cd stripe-crm-app
stripe login
stripe apps create stripe-app

You can name the app something like com.<your-org-name>. crm-buddy. I named mine - com.saasbase.crmbuddy

Let's run it with:

stripe apps start

Great! You can see a sample app running. Try going to the Customers screen and see the App UI change.

Step 2: Mock sample data

Before we build out the UI, let's create some mock APIs that will return sample data that we can then use in the app.

addNoteAPI = To add a note to a customer
getAllNotesAPI = Get all notes for all customers
getNotesForCustomerAPI = Get all notes for a specific customer

Let's start by laying out the types. Create a new file called src/types/index.ts

export interface Note {
  id: number;
  agentId: string;
  customerId: string;
  message: string;
  createdAt: Date;
}

export interface APIResponse {
  data: any;
  error: boolean;
}

Now we can start on our fake data. Create a new file called src/api/index.ts

import { APIResponse, Note } from "../types";

const notes: Note[] = [{
  id: 1,
  agentId: "acc_",
  customerId: "cus_Lkx8AOzZ3js2N1",
  message: "Needs SSO auth integration",
  createdAt: new Date()
}, {
  id: 2,
  agentId: "acc_",
  customerId: "cus_LksZWRqAAxal22", // replace this with a customer Id in your dashboard
  message: "Call scheduled for Aug 5th",
  createdAt: new Date()
}]

const generateRandomId = (): number => {
  return Math.floor(Math.random() * 100);
}

export async function addNoteAPI({ customerId, message, agentId }: { customerId: string, message: string, agentId: string }): Promise<APIResponse> {
  const newNote: Note = { id: generateRandomId(), agentId, customerId, message, createdAt: new Date() }
  notes.push(newNote)

  const response: APIResponse = { error: false, data: {} }
  return Promise.resolve(response)
}

export async function getAllNotesAPI(): Promise<APIResponse> {
  const response: APIResponse = { error: false, data: { notes } }
  return Promise.resolve(response);
}

export async function getNotesForCustomerAPI({ customerId }: { customerId: string }): Promise<APIResponse> {
  const notesForCustomer = notes.filter((record: Note) => record.customerId === customerId)
  const response: APIResponse = { error: false, data: { notes: notesForCustomer } }

  return Promise.resolve(response);
}

Since we want to show notes for an actual customer on our account. Create a new customer here and replace the customerId with the generated ID.

With the mock APIs intact, we are ready to consume this data on our app. We will build the Home Overview view next.

Step 3: Add a Home Screen view

Now that we can get some sample data, let's create the Home Screen. If you remember from the Figma mockup, the home screen shows all notes left for all customers.

Clean out all views by deleting App.tsx and App.test.tsx. Go to stripe.json and remove all the views. This is how your stripe.json will look like:

{
  "id": "com.saasbase.stripe-app-fe",
  "version": "0.0.1",
  "name": "CRM Buddy",
  "icon": "",
  "permissions": [],
  "app_backend": {
    "webhooks": null
  },
  "ui_extension": {
    "views": [
    ],
    "actions": [],
    "content_security_policy": {
      "connect-src": null,
      "image-src": null,
      "purpose": ""
    }
  },
  "post_install_action": null
}

Don't worry, we will create new ones in the following step.

Stripe CLI has an easy way to add a view. In your terminal, run: stripe apps add view. It will show a list of available viewport options. Choose stripe.dashboard.home.overview . This component will render when the user goes to the dashboard.stripe.com/dashboard URL.

Open up the newly created HomeOverview.tsx

A ContextView the container on the right you see that houses our App inside it. Let's customize it by setting a title and removing the link like so in HomeOverview.tsx :

import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { ContextView } from "@stripe/ui-extension-sdk/ui";

const HomeOverviewView = ({
  userContext,
  environment,
}: ExtensionContextValue) => {
  return <ContextView title="Overview"></ContextView>;
};

export default HomeOverviewView;

As per Stripe's guidelines, the design language of the App should be very similar to Stripe's itself. This is why we should pre-built components provided by the Stripe UI Kit to build these views. Here's a list of more components.

From our mockup, our home page should display all notes from all customers as a list. First, let's get sample data from the mockup API with useEffect.

import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { ContextView } from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";

const HomeOverviewView = ({
  userContext,
  environment,
}: ExtensionContextValue) => {
  const [notes, setNotes] = useState<Note[] | null>(null);

  const getAllNotes = () => {
    getAllNotesAPI().then((res: APIResponse) => {
      if (!res.data.error) {
        setNotes(res.data.notes);
      }
    });
  };

  useEffect(() => {
    getAllNotes();
  }, []);

  return <ContextView title="Overview">{JSON.stringify(notes)}</ContextView>;
};

export default HomeOverviewView;

Sweet, our mocked notes are coming through. We can now use the Box and the List component to render them.

Let's create a separate component in components/Notes/index.tsx

import { Box, Inline, Link, List, ListItem } from "@stripe/ui-extension-sdk/ui";
import moment from "moment";
import { Note } from "../../types";

interface NotesProps {
  notes: Note[] | null;
}

const Notes = ({ notes }: NotesProps) => {
  if (!notes || notes.length === 0) {
    return (
      <Box css={{ marginTop: "medium" }}>
        <Inline>No notes found</Inline>
      </Box>
    );
  }

  return (
    <Box css={{ marginTop: "medium" }}>
      {notes.map((note: Note, i: number) => {
        return (
          <List key={`messages_${i}`} aria-label="List of recent messages">
            <ListItem
              title={<Box>Note #{note.id}</Box>}
              secondaryTitle={
                <Box css={{ stack: "y" }}>
                  <Inline>{moment().calendar()}</Inline>
                  <Inline>{note.message}</Inline>
                </Box>
              }
              value={
                <Box css={{ marginRight: "xsmall" }}>
                  <Link
                    href={`https://dashboard.stripe.com/test/customers/${note.customerId}`}
                  >
                    View →
                  </Link>
                </Box>
              }
            />
          </List>
        );
      })}
    </Box>
  );
};

export default Notes;

Now we can import it in the HomeOverviewView.tsx :

import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { Box, ContextView, Inline } from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getAllNotesAPI } from "../api";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";

const HomeOverviewView = ({
  userContext,
  environment,
}: ExtensionContextValue) => {
  const agentName = userContext?.account.name as string;

  const [notes, setNotes] = useState<Note[] | null>(null);

  const getAllNotes = () => {
    getAllNotesAPI().then((res: APIResponse) => {
      if (!res.data.error) {
        setNotes(res.data.notes);
      }
    });
  };

  useEffect(() => {
    getAllNotes();
  }, []);

  return (
    <>
      <ContextView title="Overview">
        <Box css={{ stack: "y" }}>
          <Inline
            css={{
              color: "primary",
              fontWeight: "semibold",
            }}
          >
            View All Notes
          </Inline>

          <Notes notes={notes} />
        </Box>
      </ContextView>
    </>
  );
};

export default HomeOverviewView;

Excellent! You got it done. Here's what the Home Screen looks like.

Try passing an empty notes array to make sure it looks good.

//...
<Notes notes={[]} />
//...

Step 4: View notes for the selected customer

Now that we have a Home screen where all the notes we show all the notes left for our customers, we should create an experience for when an individual customer profile is selected on the Dashboard. We will show only the notes left on that customer.

  1. Create a new one by running: stripe apps add view. Choose the stripe.dashboard.customer.detail.

Create a new file: CustomerDetailView.tsx

Stripe makes it easy to grab the current customer profile that has been pulled up, and get their ID with environment?.objectContext?.id .

Let's start by getting notes by customer ID. If you remember, our getNotesForCustomerAPI returns notes for a specific customer if we pass in the ID. We can reuse a lot of the logic we have already defined previously for the Home View.

import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { Box, ContextView, Inline } from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getNotesForCustomerAPI } from "../api";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
import BrandIcon from "./brand_icon.svg";

const CustomerDetailView = ({
  userContext,
  environment,
}: ExtensionContextValue) => {
  const customerId = environment?.objectContext?.id;

  const agentId = userContext?.account.id as string;
  const agentName = userContext?.account.name as string;

  const [notes, setNotes] = useState<Note[] | null>(null);

  const getNotes = () => {
    if (!customerId) {
      return;
    }

    getNotesForCustomerAPI({ customerId }).then((res: APIResponse) => {
      if (!res.data.error) {
        setNotes(res.data.notes);
      }
    });
  };

  useEffect(() => {
    getNotes();
  }, [customerId]);

  console.log(notes);

  return (
    <ContextView
      title="All Notes"
      description={customerId}
      brandColor="#F6F8FA"
      brandIcon={BrandIcon}
    >
      <Box css={{ stack: "y" }}>
        <Box css={{}}>
          <Inline
            css={{
              font: "heading",
              color: "primary",
              fontWeight: "semibold",
              paddingY: "medium",
            }}
          >
            View All Notes
          </Inline>

          <Notes notes={notes} />
        </Box>
      </Box>
    </ContextView>
  );
};

export default CustomerDetailView;

Step 5: Let the user create a new note

Now that we can see notes for the selected Customer, we should be able to create a new note for them as well. Stripe has a great component called FocusView specifically for this use case. A FocusView opens up a wizard-like view for a one-off action from the user. In our case, that would be adding a new note.

Create a new component in src/components/AddNoteView/index.tsx:

import { Button, FocusView, TextArea } from "@stripe/ui-extension-sdk/ui";
import { FunctionComponent, useState } from "react";
import { addNoteAPI } from "../../api";

interface AddNoteViewProps {
  isOpen: boolean;
  customerId: string;
  agentId: string;
  onSuccessAction: () => void;
  onCancelAction: () => void;
}

const AddNoteView: FunctionComponent<AddNoteViewProps> = ({
  isOpen,
  customerId,
  agentId,
  onSuccessAction,
  onCancelAction,
}: AddNoteViewProps) => {
  const [message, setMessage] = useState<string>("");

  return (
    <>
      <FocusView
        title="Add a new note"
        shown={isOpen}
        onClose={() => {
          onCancelAction();
        }}
        primaryAction={
          <Button
            type="primary"
            onPress={async () => {
              await addNoteAPI({ customerId, agentId, message });
              setMessage("");
              onSuccessAction();
            }}
          >
            Save note
          </Button>
        }
        secondaryAction={
          <Button
            onPress={() => {
              onCancelAction();
            }}
          >
            Cancel
          </Button>
        }
      >
        <TextArea
          label="Message"
          placeholder="Looking for more enterprise features like SEO..."
          value={message}
          autoFocus
          onChange={(e) => {
            setMessage(e.target.value);
          }}
        />
      </FocusView>
    </>
  );
};

export default AddNoteView;

The ContextView allows us to add an action button in it. Perfect place for an "Add Note" button. Let's add that to our CustomerDetailView.tsx :

import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import {
  Banner,
  Box,
  Button,
  ContextView,
  Icon,
  Inline,
} from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getNotesForCustomerAPI } from "../api";
import AddNoteView from "../components/AddNoteView";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
import BrandIcon from "./brand_icon.svg";

const CustomerDetailView = ({
  userContext,
  environment,
}: ExtensionContextValue) => {
  const customerId = environment?.objectContext?.id;

  const agentId = userContext?.account.id || ""; //todo
  const agentName = userContext?.account.name || ""; //todo

  const [notes, setNotes] = useState<Note[] | null>(null);
  const [showAddNoteView, setShowAddNoteView] = useState<boolean>(false);
  const [showAddNoteSuccessMessage, setShowAddNoteSuccessMessage] =
    useState<boolean>(false);

  const getNotes = () => {
    if (!customerId) {
      return;
    }

    getNotesForCustomerAPI({ customerId }).then((res: APIResponse) => {
      if (!res.data.error) {
        setNotes(res.data.notes);
      }
    });
  };

  useEffect(() => {
    getNotes();
  }, [customerId]);

  console.log(notes);

  return (
    <ContextView
      title="All Notes"
      description={customerId}
      brandColor="#F6F8FA"
      brandIcon={BrandIcon}
      actions={
        <Button
          type="primary"
          css={{ width: "fill", alignX: "center" }}
          onPress={() => {
            setShowAddNoteView(true);
          }}
        >
          <Box css={{ stack: "x", gap: "small", alignY: "center" }}>
            <Icon name="addCircle" size="xsmall" />
            <Inline>Add note</Inline>
          </Box>
        </Button>
      }
    >
      <AddNoteView
        isOpen={showAddNoteView}
        customerId={customerId as string}
        agentId={agentId}
        onSuccessAction={() => {
          setShowAddNoteView(false);
          setShowAddNoteSuccessMessage(true);
          getNotes();
        }}
        onCancelAction={() => {
          setShowAddNoteView(false);
        }}
      />

      <Box css={{ stack: "y" }}>
        <Box css={{}}>
          <Inline
            css={{
              font: "heading",
              color: "primary",
              fontWeight: "semibold",
              paddingY: "medium",
            }}
          >
            View All Notes
          </Inline>

          <Notes notes={notes} />
        </Box>
      </Box>
    </ContextView>
  );
};

export default CustomerDetailView;

We should also show the user a notification when a note is successfully created. The Banner component is perfect for that.

import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import {
  Banner,
  Box,
  Button,
  ContextView,
  Icon,
  Inline,
} from "@stripe/ui-extension-sdk/ui";
import { useEffect, useState } from "react";
import { getNotesForCustomerAPI } from "../api";
import AddNoteView from "../components/AddNoteView";
import Notes from "../components/Notes";
import { APIResponse, Note } from "../types";
import BrandIcon from "./brand_icon.svg";

const CustomerDetailView = ({
  userContext,
  environment,
}: ExtensionContextValue) => {
  const customerId = environment?.objectContext?.id;

  const agentId = userContext?.account.id || ""; //todo
  const agentName = userContext?.account.name || ""; //todo

  const [notes, setNotes] = useState<Note[] | null>(null);
  const [showAddNoteView, setShowAddNoteView] = useState<boolean>(false);
  const [showAddNoteSuccessMessage, setShowAddNoteSuccessMessage] =
    useState<boolean>(false);

  const getNotes = () => {
    if (!customerId) {
      return;
    }

    getNotesForCustomerAPI({ customerId }).then((res: APIResponse) => {
      if (!res.data.error) {
        setNotes(res.data.notes);
      }
    });
  };

  useEffect(() => {
    getNotes();
  }, [customerId]);

  console.log(notes);

  return (
    <ContextView
      title="All Notes"
      description={customerId}
      brandColor="#F6F8FA"
      brandIcon={BrandIcon}
      actions={
        <Button
          type="primary"
          css={{ width: "fill", alignX: "center" }}
          onPress={() => {
            setShowAddNoteView(true);
          }}
        >
          <Box css={{ stack: "x", gap: "small", alignY: "center" }}>
            <Icon name="addCircle" size="xsmall" />
            <Inline>Add note</Inline>
          </Box>
        </Button>
      }
    >
      {showAddNoteSuccessMessage && (
        <Box css={{ marginBottom: "small" }}>
          <Banner
            type="default"
            onDismiss={() => setShowAddNoteSuccessMessage(false)}
            title="Added new note"
          />
        </Box>
      )}

      <AddNoteView
        isOpen={showAddNoteView}
        customerId={customerId as string}
        agentId={agentId}
        onSuccessAction={() => {
          setShowAddNoteView(false);
          setShowAddNoteSuccessMessage(true);
          getNotes();
        }}
        onCancelAction={() => {
          setShowAddNoteView(false);
        }}
      />

      <Box css={{ stack: "y" }}>
        <Box css={{}}>
          <Inline
            css={{
              font: "heading",
              color: "primary",
              fontWeight: "semibold",
              paddingY: "medium",
            }}
          >
            View All Notes
          </Inline>

          <Notes notes={notes} />
        </Box>
      </Box>
    </ContextView>
  );
};

export default CustomerDetailView;

Here's what adding a new note looks like:

0:00
/

There you have it - You have just built your very first Stripe App! Currently, it uses mocked-up data which is not ideal. In Part 2, we will use Node.js to create an API server that saves and persists data in PostgresDB.