Feb 8, 2023

How we replaced 3rd party feedback forms with Notion

Anton LytvynovskyiCTO and Co-Founder

Notion is one of the most popular productivity tools there, which offers a great API and integration capabilities to make our lives easier.

This article will tell you how one could use Notion API to replace almost any 3rd party forms on your website. The frontend framework we are using here is Nuxt 3, but concepts from this tutorial are applicable to any other javascript framework.


There are 4 form types we are currently using at

  • Contact us - for any generic questions

  • Subscribe - for our newsletter

  • General feedback - feedback/bug report/feature requests/etc

  • Request EMR - specific part of our business, essentially requesting additional market coverage for our product

Before switching to Notion API, we were using 2 third-party tools for forms management:

  • Netlify’s Forms, which was the first platform we hosted our front-end on (and forms were included in their package)

  • EmbedSocial, which was our attempt to find more customizable forms with direct notifications

It was convenient in the beginning, but as we grew, we realized there were some drawbacks to that approach.

Why would you switch to custom Notion API implementation?

  • Drop dependencies - first and foremost, as we wanted a different hosting approach, Netlify and their Forms were no longer an option.

  • Security/Compliance requirements - having EmbedSocial forms requires you to embed them on your page as an iframe. It is OK if you know what you are doing, but can put you in troubles with some of your users (if they have iframes blocked in their browsers). Depending on the vendor and your compliance requirements, you might also face issues with cookies or have other privacy concerns.

Besides, Google has plans on phasing out the third party cookies from Chrome, so removing dependency that uses them is also a form of future-proofing.

  • Advanced customizations - any 3rd-party form builder will have certain limitations on the way you can customize the forms. Some of them are more advanced, but nothing beats your own code 🙂

  • Cost optimization - at the moment of writing, Notion offers Public API for free as a part of all their plans, so you can save costs on the other tools.

  • Direct integrations - Notion offers rich integration options with other 3rd parties. The one we used was direct notifications in a dedicated Slack channel for when a new row was added to our forms database.



Before we can dive into integration with Notion API, we actually need to register our application (i.e. integration) at our Notion workspace, get API key and database ID.

The whole process is perfectly described by Notion team themselves here:, and you would be looking into steps 1-3 here.

Note. Don’t forget to actually create a database on your desired notion page, if you haven’t done that yet 😉

For the purposes of this tutorial, we want our users to submit their email and a message, so we have built a table with the corresponding properties.

empty notion table

There are two extra properties there, as you can see:

  • Type is selected programmatically, based on which type of the form the customer is submitting. We will cover it down below in this tutorial.

  • Created Time is an inbuilt field type offered by Notion, which will populate the moment we add a new row to the table.

Once the database is created, application is registered, API key and database ID received, we are ready to start integration process.

Configure Notion API in Nuxt

Assuming you have a Nuxt project which you’d like to integrate with Notion up and running, we first need to add Notion client to our dependencies. (And if you don’t have your project yet - follow to get started)

Notion has their own Javascript client, available here:, so we’ll install it with

npm install @notionhq/client
// with yarn use: yarn add @notionhq/client

We will need Notion API key and database ID later in our development, so it’s a good moment to configure them now. A good approach would be to use environment variables for those, as you generally should not store your secrets visible on the front-end.

Create new file called .env in your project root (or update the existing one) with the following values:


Let’s also define our expected form types. To do that, we will add form_types.ts file in our project root to define available form types.

We are utilizing TypeScript here, as you can see from the FormType enum, which is highly recommended for your type checks. For this example we only expect two form types submitted - Contact Us and Subscribe.

export enum FormType {
    CONTACT_US = "contact-us",
    SUBSCRIBE = "subscribe"

Create server-side API to work with Notion

Let’s now write some code connecting to Notion API.

Great part about Nuxt and other SSR frameworks is that they actually allow you to write server-side code, which will not be exposed to your client. That is a perfect place to put your business logic dealing with, among others, secrets.

Nuxt has their own approach with directory structure for that,, so we will first create:

  • server folder in our project

  • put api folder in it

  • put notion folder in it - this one is optional, but great for project structuring, as most likely you will have other APIs here as well

  • put file in it

Our will have the following contents.

import { Client } from "@notionhq/client"
import { FormType } from "@/types"

const notion = new Client({ auth: process.env.NOTION_KEY })

const databaseId = process.env.NOTION_DATABASE_ID

export interface FormPayload {
    email: string
    type: FormType
    message: string

export async function saveForm(form: FormPayload) {
    return await notion.pages.create({
        parent: { database_id: databaseId },
        properties: {
            title: {
                title: [
                        text: {
            Type: {
                select: {
                    name: form.type,
            Message: {
                rich_text: [
                        text: {
                            content: form.message,

export default defineEventHandler(async (event): Promise<any> => {
    const body = await readBody(event)
    return await saveForm(body)

Few comments on that:

  • Our entry point here is actually defineEventHandler - that is a standard approach from Nuxt, described in details here:

  • saveForm is an actual method calling Notion API. As you can see from the poperties there, their names, e.g. Type, Message match those we have configured before in our Notion database (see “Prerequisite” part above). Title is a default name for your first column in Notion database and we don’t have to send Created Time here, as that field will be automatically populated by Notion on an insert. You can read more about different database properties (columns) here -

  • Note we are calling notion.pages.create on every saveForm, as every entry to a table/database in Notion is technically a new page.

Learn more of the Notion API -, it is really great! For example, you could also read from your DB and feed that data in a dashboard on your admin page.

Create contact form component

It is now time to create our Contact Form component.

As for our UI, we are using Vuetify - that is where different components and classes in the code example below come from. Feel free to use any other framework of your liking - Nuxt has an official module for Tailwind 🙂

As suggested by the Nuxt directory structure, we should put it in components folder in our project

Let’s call our component ContactForm and created ContactForm.vue file in our components folder with the following content:

    <v-dialog v-model="dialog" max-width="500" :persistent="success === null" @update:model-value="resetForm()">
        <template v-slot:activator="{ props }">
            <v-btn color="primary" v-bind="props">
                Contact us
        <v-card class="pa-5 rounded">
            <v-card-text class="text-center" v-if="success">
                <v-icon class="mb-5" color="success" size="64">mdi-checkbox-marked-circle-outline</v-icon>
                <span class="d-block font-weight-bold mb-2 text-h5">
                    Message received
                <span class="d-block">
                    Thank you! We'll go through your message and get back to you soon!
            <v-card-text class="text-center" v-else-if="success === false">
                <v-icon class="mb-5" color="error" size="64">mdi-close-circle-outline</v-icon>
                <span class="d-block font-weight-bold mb-2 text-h5">
                    Message was not received
                <span class="d-block">
                    We're sorry, but something went wrong. Please try again later or contact us at
                    <a class="font-weight-medium" href=""></a>
            <template v-else>
                <v-card-title class="font-weight-bold pa-0 pb-5 text-h5">
                    Contact Us
                <v-card-text class="pa-0 pb-2">
                    <v-form ref="form" v-model="valid">
                        <span class="d-block mb-5 text-body-1">
                            Hey! We'd love to get in touch! Please drop us your email and message below and we'll get back to you very soon.
                        <v-text-field v-model="email.value" label="Email" required :rules="email.rules" type="email" variant="outlined" />
                        <v-textarea v-model="message.value" label="Message" required :rules="message.rules" variant="outlined" />
                <v-card-actions class="pa-0">
                    <v-btn color="primary" :disabled="!valid || loading" :loading="loading" variant="flat" @click="contactUs()">
                        Send message
                    <v-btn color="primary" variant="outlined" @click="dialog = false">
<script setup>
import { FormType } from '@/types'

const dialog = ref(false)
const form = ref(null)
const valid = ref(true)
const loading = ref(false)
const success = ref(null)

const email = ref({
    value: null,
    rules: [
        (value) => !!value || 'E-mail is required',
        (value) => /.+@.+\\..+/.test(value) || 'Invalid e-mail'
const message = ref({
    value: null,
    rules: [
        (value) => !!value || 'Please provide your message',

const contactUs = async () => {
    const payload = {
        email: email.value.value,
        type: FormType.CONTACT_US,
        message: message.value.value

    try {
        loading.value = true
        await $fetch(`/api/notion/form`, {
            method: 'POST',
            body: payload
        success.value = true
    } catch (error) {
        success.value = false
    } finally {
        loading.value = false

const resetForm = () => {
    email.value.value = null
    message.value.value = null
    success.value = null

Now we need to open our form from somewhere.

For the purposes of this tutorial, let’s keep everything simple and add a button to our main app.vue page in the root of our project. Because of Nuxt 3 auto-imports, we don’t have to explicitly import our ContactForm here.

    <ContactForm />

If everything done right, we will see the following form once we click a “Contact Us” button:

contact form with email and message fields

With this approach you are free to customize contact forms as much as you need. We are using a simple form and success/failure placeholders as an example here.

Enable integration with Slack

Once we submit a form, a new entry will be added to our Notion database.

notion table with submitted contact form message

Our final step will be to add Slack integration, so we are always notified about new incoming forms.

This can be done directly in Notion, through their inbuilt integration:

notion menu adding connection to slack

Just select “Slack” from the list of connections and after a few steps selecting a channel, you will start receiving alerts every time your database is modified (meaning every time a new form is received).

What are the drawbacks?

As usual, before switching to a new solution, you should “do your own due diligence”. Here are some of the risks you might face with this approach:

  • Requires development skills - with custom integrations (and not a plug and play code snippet solution/form builder/etc), you actually need people on your team being able to write that code for integration.

  • Single point of failure - consolidating everything in Notion would result in all of the forms on your site being down when Notion is down. Even though we may say this risk is quite low, you should think about how critical any downtime would be for your case.

Hint. For the second point there, you could try one of the following approaches to mitigate that risk of Notion being down:

  • Display some sort of “Oops, something went wrong, contact us at email” message. This works with lower loads and less critical messages, where you can “ask” your users to contact you via email as a fallback.

  • When the workload and criticality of delivery grows, you might implement some sort of a queue with retry mechanisms there. Although, at this stage you would probably be looking into a dedicated database instead of Notion, but that’s a different story 😉

Final thoughts

Overall, we at Leyr have had a good experience with this switch to Notion. We’ve exchanged 2 vendors for one, removed unneeded iframes from our site and cut extra costs. Notion proved to offer a sufficient API for now, and their inbuilt integration with Slack lets us stay on top of any new forms submitted on our page.

You can find example implementation of such integration open-sourced here:

Other publications

Subscribe to our newsletter

Get a summary of what we're building, how, and why. Only relevant information once a month and no spam. You can unsubscribe any time.

Stay up to date

We'd really love to stay connected with you. Follow us on LinkedIn for the latest news and updates on what's happening at Leyr!