Hello, Folks! It's been a while ๐
I'm back with a straightforward tutorial that guides you through building a simple application using Supabase on the backend and Next JS on the front end. This tutorial aims to help you grasp the process of easily writing and retrieving data from a database.
Technologies Used:
Next JS
Tailwind CSS
Supabase
What are we building?
We're creating a collaborative storytelling application. In this app, anyone can contribute a new story or edit an existing one. The basic functionality includes adding a story, which in turn adds new data to the database, and editing an existing story, which updates any relevant preexisting data.
Here's the application: storycreator-app.vercel.app
Let's Start
First thing first, let's set up Next JS with Supabase.
npx create-next-app@latest supabase-story-app
cd supabase-story-app
npm i @supabase/supabase-js
Launch the code editor of your choice; for this tutorial, we'll be using VS Code. Additionally, ensure that you've opted for TypeScript, Tailwind CSS, and App Router during the setup process.
Supabase Setup
Next, navigate to the Supabase Dashboard. If you don't have an account, sign up, and then create a new project named story-app
.
Inside the created project, go to the SQL Editor and establish a new table called stories
using the following query:
CREATE TABLE stories (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT
);
This command will generate a table with columns for id, title, and content.
Awesome! Your database and table are now set up. However, you may encounter an error where Supabase returns an empty array when attempting to build the UI. This is because Supabase requires the setup of Row Level Security (RLS). Follow these steps to create one for your database:
Navigate to the Authentication Tab.
Click on Policies -> New Policy -> Get Started Quickly.
Choose the appropriate policy; here, we use "Enable read access to everyone." Save the policy.
As we aim to display stories on our web app, let's insert one. Go to the SQL Editor tab and execute the following query:
INSERT INTO Story (title, content) VALUES ('First Story', 'This is the content of the first story.');
Fantastic! You've now configured your database, created a table, and added some data.
Now, let's dive into building the front end!
Integrate Supabase with Next JS
Before we get started, we'll need a few values to establish the connection. Navigate to the Supabase Dashboard, click on the "Project Settings" tab, and then select "API." From here, we require the Project URL and Project API Key. Copy these values and return them to your code editor.
In the root directory, create a file named .env.local
and paste the values as follows:
NEXT_PUBLIC_SUPABASE_URL=Your-Project-URL
NEXT_PUBLIC_SUPABASE_KEY=Your-API-Key
To ensure that the environment variables work, download the dotenv
package in the terminal:
npm i dotenv
In your root folder, create a "services" folder, and inside it, create a file named supabase.ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY;
// Check if Supabase URL or API Key is missing and throw an error if so
if (!supabaseUrl || !supabaseKey) {
throw new Error('Supabase URL or API Key is missing');
}
// Create a Supabase client using the retrieved URL and API Key
export const supabase = createClient(supabaseUrl, supabaseKey);
Fantastic! You've now successfully established the connection.
Let's create another file in the services folder named "storyServices.ts" and add the following code:
// Import necessary functions and types from the Supabase JavaScript library
import {PostgrestResponse, PostgrestSingleResponse } from "@supabase/supabase-js";
import { supabase } from "./supabase";
// Define the structure of a Story using TypeScript interface
interface Story {
id: number;
title: string;
content: string;
}
// Async function to fetch stories from the "stories" table in Supabase
export const getStories = async (): Promise<Story[] | null> => {
// Make a request to select all columns from the "stories" table
const { data, error }: PostgrestResponse<Story> = await supabase.from('stories').select('*');
// If an error occurs during the request, throw the error
if (error) {
throw error;
}
// Return the fetched data (an array of stories) or null if there's no data
return data;
};
// Async function to update a story's content in the "stories" table in Supabase
export const updateStory = async (
id: number,
newStory: Partial<Story>
): Promise<PostgrestSingleResponse<Story | null>> => {
// Make a request to Supabase to update the content of a story based on its ID
const { data, error, count, status, statusText } = await supabase
.from("stories")
.update({ content: newStory.content })
.eq("id", id);
// If an error occurs during the request, throw the error
if (error) {
throw error;
}
// Return an object containing the updated data (story) or null if there's no data,
// along with other response properties such as count, status, and statusText
return { data: data as Story | null, error, count, status, statusText };
};
Great! The main services are now updated. Let's move on to the UI!
Creating Frontend
Create a "components" folder in the root directory; this folder will contain all the components required for our web app.
Disclaimer: The app uses Tailwind CSS for UI. I won't delve into its details to keep the article simple.
In the "components" folder, create a "Story.tsx" file and add the following code:
"use client"
// Import the necessary modules and define the interface for the Story component
import React, { ChangeEvent, useState } from "react";
// Define the props for the Story component
interface StoryProps {
story: {
id: number;
title: string;
content: string;
};
onEdit: (editedStory: {
id: number;
title: string;
content: string;
}) => void;
}
// Define the Story component as a functional component
const Story: React.FC<StoryProps> = ({ story, onEdit }) => {
// State to manage the edited story and editing status
const [editedStory, setEditedStory] = useState(story);
const [isEditing, setIsEditing] = useState(false);
// Function to handle saving edits
const handleEdit = () => {
onEdit(editedStory);
setIsEditing(false); // Reset editing state after saving
};
// Function to handle changes in the content textarea
const handleContentChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setEditedStory({ ...editedStory, content: e.target.value });
};
// Return the JSX structure of the Story component
return (
<div className={`relative bg-gray-100 bg-opacity-${isEditing ? '75' : '50'} p-6 rounded-lg overflow-hidden shadow-xl transition-all duration-300 hover:bg-blur mx-4 mt-8`}>
<div className="relative z-10 text-black">
<div className="mb-4">
{/* Display the title with different font sizes for different screen sizes */}
<h2 className="font-bold text-xl md:text-2xl lg:text-3xl mb-2">{story.title}</h2>
{/* Display the content paragraph without a background only when not editing */}
{!isEditing && (
<p className="font-light text-gray-800 text-base md:text-lg lg:text-xl overflow-hidden overflow-ellipsis whitespace-nowrap">
{story.content}
</p>
)}
</div>
{/* Display textarea and button only when editing */}
{isEditing && (
<>
<textarea
className="resize-none border rounded-md p-2 mb-4 w-full h-40 md:h-48 lg:h-56 focus:ring-4"
value={editedStory.content}
onChange={handleContentChange}
placeholder="Type your story here..."
/>
<button className="bg-gray-700 hover:bg-gray-900 text-white font-normal py-2 px-6 rounded-full transition duration-300" onClick={handleEdit}>
Save Edit
</button>
</>
)}
{/* Button to trigger editing */}
{!isEditing && (
<button className="bg-gray-700 hover:bg-gray-900 text-white font-normal py-2 px-6 rounded-full transition duration-300" onClick={() => setIsEditing(true)}>
Continue...
</button>
)}
</div>
</div>
);
};
// Export the Story component as the default export of the file
export default Story;
Now, to display all these story cards in a responsive grid on our web app, create a "StoryList.tsx" file in the "components" folder and add the following code:
// Import React and the Story component
"use client"
import React from "react";
import Story from "./Story";
// Define the props for the StoryList component
interface StoryListProps {
stories: {
id: number;
title: string;
content: string;
}[];
onEdit: (
editedStory: {
id: number;
title: string;
content: string;
}
) => void;
}
// Define the StoryList component as a functional component
const StoryList: React.FC<StoryListProps> = ({ stories, onEdit }) => {
return (
<>
{/* Display a title for the list of stories */}
<h1 className="text-center font-bold text-3xl sm:text-4xl md:text-5xl mt-8 text-purple-500">
List Of Stories
</h1>
{/* Display a grid of stories using the Story component */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 gap-4">
{/* Map through the stories array and render a Story component for each story */}
{stories.map((story) => (
<Story key={story.id} story={story} onEdit={onEdit} />
))}
</div>
</>
);
};
// Export the StoryList component as the default export of the file
export default StoryList;
Let's create a form component that facilitates the addition of a new story to the database. In the components folder create an AddStory.tsx file
"use client"
import React, { useState } from "react";
import { supabase } from "../services/supabase";
// Define the props for the AddStory component
interface AddStoryFormProps {
onAdd: () => void; // Callback function to be executed after adding a story
}
const AddStory: React.FC<AddStoryFormProps> = ({ onAdd }) => {
// State to manage the title and content input values
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
// Function to handle adding a new story to the database
const handleAddStory = async () => {
try {
// Check if both title and content are provided
if (title && content) {
// Make a request to add or update a story in the "stories" table
const { data, error } = await supabase
.from("stories")
.upsert([{ title, content }]);
if (error) {
throw error; // Throw an error if the request encounters an issue
}
// Clear the form and trigger the parent component to refresh the story list
setTitle("");
setContent("");
onAdd(); // Execute the callback function passed as a prop
}
} catch (error: any) {
console.error("Error adding story:", error.message);
}
};
// JSX structure for the form component
return (
<div className="m-6 flex items-center justify-center">
{/* Set full prop to make it cover the entire page */}
<div className="bg-white rounded-md p-8">
<h2 className="text-black text-2xl font-bold mb-4">Add New Story</h2>
<form>
{/* Title input field */}
<label className="text-black mb-4 block">
Title:
<input
className="border rounded-md p-2 w-full"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
{/* Content textarea */}
<label className=" text-black mb-4 block">
Content:
<textarea
className="border rounded-md p-2 w-full"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
{/* Button to trigger the handleAddStory function */}
<button
className="bg-purple-500 text-white font-semibold rounded-md py-2 px-4"
type="button"
onClick={handleAddStory}
>
Add Story
</button>
</form>
</div>
</div>
);
};
export default AddStory;
Now best the part, add the components on a page.tsx file under the app directory.
"use client"
import { getStories, updateStory } from "@/services/storyServices"; // Import functions for fetching and updating stories
import React, { useEffect, useState } from "react";
import AddStory from "@/components/AddStory"; // Import the AddStory component
import StoryList from "@/components/StoryList"; // Import the StoryList component
// Define the Home component as a functional component
const Home: React.FC = () => {
// State to manage the list of stories
const [stories, setStories] = useState<{
id: number;
title: string;
content: string;
}[]>([]);
// useEffect hook to fetch stories when the component mounts
useEffect(() => {
fetchStories();
}, []);
// Function to fetch stories from the database
const fetchStories = async () => {
try {
// Make a request to get stories using the getStories function
const storiesData = await getStories();
// Update the state with the fetched stories (or an empty array if there's an error)
setStories(storiesData || []);
} catch (error: any) {
console.error("Fetched Error: ", error.message);
}
};
// Function to handle adding a new story
const handleAdd = () => {
// Refresh the story list after adding a new story
fetchStories();
};
// Function to handle editing an existing story
const handleEdit = async (editedStory: {
id: number;
title: string;
content: string;
}) => {
// Update the state with the edited story
const updatedStories = stories.map((s) => (s.id === editedStory.id ? editedStory : s));
setStories(updatedStories);
try {
// Make a request to update the story using the updateStory function
await updateStory(editedStory.id, editedStory);
} catch (error: any) {
console.error("Error updating story:", error.message);
}
};
// JSX structure for the Home component
return (
<div className="flex flex-col min-h-screen">
{/* AddStory component for adding new stories */}
<AddStory onAdd={handleAdd} />
{/* StoryList component for displaying the list of stories */}
<StoryList stories={stories} onEdit={handleEdit} />
{/* Footer section */}
<footer className="mt-20 py-4 text-center text-black font-semibold">
Made with <span className="text-pink-500">♥</span> by Vanshika
</footer>
</div>
);
};
// Export the Home component as the default export of the file
export default Home;
Voila! Our app is ready! Time to run.
npm run dev
You should see your app loaded on https://localhost:3000 .
That's it for this tutorial! Let me know how you liked it! Cheers ๐ฅ