TL;DR
In this tutorial, you will learn how to create an X (Twitter) post scheduler ๐ฅ
- Authenticates users via X (Twitter).
- Schedule posts and save them to Supabase.
- Post to X (Twitter) with Trigger.dev.
Your background job management for NextJS
Trigger.devย is an open-source library that enables you to create and monitor long-running jobs for your app with NextJS, Remix, Astro, and so many more!
If you can spend 10 seconds giving us a star, I would be super grateful ๐
https://github.com/triggerdotdev/trigger.dev
Let's set it up ๐ฅ
Let's create a TypeScript Next.js application by running the code snippet below.
npx create-next-app x-post-scheduler
Install theย React Iconsย and theย Headless UIย packages. React Icons enable us to use different icons within the application, and we'll leverage Headless UI to addย animated custom modalsย to the application.
npm install @headlessui/react react-icons
The right way for authentication ๐
Here, I'll walk you through how to add X authentication to your Next.js application and enable permissions to send posts on behalf of your users.
To create a Twitter Developersโ project, you must have an X account. Visit theย homepage and create a new project.
Provide a suitable project name and answer all the required questions.
Next, create an App under the project and copy the tokens generated into a .env.local
file within your Next.js project.
TWITTER_API_KEY=<your_api_key>
TWITTER_API_SECRET=<your_api_secret>
TWITTER_BEARER_TOKEN=<your_bearer_token>
Scroll down the page and set up user authentication to enable users to sign in to your application via X.
Select Read and write
as the app permission, enable Request email from users
, and select Web app
as the type of app.
Scroll down to the next section and provide your app's callback URL, website URL, and the required information. If you are using the Next.js development server, you may use the same inputs in the image below. After authentication, users are redirected to http://www.localhost:3000/dashboard
, which is another route on the client side.
After setting up the authentication process, save the OAuth 2.0 Client ID and secret into the .env.local
file.
TWITTER_CLIENT_ID=<app_client_id>
TWITTER_CLIENT_SECRET=<app_client_secret>
Adding X authentication to Next.js
Create a Sign in with Twitter
link element within the index.ts
file that redirects users to X and allows them to grant your app access to their profile.
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
import Link from "next/link";
export default function Home() {
const router = useRouter();
const getTwitterOauthUrl = () => {
const rootUrl = "https://twitter.com/i/oauth2/authorize";
const options = {
redirect_uri: "<your_callback_URL>",
client_id: process.env.TWITTER_CLIENT_ID!,
state: "state",
response_type: "code",
code_challenge: "y_SfRG4BmOES02uqWeIkIgLQAlTBggyf_G7uKT51ku8",
code_challenge_method: "S256",
//๐๐ป required scope for authentication and posting tweets
scope: ["users.read", "tweet.read", "tweet.write"].join(" "),
};
const qs = new URLSearchParams(options).toString();
return `${rootUrl}?${qs}`;
};
return (
<main
className={`flex items-center flex-col justify-center min-h-screen ${inter.className}`}
>
<h2 className='font-bold text-2xl '>X Scheduler</h2>
<p className='mb-3 text-md'>Get started and schedule posts</p>
<Link
href={getTwitterOauthUrl()}
className='bg-blue-500 py-3 px-4 text-gray-50 rounded-lg'
>
Sign in with Twitter
</Link>
</main>
);
}
From the code snippet above, the Sign in with Twitter
link executes the getTwitterOauthUrl
function, redirecting users to X and enabling them to grant the app access to their profile.
When users authorize your app, they are redirected to the callback URL page, and you need to access the code
parameter added to the URL and send this code to the server for further processing.
http://www.localhost:3000/dashboard?state=state&code=WTNhMUFYSDQwVnBsbEFoWGM0cmIwMWhKd3lJOFM1Q3FuVEdtdE5ESU1mVjIwOjE2OTY3NzMwMTEyMzc6M
Next, create the /dashboard
client route by creating a dashboard.tsx
file that extracts the code parameter from the URL and sends it to the server when the component mounts.
import React, { useCallback, useEffect } from "react";
const Dashboard = () => {
const sendAuthRequest = useCallback(async (code: string | null) => {
try {
const request = await fetch("/api/twitter/auth", {
method: "POST",
body: JSON.stringify({ code }),
headers: {
"Content-Type": "application/json",
},
});
const response = await request.json();
console.log("RES >>>", response);
} catch (err) {
console.error(err);
}
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.href);
const code = params.get("code");
sendAuthRequest(code);
}, [sendAuthRequest]);
return (
<main className='w-full min-h-screen'>
<p>Dashboard</p>
</main>
);
};
export default Dashboard;
The code snippet above retrieves the code parameter from the URL and sends it to an /api/twitter/auth
endpoint on the server where the user will be authenticated.
On the server, the code received fetches the user's access token. With this token, you can retrieve the user's details and save or send them to the client.
Therefore, create an api/twitter/auth.ts
file (server route) that receives the code parameter from the client. Copy the code snippet below to the top of the file.
import type { NextApiRequest, NextApiResponse } from "next";
const BasicAuthToken = Buffer.from(
`${process.env.TWITTER_CLIENT_ID!}:${process.env.TWITTER_CLIENT_SECRET!}`,
"utf8"
).toString("base64");
const twitterOauthTokenParams = {
client_id: process.env.TWITTER_CLIENT_ID!,
//๐๐ป according to the code_challenge provided on the client
code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
redirect_uri: `<your_callback_URL>`,
grant_type: "authorization_code",
};
//gets user access token
export const fetchUserToken = async (code: string) => {
try {
const formatData = new URLSearchParams({
...twitterOauthTokenParams,
code,
});
const getTokenRequest = await fetch(
"https://api.twitter.com/2/oauth2/token",
{
method: "POST",
body: formatData.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BasicAuthToken}`,
},
}
);
const getTokenResponse = await getTokenRequest.json();
return getTokenResponse;
} catch (err) {
return null;
}
};
//gets user's data from the access token
export const fetchUserData = async (accessToken: string) => {
try {
const getUserRequest = await fetch("https://api.twitter.com/2/users/me", {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
const getUserProfile = await getUserRequest.json();
return getUserProfile;
} catch (err) {
return null;
}
};
- From the code snippet above,
- The
BasicAuthToken
variable contains the encoded version of your tokens. - The
twitterOauthTokenParams
contains the parameters required for getting the users' access token. - The
fetchUserToken
function sends a request to Twitter's endpoint and returns the user's access token, and thefetchUserData
function accepts the token and retrieves the user's X profile.
- The
Finally, create the endpoint as done below.
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { code } = req.body;
try {
const tokenResponse = await fetchUserToken(code);
const accessToken = tokenResponse.access_token;
if (accessToken) {
const userDataResponse = await fetchUserData(accessToken);
const userCredentials = { ...tokenResponse, ...userDataResponse };
return res.status(200).json(userCredentials);
}
} catch (err) {
return res.status(400).json({ err });
}
}
The code snippet above retrieves the user's access token and profile details using the functions declared above, merge them into a single object, and sends them back to the client.
Congratulations! You've successfully added X (Twitter) authentication to your Next.js application.
Building the schedule dashboard โฐ
In this section, you'll learn how to create the user interface for the application by building a calendar-like table to enable users to add and delete scheduled posts within each cell.
Before we proceed, create a utils/util.ts
file. It will contain some of the functions used within the application.
mkdir utils
cd utils
touch util.ts
Copy the code snippet below into the util.ts
file. It describes the structure of the table header and its contents (time and schedule). They will mapped into the table on the user interface.
export interface Content {
minutes?: number;
content?: string;
published?: boolean;
day?: number;
}
export interface AvailableScheduleItem {
time: number;
schedule: Content[][];
}
// table header
export const tableHeadings: string[] = [
"Time",
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// table contents
export const availableSchedule: AvailableScheduleItem[] = [
{
time: 0,
schedule: [[], [], [], [], [], [], []],
},
{
time: 1,
schedule: [[], [], [], [], [], [], []],
},
{
time: 2,
schedule: [[], [], [], [], [], [], []],
},
{
time: 3,
schedule: [[], [], [], [], [], [], []],
},
{
time: 4,
schedule: [[], [], [], [], [], [], []],
},
{
time: 5,
schedule: [[], [], [], [], [], [], []],
},
{
time: 6,
schedule: [[], [], [], [], [], [], []],
},
{
time: 7,
schedule: [[], [], [], [], [], [], []],
},
{
time: 8,
schedule: [[], [], [], [], [], [], []],
},
{
time: 9,
schedule: [[], [], [], [], [], [], []],
},
{
time: 10,
schedule: [[], [], [], [], [], [], []],
},
{
time: 11,
schedule: [[], [], [], [], [], [], []],
},
{
time: 12,
schedule: [[], [], [], [], [], [], []],
},
{
time: 13,
schedule: [[], [], [], [], [], [], []],
},
{
time: 14,
schedule: [[], [], [], [], [], [], []],
},
{
time: 15,
schedule: [[], [], [], [], [], [], []],
},
{
time: 16,
schedule: [[], [], [], [], [], [], []],
},
{
time: 17,
schedule: [[], [], [], [], [], [], []],
},
{
time: 18,
schedule: [[], [], [], [], [], [], []],
},
{
time: 19,
schedule: [[], [], [], [], [], [], []],
},
{
time: 20,
schedule: [[], [], [], [], [], [], []],
},
{
time: 21,
schedule: [[], [], [], [], [], [], []],
},
{
time: 22,
schedule: [[], [], [], [], [], [], []],
},
{
time: 23,
schedule: [[], [], [], [], [], [], []],
},
];
Add the code snippet below into the file to help format the time into a more readable format for your users.
export const formatTime = (value: number) => {
if (value === 0) {
return `Midnight`;
} else if (value < 10) {
return `${value}am`;
} else if (value >= 10 && value < 12) {
return `${value}am`;
} else if (value === 12) {
return `${value}noon`;
} else {
return `${value % 12}pm`;
}
};
Update the dashboard.tsx
file to display the table header and contents on the webpage within a table.
"use client";
import React, { useState } from "react";
import {
Content,
availableSchedule,
formatTime,
tableHeadings,
} from "@/utils/util";
import { FaClock } from "react-icons/fa6";
const Dashboard = () => {
const [yourSchedule, updateYourSchedule] = useState(availableSchedule);
//๐๐ป add scheduled post
const handleAddPost = (id: number, time: number) => {
console.log({ id, time });
};
//๐๐ป delete scheduled post
const handleDeletePost = (
e: React.MouseEvent<HTMLParagraphElement>,
content: Content,
time: number
) => {
e.stopPropagation();
if (content.day !== undefined) {
console.log({ time, content });
}
};
return (
<main className='w-full min-h-screen'>
<header className='w-full flex items-center mb-6 justify-center'>
<h2 className='text-center font-extrabold text-3xl mr-2'>
Your Post Schedules
</h2>
<FaClock className='text-3xl text-pink-500' />
</header>
<div className=' p-8'>
<div className='w-full h-[80vh] overflow-y-scroll'>
<table className='w-full border-collapse'>
<thead>
<tr>
{tableHeadings.map((day, index) => (
<th
key={index}
className='bg-[#F8F0DF] text-lg p-4 font-bold'
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{yourSchedule.map((item, index) => (
<tr key={index}>
<td className='bg-[#F8F0DF] text-lg font-bold'>
{formatTime(item.time)}
</td>
{item.schedule.map((sch, id) => (
<td
key={id}
onClick={() => handleAddPost(id, item.time)}
className='cursor-pointer'
>
{sch.map((content, ind: number) => (
<div
key={ind}
onClick={(e) =>
handleDeletePost(e, content, item.time)
}
className={`p-3 ${
content.published ? "bg-pink-500" : "bg-green-600"
} mb-2 rounded-md text-xs cursor-pointer`}
>
<p className='text-gray-700 mb-2'>
{content.minutes === 0
? "o'clock"
: `at ${content.minutes} minutes past`}
</p>
<p className=' text-white'>{content.content}</p>
</div>
))}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
};
export default Dashboard;
The handleAddPost
function runs when the user clicks on an empty cell on the table, and the handleDeletePost
is executed when the user clicks on a scheduled post within a cell.
Within the util.ts
file, create the Typescript interface for newly added posts and selected posts to be deleted. The DelSectedCell
Typescript interface defines the properties of a post within a cell, and the SelectedCell
represents the structure of the newly added post.
export interface DelSelectedCell {
content?: string;
day_id?: number;
day?: string;
time_id?: number;
time?: string;
minutes?: number;
}
export interface SelectedCell {
day_id?: number;
day?: string;
time_id?: number;
time?: string;
minutes?: number;
}
Import the DelSelectedCell
and SelectedCell
interfaces into the dashboard.tsx
file and create the states holding both data structures. Add two boolean states that display the needed modal when the user performs an add or delete event.
const [selectedCell, setSelectedCell] = useState<SelectedCell>({
day_id: 0,
day: "",
time_id: 0,
time: "",
});
const [delSelectedCell, setDelSelectedCell] = useState<DelSelectedCell>({
content: "",
day_id: 0,
day: "",
time_id: 0,
time: "",
minutes: 0,
});
//๐๐ป triggers the add post modal
const [addPostModal, setAddPostModal] = useState(false);
//๐๐ป triggers the delete post modal
const [deletePostModal, setDeletePostModal] = useState(false);
Modify the handleAddPost
and handleDeletePost
functions to update the recently added states as follows.
const handleAddPost = (id: number, time: number) => {
setSelectedCell({
day_id: id + 1,
day: tableHeadings[id + 1],
time_id: time,
time: formatTime(time),
});
setAddPostModal(true);
};
const handleDeletePost = (
e: React.MouseEvent<HTMLParagraphElement>,
content: Content,
time: number
) => {
e.stopPropagation();
if (content.day !== undefined) {
setDelSelectedCell({
content: content.content,
day_id: content.day,
day: tableHeadings[content.day],
time_id: time,
time: formatTime(time),
minutes: content.minutes,
});
setDeletePostModal(true);
}
};
The Delete and Add post modals with Headless UI
In this section, how to add posts when a user clicks on a cell and delete posts from the schedule when users click on a specific post in a cell.
Create a components folder containing the add and delete post modals.
mkdir components
cd components
touch AddPostModal.tsx DeletePostModal.tsx
Display both components within the dashboard.tsx
when the user wants to add or delete a post.
return (
<main>
{/*-- other dashboard elements --*/}
{addPostModal && (
<AddPostModal
setAddPostModal={setAddPostModal}
addPostModal={addPostModal}
selectedCell={selectedCell}
yourSchedule={yourSchedule}
updateYourSchedule={updateYourSchedule}
profile={username}
/>
)}
{deletePostModal && (
<DeletePostModal
setDeletePostModal={setDeletePostModal}
deletePostModal={deletePostModal}
delSelectedCell={delSelectedCell}
yourSchedule={yourSchedule}
updateYourSchedule={updateYourSchedule}
profile={username}
/>
)}
</main>
);
Both components accept the schedule, the states containing the post to be added or deleted, and the user's X username as props.
Copy the code snippet below into the AddPostModal.tsx
file.
import {
SelectedCell,
AvailableScheduleItem,
updateSchedule,
} from "@/utils/util";
import { Dialog, Transition } from "@headlessui/react";
import {
FormEventHandler,
Fragment,
useState,
Dispatch,
SetStateAction,
} from "react";
interface Props {
setAddPostModal: Dispatch<SetStateAction<boolean>>;
updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>;
addPostModal: boolean;
selectedCell: SelectedCell;
profile: string | any;
yourSchedule: AvailableScheduleItem[];
}
const AddPostModal: React.FC<Props> = ({
setAddPostModal,
addPostModal,
selectedCell,
updateYourSchedule,
profile,
yourSchedule,
}) => {
const [content, setContent] = useState<string>("");
const [minute, setMinute] = useState<number>(0);
const closeModal = () => setAddPostModal(false);
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
}
return ()
}
The code snippet above accepts the values passed via props into the component. The content and minute states hold the post's content and the post's scheduled minute. The handleSubmit
function is executed when the user clicks the Save
button to schedule a post.
Return the JSX elements below from the AddPostModal
component. The code snippet below uses theย Headless UI componentsย to render an animated and already customised modal.
<div>
<Transition appear show={addPostModal} as={Fragment}>
<Dialog as='div' className='relative z-10' onClose={closeModal}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-200'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='fixed inset-0 bg-black bg-opacity-80' />
</Transition.Child>
<div className='fixed inset-0 overflow-y-auto'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
>
<Dialog.Panel className='w-full max-w-xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title
as='h3'
className='text-xl font-bold leading-6 text-gray-900'
>
Schedule a post on {selectedCell.day} by {selectedCell.time}
</Dialog.Title>
<form className='mt-2' onSubmit={handleSubmit}>
{minute > 59 && (
<p className='text-red-600'>
Error, please minute must be less than 60
</p>
)}
<label htmlFor='minute' className='opacity-60'>
How many minutes past?
</label>
<input
type='number'
className='w-full border-[1px] px-4 py-2 rounded-md mb-2'
name='title'
id='title'
value={minute.toString()}
onChange={(e) => setMinute(parseInt(e.target.value, 10))}
max={59}
required
/>
<label htmlFor='content' className='opacity-60'>
Post content
</label>
<textarea
className='w-full border-[1px] px-4 py-2 rounded-md mb-2 text-sm'
name='content'
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
<div className='mt-4 flex items-center justify-between space-x-4'>
<button
type='submit'
className='inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
>
Save
</button>
<button
type='button'
className='inline-flex justify-center rounded-md border border-transparent bg-red-100 px-4 py-2 text-sm font-medium text-red-900 hover:bg-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2'
onClick={closeModal}
>
Cancel
</button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
Update the handleSubmit
function as done below.
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
if (
Number(minute) < 60 &&
content.trim().length > 0 &&
selectedCell.time_id !== undefined &&
selectedCell.day_id !== undefined
) {
const newSchedule = [...yourSchedule];
const selectedDay =
newSchedule[selectedCell.time_id].schedule[selectedCell.day_id - 1];
selectedDay.push({
content,
published: false,
minutes: minute,
day: selectedCell.day_id,
});
updateYourSchedule(newSchedule);
closeModal();
}
};
The function first validates if the minute entered by the user is valid, ensures the content is not empty, and confirms that the selected time and day are defined. Then, it retrieves the user's post schedule, identifies the selected day scheduled for the post, and updates the array with the new post details.
Add the code snippet below into the DeletePostModal.tsx
file.
"use client";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, Dispatch, SetStateAction } from "react";
import {
DelSelectedCell,
AvailableScheduleItem,
updateSchedule,
} from "../utils/util";
interface Props {
setDeletePostModal: Dispatch<SetStateAction<boolean>>;
deletePostModal: boolean;
delSelectedCell: DelSelectedCell;
profile: string | any;
yourSchedule: AvailableScheduleItem[];
updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>;
}
const DeletePostModal: React.FC<Props> = ({
setDeletePostModal,
deletePostModal,
delSelectedCell,
yourSchedule,
updateYourSchedule,
profile,
}) => {
const closeModal = () => setDeletePostModal(false);
const handleDelete = () => {};
return <main>{/**-- JSX elements --**/}</main>;
};
Render these JSX elements within the DeletePostModal
component.
return (
<div>
<Transition appear show={deletePostModal} as={Fragment}>
<Dialog as='div' className='relative z-10' onClose={closeModal}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-200'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='fixed inset-0 bg-black bg-opacity-25' />
</Transition.Child>
<div className='fixed inset-0 overflow-y-auto'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
>
<Dialog.Panel className='w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title
as='h3'
className='text-xl font-bold leading-6 text-gray-900'
>
Delete post
</Dialog.Title>
<div className='mt-2'>
<p className='mb-3'>Are you sure you want to delete?</p>
<p className='text-sm text-gray-500'>
{`"${delSelectedCell.content}" scheduled for ${delSelectedCell.day} at ${delSelectedCell.time_id}:${delSelectedCell.minutes}`}
</p>
</div>
<div className='mt-4 flex items-center justify-between space-x-4'>
<button
type='button'
className='inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
onClick={handleDelete}
>
Yes
</button>
<button
type='button'
className='inline-flex justify-center rounded-md border border-transparent bg-red-100 px-4 py-2 text-sm font-medium text-red-900 hover:bg-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2'
onClick={closeModal}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
Modify the handleDelete
function to accept the properties of the selected post and remove it from the schedule.
const handleDelete = () => {
if (
delSelectedCell.time_id !== undefined &&
delSelectedCell.day_id !== undefined
) {
//๐๐ป gets the user's post schedule
const initialSchedule = [...yourSchedule];
//๐๐ป gets the exact day the post is scheduled for
let selectedDay =
initialSchedule[delSelectedCell.time_id].schedule[
delSelectedCell.day_id - 1
];
//๐๐ป filters the array to remove the selected post
const updatedPosts = selectedDay.filter(
(day) =>
day.content !== delSelectedCell.content &&
day.minutes !== delSelectedCell.minutes
);
//๐๐ป updates the schedule
initialSchedule[delSelectedCell.time_id].schedule[
delSelectedCell.day_id - 1
] = updatedPosts;
//๐๐ป updates the schedule
updateYourSchedule(initialSchedule);
closeModal();
}
};
Congratulations on making it thus far! You can now add and remove scheduled posts from each cell. Next, let's connect a backend database (Supabase) to the application to persist data when the page is refreshed.
Saving everything to the database ๐
Supabaseย is an open-source Firebase alternative that enables you to add authentication, file storage, Postgres, and real-time database to your software applications. With Supabase, you can build secured and scalable applications in a few minutes.
In this section, you'll learn how to integrate Supabase into your Next.js application and save and update the user's schedule via Supabase. Before we proceed, install the Supabase package.
npm install @supabase/supabase-js
Visit theย Supabase homepageย and create a new organization and project.
To set up the database for the application, you need to create two tables - schedule_posts
containing the scheduled posts and the users
table containing all the user's information retrieved from X.
The users
table has three columns containing the username, ID, and access token retrieved after authentication. Ensure you make the username column the primary key for the table.
The schedule_posts
table contains six columns: a unique ID for each row, the timestamp indicating when the post will be live, the post content for X, the post status, the user's username on X, and day_id
representing the scheduled day. day_id
is necessary for retrieving existing schedules.
Next, make the profile
column a foreign key to the username column on the users
table. We'll need this connection when making queries that merge the data within both tables.
Clickย API
ย on the sidebar menu and copy the project's URL and API into theย .env.local
ย file.
NEXT_PUBLIC_SUPABASE_URL=<public_supabase_URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<supabase_anon_key>
Finally, create aย src/supbaseClient.ts
ย file and create the Supabase client for the application.
import { createClient } from "@supabase/supabase-js";
const supabaseURL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseURL, supabaseAnonKey, {
auth: { persistSession: false },
});
Finally, create an api/schedule
folder on the server containing a read, create, and delete route. The api/schedule/create
adds a new post schedule to the database, api/schedule/delete
deletes a selected post, and api/schedule/read
fetches all the posts created by a user.
cd pages/api
mkdir schedule
touch create.ts delete.ts read.ts
Before we continue, update the api/twitter/auth
endpoint to save the userโs access token and profile information to Supabase after authentication.
import { supabase } from '../../../../supabaseClient';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { code } = req.body
try {
const tokenResponse = await fetchUserToken(code)
const accessToken = tokenResponse.access_token
if (accessToken) {
const userDataResponse = await fetchUserData(accessToken)
const userCredentials = { ...tokenResponse, ...userDataResponse };
//๐๐ป saves user's information to Supabase
const { data, error } = await supabase.from("users").insert({
id: userCredentials.data.id,
accessToken: userCredentials.access_token,
username: userCredentials.data.username
});
if (!error) {
return res.status(200).json(userCredentials)
} else {c
return res.status(400).json({error})
}
}
} catch (err) {
return res.status(400).json({err})
}
}
Scheduling new posts
Modify the api/schedule/create
endpoint to accept the post details and save them to the Supabase.
import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../../supabaseClient";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { profile, timestamp, content, published, day_id } = req.body;
const { data, error } = await supabase.from("schedule_posts").insert({
profile, timestamp, content, published, day_id
});
res.status(200).json({data, error});
}
Add a function within the utils/util.ts
file that accepts all the post details from the client and sends them to the endpoint. Execute the function when a user adds a new schedule.
export const getNextDayOfWeek = (dayOfWeek: number, hours: number, minutes: number) => {
var today = new Date();
var daysUntilNextDay = dayOfWeek - today.getDay();
if (daysUntilNextDay < 0) {
daysUntilNextDay += 7;
}
today.setDate(today.getDate() + daysUntilNextDay);
today.setHours(hours);
today.setMinutes(minutes);
return today;
}
export const updateSchedule = async (
profile: string,
schedule: any
) => {
const { day_id, time, minutes, content, published } = schedule
const timestampFormat = getNextDayOfWeek(day_id, time, minutes)
try {
await fetch("/api/schedule/create", {
method: "POST",
body: JSON.stringify({profile, timestamp: timestampFormat, content, published, day_id}),
headers: {
"Content-Type": "application/json",
},
});
} catch (err) {
console.error(err);
}
};
The getNextDayOfWeek
function accepts the day, hour, and minutes of the post, gets the date of the selected day, and converts the post's time and date into a datetime format - the acceptable format on the database, before adding it to the request's body. The profile
parameter contains the userโs X username stored in local storage after authentication.
Deleting scheduled posts
Update the api/schedule/delete
endpoint to accept the postโs data and delete it from database.
import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../../supabaseClient";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { profile, timestamp, content } = req.body;
const { data, error } = await supabase
.from("schedule_posts").delete().eq("content", content).eq("timestamp", timestamp.toISOString())
res.status(200).json({ data, error });
}
Create the client-side function within the utils/util.ts
file that sends a request to the endpoint when the user deletes a post.
export const deleteSchedule = async (
profile: string,
schedule: any
) => {
const { day_id, time_id, minutes, content, published } = schedule
const timestampFormat = getNextDayOfWeek(day_id, time_id, minutes)
try {
await fetch("/api/schedule/delete", {
method: "POST",
body: JSON.stringify({ profile, timestamp: timestampFormat, content}),
headers: {
"Content-Type": "application/json",
},
});
} catch (err) {
console.error(err);
}
};
Fetching usersโ schedule
Modify the api/schedule/create
endpoint to accept a post details and add them to the database.
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../../supabaseClient";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { profile, timestamp, content, published, day_id } = req.body;
const { data, error } = await supabase.from("schedule_posts").insert({
profile, timestamp, content, published, day_id
});
res.status(200).json({data, error});
}
Create its request function on the client that retrieves all the scheduled posts and converts them to the format required on the front end.
export const fetchSchedule = async (
profile: string, updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>
) => {
try {
const request = await fetch("/api/schedule/read", {
method: "POST",
body: JSON.stringify({ profile }),
headers: {
"Content-Type": "application/json",
},
});
const response = await request.json();
const { data } = response
//๐๐ป if there is a response
if (data) {
const result = data.map((item: any) => {
const date = new Date(item.timestamp);
//๐๐ป returns a new array in the format below
return {
//๐๐ป converts 24 hour to 0 hour
time: date.getUTCHours() + 1 < 24 ? date.getUTCHours() + 1 : 0,
schedule: {
content: item.content,
published: item.published,
minutes: date.getUTCMinutes(),
day: item.day_id
},
}
})
//๐๐ป loops through retrieved schedule and add them to the large table array
result.forEach((object: any) => {
const matchingObjIndex = availableSchedule.findIndex((largeObj) => largeObj.time === object.time);
if (matchingObjIndex !== -1) {
availableSchedule[matchingObjIndex].schedule[object.schedule.day].push(object.schedule)
}
})
updateYourSchedule(availableSchedule)
}
} catch (err) {
console.error(err);
}
};
The code snippet above receives the scheduled posts from the server, converts it into the format required by the table UI, then loops through the data, adds them to the large array - availableSchedule
that contains the table layout, and updates the schedule state with the modified version.
Congratulations! You've successfully added Supabase to the application.
Sending the post at the right time โณ
Trigger.dev offers three communication methods: webhook, schedule, and event. Schedule is ideal for recurring tasks, events activate a job upon sending a payload, and webhooks trigger real-time jobs when specific events occur.
In this section, youโll learn how to schedule recurring tasks with Trigger.dev by comparing the current time with the time scheduled post times and automatically posting them to X at their scheduled times.
Adding Trigger.dev to a Next.js app
Before we continue, you need toย create a Trigger.dev account. Once registered, create an organization and choose a project name for your jobs.
Select Next.js as your framework and follow the process for adding Trigger.dev to an existing Next.js project.
Otherwise, clickย Environments & API Keys
ย on the sidebar menu of your project dashboard.
Copy your DEV server API key and run the code snippet below to install Trigger.dev. Follow the instructions carefully.
npx @trigger.dev/cli@latest init
Start your Next.js project.
npm run dev
In another terminal, run the following code snippet to establish a tunnel between Trigger.dev and your Next.js project.
npx @trigger.dev/cli@latest dev
Finally, rename theย jobs/examples.ts
ย file toย jobs/functions.ts
. This is where all the jobs are processed.
Congratulations!๐ You've successfully added Trigger.dev to your Next.js app.
Posting scheduled contents on X with Trigger.dev
Here, you'll learn how to create recurring jobs with Trigger. The job will check the scheduled posts every minute and post content to X at the exact time stated by the user.
To integrate Trigger.dev with Supabase, you need to install the Trigger.dev Supabase package.
npm install @trigger.dev/supabase
Import the [cronTrigger](https://trigger.dev/docs/sdk/crontrigger)
and Supabase from their packages into the jobs/functions.ts
file. The cronTrigger()
function enables us to execute jobs on a recurring schedule.
PS: Scheduled Triggersย do notย trigger Jobs in the DEVย Environment. When youโre working locally you should useย the Test featureย to trigger any scheduled jobs.
import { cronTrigger } from "@trigger.dev/sdk";
import { Supabase } from "@trigger.dev/supabase";
const supabase = new Supabase({
id: "supabase",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
});
Modify the job within the jobs/functions.ts
file to fetch posts that are yet to be published, and whose schedule time matches the current time.
client.defineJob({
id: "post-schedule",
name: "Post Schedule",
//๐๐ป integrate Supabase
integrations: { supabase },
version: "0.0.1",
//๐๐ป runs every minute
trigger: cronTrigger({
cron: "* * * * *",
}),
run: async (payload, io, ctx) => {
await io.logger.info("Job started! ๐");
const { data, error } = await io.supabase.runTask(
"find-schedule",
async (db) => {
return (await db.from("schedule_posts")
.select(`*, users (username, accessToken)`)
.eq("published", false)
.lt("timestamp", new Date().toISOString()))
}
);
await io.logger.info(JSON.stringify(data))
}
});
The code snippet above uses the foreign key (username
) created between the users
and the schedule_posts
table to perform a join query. This query returns the access token and username from the users
table and all the data within schedule_posts
.
Finally, iterate through the posts and post its content on X.
for (let i = 0; i < data?.length; i++) {
try {
const postTweet = await fetch("https://api.twitter.com/2/tweets", {
method: "POST",
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${data[i].users.accessToken}`,
},
body: JSON.stringify({ text: data[i].content })
})
const getResponse = await postTweet.json()
await io.logger.info(`${i}`)
await io.logger.info(`Tweet created successfully!${i} - ${getResponse.data}`)
} catch (err) {
await io.logger.error(err)
}
}
Congratulations! You have completed the project for this tutorial.
Conclusion
So far, you've learned how to add Twitter (X) authentication to a Next.js application, save data to Supabase, and create recurring tasks with Trigger.dev.
As an open-source developer, you're invited to join ourย communityย to contribute and engage with maintainers. Don't hesitate to visit ourย GitHub repositoryย to contribute and create issues related to Trigger.dev.
The source for this tutorial is available here:
https://github.com/triggerdotdev/blog/tree/main/x-post-scheduler
Thank you for reading!
Help me out ๐ฉท
If you can spend 10 seconds giving us a star, I would be super grateful ๐
https://github.com/triggerdotdev/trigger.dev
Top comments (7)
Interesting post. What did you use to create the GIF of the dashboard (at the end of the post)?
it seems an AI-generated image
A nice coding practise for people to do! There's just one problem. Twitter isn't worth supporting anymore.
Seems interesting, Need to execute it.
This is great, something I need to implement so I can schedule posts on X. Thanks!
Can we get the full code for this?
This is a really nice way to get more engagement over X!