Build a headless task manager with BCMS, Nuxt & Twilio alerts

headless task manager
By Hammad Ahmed
Read time 12 min
Posted on 3 Jul 2025

In this tutorial, you will build a headless task manager web app that can send SMS alerts. You will build the UI in Nuxt, which is Vue’s official meta framework. You will use BCMS, a headless CMS, to persist the data from your task manager. You will use <a href="https://zapier.com/" target="_blank">Zapier</a> and <a href="https://www.twilio.com/en-us" target="_blank">Twilio</a> to send SMS alerts based on the data from your task manager.

You don’t need to have used BCMS, Zapier, and Twilio before.

You will start by using the BCMS CLI tool to scaffold BCMS’s Nuxt "simple blog" starter template for you. You will then move on to data modelling in the BCMS Cloud dashboard and create the required templates there. Review the 'requirements' and 'technologies' sections below, and let's get started!

task project

Requirements for building a headless task manager

You need to have the following installed:

  • Node.js interpreter (any version greater than 18.3, preferably version 22, which this tutorial uses)

  • npm package manager

  • Typescript

Technologies for building a headless task manager

You will use the following technologies in this tutorial:

  • HTML, CSS, JS (basic knowledge required)

  • Nuxt (no prior knowledge required)

  • Typescript (basic familiarity)

  • Tailwind (no prior knowledge required)

  • BCMS (no prior knowledge required)

Scaffolding

Use the command below to get started. You can replace "headless-task-manager" with whatever name you want to give to the project. Note that this name will start appearing in your BCMS Cloud dashboard as a project, meaning that you are creating something here and on the BCMS Cloud platform as well.

npx @thebcms/cli --create nuxt --starter simple-blog --project-nam headless-task-manager
headless-task-manager

The terminal will give you a URL to paste into a browser window, or it may automatically open the link for you. There, you can log in to your BCMS account, for which you will need to create an account. 

  • Note: This tutorial uses the Cloud version of BCMS. You may also use the self-hosted version, instead.

Once logged in, the browser will say so and ask you to go back to the terminal and follow up there.

bcms login
  • Note: You cannot create an empty Nuxt project using the BCMS CLI tool at the time of writing this tutorial.

From here on, the CLI utility will work on its own and get the command done. From the outputs on the terminal, you can see that it does four jobs.

  • Clone the relevant repo that we want

  • Create the project files

  • Create a BCMS project on BCMS Cloud

    • It will output, penultimately, the URL on which you can access the created project on BCMS Cloud.

Set up the API keys for the project

This will create an API key in the Cloud and insert it into your .env file along with other keys. The CLI utility has integrated the Nuxt project with the Cloud API for you. Altogether, you will see four keys in your .env file.

The last parts of the output are terminal commands for you to navigate to your project in the terminal and run two npm commands. 

5. headless task manager command.png

Let's run all three commands.

cd headless-task-manager && npm install && npm run dev

Navigate to the created project’s folder and run npm install there to install the required dependencies.

7. install project.png

Then run the last command npm run dev and open up "localhost:3000", as it says in the terminal output, in a browser. 

8. ncjkw.png

It will first show the "Starting Nuxt" screen, after which the demo app will appear.

nuxt
home page

You can leave your Nuxt project here for the moment and jump to data modelling.

  • Make sure the npm package @thebcms/client is at least version 1.5.1: "<i>@thebcms/client&quot;: &quot;1.5.1</i>"

Data modelling

BCMS provides a few options to structure and store data. These are templates, widgets, groups, media, and entries.

Think of a template as a database table schema, i. e. the columns. And think of entries as individual database records that can be created only according to a specific template. Media are files, e. g. photos and videos that you can upload to BCMS.

Let’s log on to the BCMS website. Under the BCMS logo, if not already selected, select the project name from the dropdown that you entered in the terminal command. You will notice some templates, entries, and media files in this project.

These are the entries that are being displayed on localhost:3000.

Click on "Entries” from the toggleable pane on the left and delete all four blog entries. Also, click on Media Manager in the toggleable pane on the left and delete all four image files that are present there. Likewise, delete everything in “Templates” as well. You will create two templates: task and project.

For the task template/table, you need the following fields:

  • title

  • description

  • timestamps (created_at and updated_at)

  • priority

  • complete

  • due_date

The project template only needs a “title”. Let's first create the project template.

Click on "Templates" from the toggleable pane on the left and click on the "Create new template +" button in the upper right-hand corner. Enter “project” in the name field and click “Create”. BCMS adds five properties by default to a template.

Two of them are visible: title and slug. Three of them are not visible: _id, createdAt, and updatedAt.

11 data modelling.png

There is a reason that you chose to create the project template first. It is so that you can then add an "Entry Pointer" property in the task template that points to project.

This will create a one-to-many relationship between project and task.

Let's create the Task template:

  • Click on "Templates" from the toggleable pane on the left and click on the "Create new template +" button in the upper right-hand corner.

  • Enter “task” in the name field and click “Create”. Drag a String property from the right and name it “description”.

  • Add a Boolean property and name it “complete”.

  • Add a Date property and name it “due_date”.

  • Add an enumeration property by the name of priority and add four enumerations to it: ‘P1’, ‘P2’, ‘P3’, and ‘P4’.

  • Add an Entry Pointer property by the name of project and select "project" in the "Select template" dropdown.

Set all of these properties as required.

task manager entries

Let's add two task entries using the UI here. You will need a project entry beforehand so that you can assign those two tasks to it. In the toggleable pane on the left, click on “project” under Entries. Then click on "Create new entry" in the top right-hand corner. Set the title to "Inbox", and click "Create".

14. inbox.png

Same way, click on “task” under Entries in the toggleable pane on the left. Click on "Create new entry" in the top right-hand corner. Create a random task, set the project to Inbox, and click "Create". 

15. task.png

Create another random task entry, set the project to Inbox, and click "Create". You are done with the data modelling part.

16 second task.png

Go to the API tab in the Settings option and give the API key (already there) permission to perform all four operations on both templates.

Now that you have changed the templates and entries in BCMS Cloud, you can run `bcms --pull types --lng ts` in the root of the project, so that the BCMS CLI will update the project to reflect the changes.

What it does is that: before, you had "BCMSBlogEntry" and other types and interfaces available to be `import`ed in your code, it will replace them with new interfaces and types. `npm run dev` runs two commands: `bcms --pull types --lng ts` followed by `nuxt dev`.

CRUD with BCMS And Nuxt

Let's first delete some of the files that came with the simple blog starter kit. Delete the contents of composables, layouts, assests/media, public, components, server, pages, and utils folders.

Let's install Moment to handle dates.

Note: From the Moment docs: We recognize that many existing projects may continue to use Moment, but we would like to discourage Moment from being used in new projects going forward…<strong>We now generally consider Moment to be a legacy project in maintenance mode. It is not dead, but it is indeed done.</strong>

npm install --save-dev @nuxtjs/moment

Edit the nuxt.config.js file and declare the module there:

export default {
  buildModules: [
    '@nuxtjs/moment'
  ]
}

Create an app.config.ts file in the root of the directory with the following code:

export default defineAppConfig({
  title: 'Task Manager'
});

You have defined the browser title of your app here so that you can reuse it in multiple places in your app. Let's now create the Nitro (the server engine that comes with Nuxt) API endpoints that you will call from your Vue components.

Create a folder called api inside the ./server folder and then two folders inside of that: ./server/api/project and ./server/api/task. You need to define the CRUD operations for each resource. Create three files inside each folder for now:

...
- pages
- server
  - api
    - task
      /* all.ts
      /* create.ts
      /* update.ts
    - project
      /* all.ts
      /* create.ts
      /* update.ts
.env
.gitignore
...

To perform these CRUD operations, you need to import the BCMS client module (node_modules/@thebcms/client/handlers/entry.mjs) in all six files:

import { bcms } from '~/bcms-client';

bcms.entry lets you call fourteen methods to perform operations that have to do with templates and entries. You will use getAll, create, and update out of these for the API endpoints.

Browse to the BCMS client module in your node_modules folder and take a look at these methods and familiarize yourself with their signatures. For getAll, you just need to pass the template name as the only argument.  For create, you need to pass the template name and the entry data. The entry data should be of the type EntryParsedCreateData:

// node_modules/@thebcms/client/handlers/entry.d.ts
export interface EntryParsedCreateData {
    statuses: EntryStatusValue[];
    meta: Array<{
        lng: string;
        data: EntryParsedDataProps;
    }>;
    content: Array<{
        lng: string;
        nodes: EntryContentNode[];
    }>;
}

For update, you need to pass the template name, the _id of the entry being updated, and the updated entry data. The updated entry data should be of the type EntryParsedCreateData:

export interface EntryParsedUpdateData {
    lng: string;
    status?: string;
    meta: EntryParsedDataProps;
    content: EntryContentNode[];
}

For create and update, you will receive the data from the Vue components. Use Nuxt's readBody() function to store this data in a variable like this:

import { bcms } from '~/bcms-client';

export default defineEventHandler(async (event) => {
    const body = await readBody(event);
});

For the templates, BCMS provides TypeScript types that you can typehint in your code.

Check out the project.d.ts and task.d.ts files inside ./bcms/types/ts/entry.

These two files give you six types in total: ProjectEntry, ProjectEntryMeta, ProjectEntryMetaItem, TaskEntry, TaskEntryMeta, TaskEntryMetaItem. You will use them throughout the API endpoint files.

You will create two types of our own, too: TasksResponse and ProjectsResponse. Let's look at these in the full code for task/all.ts and project/all.ts:

// ./server/api/task/all.ts
import { bcms } from '~/bcms-client';
import type { TaskEntry, TaskEntryMeta, TaskEntryMetaItem } from '~/bcms/types/ts';

export type TasksResponse = TaskEntryMetaItem & {
    id: number
};

export default defineEventHandler(async () => {
    const tasks = (await bcms.entry.getAll('task')) as TaskEntry[];
    const res: TasksResponse = tasks.map((task: TaskEntryMeta) => {
        let id = task._id
        task = task.meta.en as TaskEntryMetaItem;
        task.id = id;
        return task;
    });

    return res;
});
// ./server/api/project/all.ts
import { bcms } from '~/bcms-client';
import type { ProjectEntry, ProjectEntryMeta, ProjectEntryMetaItem } from '~/bcms/types/ts';

export type ProjectsResponse = ProjectEntryMeta & {
    id: number
};

export default defineEventHandler(async () => {
    const projects = (await bcms.entry.getAll('project')) as ProjectEntry[];
    const res: ProjectsResponse = projects.map((project: ProjectEntryMetaItem) => {
        let id = project._id
        project = project.meta.en as ProjectEntryMeta;
        project.id = id;
        return project
    });

    return res;
});

The code for both these files is mostly the same. You are going to import these TasksResponse and ProjectsResponse types later in your Vue component so that you can typehint the responses from useFetch(). In both of these files, we map the response from bcms.entry.getAll() and keep only the <i>id </i>and the <i>meta.en</i> object. The <i>meta.en</i> contains the entry data like "title", "duedate", etc.

Check out the TaskEntryMetaItemPriority.d.ts file inside ./bcms/types/ts/enum; it simply checks if the priority property is one of the four values: 'P1', 'P2', 'P3', or 'P4'. Take a look at the full code for the create.ts files:

// ./server/api/task/create.ts
import { EntryParsedCreateData } from '@thebcms/client';
import { bcms } from '~/bcms-client';
import { TaskEntryMetaItemPriority } from '~/bcms/types/ts';

export default defineEventHandler(async (event) => {
    const body = await readBody(event);

    const request: EntryParsedCreateData = {
        meta: [
            {
                lng: "en",
                data: {
                    title: body.title,
                    slug: body.title,
                    description: body.description,
                    due_date: {
                        timestamp: body.due_date.timestamp,
                        timezoneOffset: body.due_date.timezoneOffset
                    },
                    complete: body.complete,
                    priority: body.priority as TaskEntryMetaItemPriority,
                    project: {
                        entryId: body.projectId,
                        templateId: '67c8fd885ab1d0bab17d107f'
                    }
                }
            }
        ],
        statuses: [],
        content: [{
            lng: "en",
            nodes: [],
            plainText: ""
        }]
    };

    await bcms.entry.create('task', request);
});
// ./server/api/project/create.ts
import { EntryParsedCreateData } from '@thebcms/client';
import { bcms } from '~/bcms-client';
import type { ProjectEntryMetaItem } from '~/bcms/types/ts';

export default defineEventHandler(async (event) => {
    const body = await readBody(event);

    const request: EntryParsedCreateData = {
        meta: [
            {
                lng: "en",
                data: {
                    title: body.title,
                    slug: body.title
                } as ProjectEntryMetaItem
            }
        ],
        statuses: [],
        content: [{
            lng: "en",
            nodes: [],
            plainText: ""
        }]
    };

    await bcms.entry.create('project', request);
});

You have followed the EntryParsedCreateData type and created the request objects according to it. The "due_date" property is of the type DATE in the BCMS Cloud, so it needs to be in the format:

{
    timestamp: number,
    timezoneOffset: number
}

The project property is of the type ENTRY_POINTER, so it needs to be in the format:

{
    entryId: string,
    templateId: string
}

Note that there is no option at the moment to pass the template name when specifying the entry pointer. I got the template id from the browser's URL bar when visiting the "project Entries" page in the BCMS Cloud dashboard:

.../na-2d5cf2d3594b/i/headlesstaskmanager/bcms/template/67c8fd885ab1d0bab17d107f/...
                                                        ↑ project's template id

You have to follow the EntryParsedUpdateData type in your code for the update.ts files:

// ./server/api/project/update.ts
import { EntryParsedUpdateData } from '@thebcms/client';
import { bcms } from '~/bcms-client';
import { ProjectEntryMetaItem } from '~/bcms/types/ts';

export default defineEventHandler(async (event) => {
    const body = await readBody(event);

    const request: EntryParsedUpdateData = {
        meta: {
            title: body.title,
            slug: body.title
        } as ProjectEntryMetaItem,
        content: [{
            lng: "en",
            nodes: [],
            plainText: ""
        }],
        lng: "en"
    };

    const res = await bcms.entry.update('project', body.projectId, request);
});
// ./server/api/task/update.ts
import { EntryParsedUpdateData } from '@thebcms/client';
import { bcms } from '~/bcms-client';
import { TaskEntryMetaItemPriority } from '~/bcms/types/ts';

export default defineEventHandler(async (event) => {
    const body = await readBody(event);

    const request: EntryParsedUpdateData = {
        meta: {
            title: body.title,
            slug: body.title,
            description: body.description,
            priority: body.priority as TaskEntryMetaItemPriority,
            due_date: body.due_date,
            complete: body.complete,
            project: {
                entryId: body.projectId,
                templateId: '67c8fd885ab1d0bab17d107f'
            }
        },
        content: [{
            lng: "en",
            nodes: [],
            plainText: ""
        }],
        lng: "en"
    };

    await bcms.entry.update('task', body.taskId, request);
});
  • Do you think you can implement the delete API endpoints yourself?

  • Hint: For the <i>deleteById </i>method, you need to pass the template name and the <i>_id</i> of the entry being deleted.

You are finished with the Nitro API. Let's create an index.vue file in the pages folder with the following contents:

<template>
    <div id="wrapper" class="my-0 mx-auto w-[100%] md:w-[80%] lg:w-[60%]">
        <h1 class="my-5 italic text-5 text-gray-500 font-mono ml-[20px]">{{ useAppConfig().title }}</h1>
        <App></App>
    </div>
</template>

I am using the title property here, which was defined earlier in the app.config.ts file. You have placed the <App> component here, which you will create.

You will create three components in total: <App>, <Task>, and <Project>. Your <App> component will fetch all project entries and all task entries using our API and iteratively display them on the page using the <Project> and <Task> components.

Your <App> component also handles the responsibility for creating new tasks as well as new projects. The <Task> and <Project> components handle updating and deleting tasks and projects, respectively. You fetch all the tasks and projects in <App> like this:

const [tasksReq, projectsReq] = await Promise.all([ 
    useFetch<TasksResponse>('/api/task/all'),
    useFetch<ProjectsResponse>('/api/project/all')
]);
const tasks = tasksReq.data;
const projects = projectsReq.data;

projects.value.forEach(project => project.tasks = tasks.value.filter(task => task.project._id === project.id)); // *

After fetching the total data, you (*) iterate over projects and assign the belonging tasks to each project. We do it for one reason mainly: so that you can count the number of tasks that belong to each project and display that number to the left of the project name in the projects pane. I will show you the full code for the <App> component last.

You pass the tasks and projects data that you fetch to your <Project> and <Task> components in the form of props, and you listen for emitted events from them in your <App> component. Let's first take a look at the full code for <Project>:

<template>
    <li @mouseover="projectHover=true" @mouseleave="projectHover=false" @click="$emit('changeSelectedProject')" @dblclick="showInput" v-show="!projectEditInput" :class="{'cursor-pointer': projectHover}">
        {{title}} ({{tasks.length}})
        <span v-show="projectHover" class="ml-[10px] text-[11px] text-sky-700 border-[1px] border-gray-200 px-1 rounded" @click="deleteProject">&#10060;</span>
    </li>
    <form @submit.prevent="updateProject"  name="project-edit">
        <input ref="projectEdit" placeholder="Project name" class="w-[70%]" v-show="projectEditInput" v-model="title" required>
    </form>
</template>

<script setup lang="ts">
    const props = defineProps(['id', 'title', 'tasks']);
    const emit = defineEmits(['changeSelectedProject', 'deleteProject']);
    let id = ref(props.id);
    let title = ref(props.title);
    let tasks = ref(props.tasks);
    let projectEdit = ref(null)
    let projectEditInput = ref(false);
    let projectHover = ref(false);
    const showInput = async () => {
        projectEditInput.value=!projectEditInput.value;
        await nextTick();
        projectEdit.value.focus();
    }
    const updateProject = async () => {
        projectEditInput.value = !projectEditInput.value;
    await $fetch('/api/project/update', {
        method: 'PUT',
        headers: {},
        body: {
          projectId: id.value, title: title.value
        }
    })
    }
    const deleteProject = async () => {
        await $fetch('/api/project/delete', {
        method: 'DELETE',
        headers: {},
        body: {
          projectId: id.value
        }
    });
    emit('deleteProject');
    };
</script>

You make $fetch requests to your Nitro API to update and delete projects. You emit the changeSelectedProject event when a specific project's name is clicked in the projects pane. And in the deleteProject function, you emit the deleteProject event. You listen for both of these events in the <App> component:

<Project
    v-for="project in projects"
    :key="project.id"
    :id="project.id"
    :title="project.title"
    :tasks="project.tasks"
    @changeSelectedProject="changeCurrentProject(project)"
    @deleteProject="removeProject(project.id)"
/>

This is your removeProject function in the <App> component:

const removeProject = projectId => {
    projects.value.splice(projects.value.indexOf(projects.value.find(p => p.id === projectId)), 1);
};

You simply splice the particular project from the projects ref. This is the changeCurrentProject in the <App> component:

const changeCurrentProject = project => {
    selectedProject.value = project.id;

    useHead({
        title: project.title + ' | ' + title // title come from "const title = useAppConfig().title;"
    });
};

Whenever a project's name is clicked on in the projects pane, we toggle to that project's tasks in the tasks pane. You do it like this:

<div v-for="task in tasks" :key="task.id" v-show="task.project._id===selectedProject">
    <Task @deleteTask="removeTask(task)"
        :id="task.id"
        :title="task.title"
        :description="task.description"
        :priority="task.priority"
        :due_date="task.due_date"
        :complete="task.complete"
        :project="task.project"
     />
</div>

You may notice that you are listening to a similar deleteTask event from <Task> and handling it with a similar removeTask function. Below is the removeTask function from the <App> component:

const removeTask = task => {
    tasks.value.splice(tasks.value.indexOf(tasks.value.find(t => t.id === task.id)), 1);
    // remove the task from the projects as well
    projects.value.find(p => p.id === task.project._id).tasks.splice(
        projects.value.find(p => p.id === task.project._id).tasks
                .find(t => t.id === task.id),
        1
    );
};

Remember how you iterated over the projects ref and assigned all the belonging tasks to each project? So, not only do you have to splice the tasks ref, but you also need to splice the project's tasks array to which the task being deleted belongs. Below is the full code for the <Task> component:

<template>
    <section class="pb-[20px] border-t-[1px]" :class="{'opacity-30': complete, 'cursor-pointer': taskHover}" @dblclick="taskEditForm=!taskEditForm" v-show="!taskEditForm"  @mouseover="taskHover=true" @mouseleave="taskHover=false">
        <h3 class="task-title mt-[15px] font-bold text-slate-700">
            <input class="task-complete ml-[-28px] mr-[10px]" type="checkbox" @input="complete=!complete; updateTask()" :checked="complete">
            {{title}}
        </h3>
        <span v-show="taskHover" class="float-right border-gray-200 px-1 text-sky-700 border-[1px] rounded" @click="deleteTask">&#10060;</span>
        <p class="mt-[2px] mb-[15px]">{{description}}</p>
        <time class="italic mr-[30px]">Due {{moment(due_date.timestamp).fromNow()}}</time>
        <span class="italic">Priority: {{getPriority()}}</span>
    </section>
    <form class="border-t-[1px] pt-[15px]" v-show="taskEditForm" name="task-edit" @submit.prevent="taskEditForm=!taskEditForm; updateTask()">
        <div class="input-group">
            <label for="task-title">Title:</label>
            <input placeholder="Name your task" type="text" id="task-title" required v-model="title">
        </div>
        <div class="input-group">
            <label for="task-description">Description:</label>
            <textarea placeholder="Describe your task" required v-model="description">{{description}}</textarea>
        </div>
        <div class="input-group">
            <label for="task-priority">Priority:</label>
            <select required v-model="priority">
                <option value="P1" :selected="priority === 'P1'">Very imortant</option>
                <option value="P2" :selected="priority === 'P2'">Important</option>
                <option value="P3" :selected="priority === 'P3'">Normal</option>
                <option value="P4" :selected="priority === 'P4'">Not important</option>
            </select>
        </div>
        <div class="input-group">
            <label for="task-due-date">Due:</label>
            <input type="datetime-local" required :value="moment(due_date.timestamp).format('YYYY-MM-DDThh:mm')" @input="e => due_date.timestamp = (new Date(e.target.value)).getTime()">
        </div>
        <button class="task-edit-button btn mb-[15px]">Update task</button>
    </form>
</template>

<script setup lang="ts">
    import moment from 'moment';

    const props = defineProps(['id', 'title', 'description', 'due_date', 'priority', 'complete', 'project']);
    const emit = defineEmits(['deleteTask']);

    let id = ref(props.id);
    let title = ref(props.title);
    let description = ref(props.description);
    let priority = ref(props.priority);
    let due_date = ref(props.due_date);
    let project = ref(props.project);
    let complete = ref(props.complete);
    let taskEditForm = ref(false);
    let taskHover = ref(false);

    const getPriority = () => {
        switch (priority._value) {
            case 'P1': return 'Very important';
            case 'P2': return 'Important';
            case 'P3': return 'Normal';
            case 'P4': return 'Not important';
        }
    };

    const updateTask = async () => {
        await $fetch('/api/task/update', {
            method: 'PUT',
            body: {
                taskId: id.value,
                title: title.value,
                description: description.value,
                priority: priority.value,
                due_date: due_date.value,
                complete: complete.value,
                projectId: project.value._id
            }
        });
    };

    const deleteTask = async () => {
        await $fetch('/api/task/delete', {
            method: 'DELETE',
            body: {taskId: id.value}
        });
        emit('deleteTask');
    }
</script>

Here is the full code for the <App> component:

<template>
    <div class="mx-[20px]">
        <div id="column-1" class="float-left w-[25%]">
            <h2 class="text-lg font-bold text-sky-700 mb-[20px]">Projects</h2>
            <div id="projects">
                <ul class="list-disc">
                    <Project
                        v-for="project in projects"
                        :key="project.id"
                        :id="project.id"
                        :title="project.title"
                        :tasks="project.tasks"
                        @changeSelectedProject="changeCurrentProject(project)"
                        @deleteProject="removeProject(project.id)"
                     />
                </ul>
            </div>
            <div class="mt-[20px]">
                <button @click="showInput" class="btn" v-show="!projectAddInput">New project</button>
                <form @submit.prevent="addProject" name="project-add">
                    <input placeholder="Project name" class="w-[70%]" required
                           v-model="newProjectName"
                           v-show="projectAddInput"
                    >
                </form>
            </div>
        </div>
        <div id="column-2" class="float-left w-[75%]">
            <h2 class="text-lg font-bold text-sky-700 float-left">Tasks</h2>
            <button class="mb-[20px] btn float-right" :class="taskAddForm ? 'text-white bg-slate-200' : ''" @click="taskAddForm=!taskAddForm">New task</button>
            <div class="mt-[50px]">
                <form name="task-add" v-show="taskAddForm" @submit.prevent="addTask">
                    <div class="input-group">
                        <label for="task-title">Title:</label>
                        <input placeholder="Name your task" type="text" required v-model="newTitle">
                    </div>
                    <div class="input-group">
                        <label for="task-description">Description:</label>
                        <textarea placeholder="Describe your task" required v-model="newDescription"></textarea>
                    </div>
                    <div class="input-group">
                        <label for="task-priority">Priority:</label>
                        <select required v-model="newPriority">
                            <option value="P1">Very Imortant</option>
                            <option value="P2">Important</option>
                            <option value="P3">Normal</option>
                            <option value="P4">Not Important</option>
                        </select>
                    </div>
                    <div class="input-group">
                        <label for="task-due-date">Due:</label>
                        <input type="datetime-local" required v-model="newDueDate">
                    </div>
                    <button class="mb-[20px] btn">Add task</button>
                </form>
            </div>
            <div id="tasks">
                <div v-for="task in tasks" :key="task.id" v-show="task.project._id===selectedProject">
                    <Task @deleteTask="removeTask(task)"
                        :id="task.id"
                        :title="task.title"
                        :description="task.description"
                        :priority="task.priority"
                        :due_date="task.due_date"
                        :complete="task.complete"
                        :project="task.project"
                     />
                </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
    import type { TasksResponse } from '~/server/api/task/all';
    import type { ProjectsResponse } from '~/server/api/project/all';
    import type { TaskEntryMetaItem } from '~/bcms/types/ts';

    let projectAddInput = ref(false);
    let projectAdd = ref(null);
    let taskAddForm = ref(false);
    let newTitle = ref('');
    let newDescription = ref('');
    let newPriority = ref('P3');
    let newDueDate = ref();
    let newProjectName = ref('');

    const [tasksReq, projectsReq] = await Promise.all([ 
        useFetch<TasksResponse>('/api/task/all'),
        useFetch<ProjectsResponse>('/api/project/all')
    ]);
    const tasks = tasksReq.data;
    const projects = projectsReq.data;
    projects.value.forEach(project => project.tasks = tasks.value.filter(task => task.project._id === project.id));
    let selectedProject = ref(projects.value[0].id);
    const title = useAppConfig().title;

    useHead({
        title: projects.value[0].title + ' | ' + title
    })

    const showInput = async () => {
        projectAddInput.value = !projectAddInput.value;
        await nextTick();
        projectAdd.value.focus();
    }

    const removeProject = projectId => {
        projects.value.splice(projects.value.indexOf(projects.value.find(p => p.id === projectId)), 1);
    };

    const removeTask = task => {
        tasks.value.splice(tasks.value.indexOf(tasks.value.find(t => t.id === task.id)), 1);
        // remove the task from the projects as well
        projects.value.find(p => p.id === task.project._id).tasks.splice(
            projects.value.find(p => p.id === task.project._id).tasks
                    .find(t => t.id === task.id),
            1
        );
    };

    const changeCurrentProject = project => {
        selectedProject.value = project.id;
        useHead({
            title: project.title + ' | ' + title
        });
    };

    const addProject = async () => {
        projectAddInput.value = !projectAddInput.value;
        projects.value.push({title: newProjectName, id: Math.random().toString(36).slice(2), tasks: []});
        selectedProject.value = projects.value[projects.value.length - 1].id;
        useHead({
            title: newProjectName.value + ' | ' + title
        })
        await $fetch('/api/project/create', {
            method: 'POST',
            body: {title: newProjectName.value}
        });
    };

    const addTask = async () => {
        taskAddForm.value = !taskAddForm.value;
        let newTask: TaskEntryMetaItem = {
            id: Math.random().toString(36).slice(2),
            title: newTitle.value,
            description: newDescription.value,
            complete: false,
            priority: newPriority.value,
            due_date: {
                timestamp: new Date(newDueDate.value).getTime(),
                timezoneOffset: new Date(newDueDate.value).getTimezoneOffset()
            },
            project: {
                _id: selectedProject.value,
                title: projects.value.filter(project => project.id === selectedProject.value).title
            }
        };
        tasks.value.push(newTask);
        projects.value.find(project => project.id == selectedProject.value).tasks.push(newTask);
        await $fetch('/api/task/create', {
            method: 'POST',
            body: {
                title: newTask.title,
                description: newTask.description,
                complete: newTask.complete,
                priority: newTask.priority,
                due_date: newTask.due_date,
                projectId: selectedProject.value,
            }
        });
    }
</script>

In the addProjectand addTask functions, you generate random ids using Math.random().toString(36).slice(2). You do not need to retrieve the _ids generated by BCMS back when a task or project is created, to make your code work. You can simply avoid that. Check out the full source code for this project here: https://github.com/shammadahmed/sms-alerts-task-manager.

Deployment

You can deploy your Nuxt project, including the Nitro API, for free on serverless platforms like Netlify, Cloudflare, and Vercel. And there is no configuration required to deploy, the platforms automatically detect that we are using Nuxt.

One thing that you do need to do is: for Nuxt, you need to set the env variables separately. Before now, we have been running the npm run dev command, which makes use of the .env file. The npm run build command doesn't use the .env file.

You need to specify the ENV variables in the command. You could also set them using your specific platform's environment variables settings. Check out the live version of this app deployed on Netlify: https://tasksnsms.netlify.app/.

SMS alerts with Zapier and Twilio

Let's get started with the SMS alerts part and create a Twilio account. After you are inside the Twilio dashboard, you may need to click a specific button to reveal and obtain your Twilio phone number. You will use this phone number in the “From” field when you call the Twilio API from Zapier. Other than this phone number, you will also need the Account SID and the Auth Token. Keep the Twilio tab open so that you can copy these values to Zapier.

twilio

I am using Twilio to send the actual SMS and Zapier to set up Cron jobs.

Create an account on Zapier. After you are inside the Zapier dashboard, click “+ Create” and then “Zaps”. A zap is simply an automated workflow that Zapier performs for you however you want. Click on the first step that says “Trigger” and select “Schedule” under “Popular built-in tools”.

In the edit pane that appears on the right, set “Trigger event” to “Everyday” in the Setup tab. In the Configure tab, you can set the Time of Day to be any time you like, e.g., 6 o’clock in the morning. Set “Trigger on Weekends?” to “yes”.

In the Test tab, click “Test trigger”. After the test completes, click “Continue with selected record”. Zapier will now ask you to select a tool for the second step.  Select “Code” under “Popular built-in tools” and name it “Capture tasks and send SMS” by clicking on the pencil icon next to where it says “Select the event”.

19 twilio scheduling.png

Select “Run JavaScript” for “Action event” in the Setup tab for this step. In the Configure tab, paste the following code in the “Code” input:

const accountSid = '<account sid>';
const authToken = '<auth token>';
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;

const headers = {
    'Authorization': 'Basic ' + btoa(`${accountSid}:${authToken}`)
}

const res = await fetch('https://tasksnsms.netlify.app/api/task/all');
const tasks = await res.json();

const getPriority = (priority) => {
    switch (priority) {
        case 'P1': return 'Very important';
        case 'P2': return 'Important';
        case 'P3': return 'Normal';
        case 'P4': return 'Not important';
    }
};

const dueToday = tasks.filter(task => {
    let today = new Date(task.due_date.timestamp).getDay() === (new Date()).getDay();
    if (today && !task.complete) {
        return true;
    }
});

for (const task of dueToday) {
    await fetch(url, {
        method: 'POST',
        headers: headers,
        body: (new URLSearchParams({
          'To': '<to phone number>',
          'From': '<from phone number>',
          'Body': `Due Today: ${task.title} \nDescription: ${task.description} \nPriority: ${getPriority(task.priority)} \nProject: ${task.project.meta.en.title}`
        })).toString()
    });
}

return {};

Replace the strings for Account SID, Auth Token, “To” phone number, and the “From” Phone number. Right now, I have used my own phone number in the “To” field. You may recognize the getPriority function from the <Task>  component. In this code, you make a fetch request to your Nitro task/all API endpoint and retrieve all tasks.

You then filter the tasks array and store the ones that are due today and still incomplete in dueToday. You loop over dueToday  and send an SMS for each task.

I use (new URLSearchParams).toString()  since I need to send the data to Twilio, not as JSON but in the application/<i>x-www-form-urlencoded</i> MIME type. As for the Twilio URL, I obtained it from their SMS demo at “https://console.twilio.com/us1/develop/sms/try-it-out/send-an-sms”.

Before you run the test for this step, make sure that at least one task in your app is incomplete and has a due date of today. In the Test tab, click the “Test step” button. You will receive an SMS:

sms alert

Finally, click the “Publish” button in the top right-hand corner of the website. Your Zap is now live, and you will receive your tasks in SMSes on time:

21 project done.png

Where to go from here?

One thing that we are missing in this application is authentication. You can add authentication using Nuxt's built-in auth features. You can also use Auth-as-a-Service services like Auth0 and Clerk. You will need to modify the project template to add a "user_id" field to it.

Conclusion

To sum up, you started with a scaffolded blog template and then created the data templates in BCMS Cloud. You then coded the Nitro API endpoints and connected them to your Vue components. We used a serverless platform to deploy the project. In the last section, we created a zap in Zapier to run a block of code every day to obtain tasks, whenever due, in SMS form.

I enjoyed working on this project. I hope that you also enjoyed following along and reading this tutorial. Feel free to ask any questions that you may have. Till then, happy coding!

It takes a minute to start using BCMS

Gradient

Join our Newsletter

Get all the latest BCMS updates, news and events.

You’re in!

The first mail will be in your inbox next Monday!
Until then, let’s connect on Discord as well:

Join BCMS community on Discord

By submitting this form you consent to us emailing you occasionally about our products and services. You can unsubscribe from emails at any time, and we will never pass your email to third parties.

Gradient