Build A Realtime Chat App With Next.js And Supabase
Hey guys, welcome back to the blog! Today, we're diving deep into something super cool: building a realtime chat application using two powerhouse technologies – Next.js and Supabase. If you've been looking to create dynamic, instant communication features in your web apps, you've come to the right place. We're going to break down the entire process, from setting up your projects to implementing those slick, real-time updates that make your chat feel alive. Get ready to level up your full-stack game!
Setting Up Your Next.js Project
First things first, let's get our Next.js project up and running. If you're new to Next.js, it's a fantastic React framework that makes building server-rendered and static web applications a breeze. To start, open your terminal and run the following command:
npx create-next-app@latest my-chat-app
cd my-chat-app
This command will create a new Next.js project named my-chat-app and then navigate you into the project directory. We'll be adding our real-time chat functionalities here. Next.js provides a great structure for managing both frontend and backend logic, which is perfect for what we're about to do. You can customize the setup with TypeScript or Tailwind CSS if you prefer, but for simplicity, we'll stick to the default JavaScript setup. Once created, fire up your development server with npm run dev to see your basic Next.js app in action. This initial setup is crucial because it lays the foundation for all the exciting features we'll integrate later. The Next.js ecosystem is known for its developer experience, offering features like fast refresh, file-system routing, and API routes, all of which will streamline our development process. Understanding these core Next.js concepts will make integrating Supabase much smoother. We'll be leveraging Next.js API routes later to handle some backend operations, so keep that in mind!
Introducing Supabase for Realtime Features
Now, let's talk about Supabase. If you haven't heard of it, think of it as an open-source Firebase alternative. It provides a suite of tools for backend development, including a PostgreSQL database, authentication, storage, and, most importantly for us, realtime subscriptions. This is the magic sauce that will allow our chat messages to appear instantly for all connected users without needing to manually refresh the page. To get started with Supabase, head over to supabase.io and create a free account. Once logged in, create a new project. You'll be given a project URL and a public API key – make sure to copy these down, as you'll need them to connect your Next.js app to your Supabase backend. You can find these details on your project's API settings page. Supabase's powerful database capabilities, built on PostgreSQL, mean you can store chat messages, user information, and more with ease. The realtime feature works by leveraging PostgreSQL's logical replication. When data changes in your database, Supabase can broadcast these changes to any subscribed clients. This is incredibly efficient and scalable, making it ideal for applications requiring instant data synchronization. We'll be setting up a simple messages table in our Supabase database to store the chat content. This table will likely have columns such as id (a unique identifier), content (the message text), user_id (to know who sent it), and created_at (a timestamp). The realtime aspect comes into play when we subscribe to changes on this messages table. Any new row inserted into this table will be broadcasted to all clients that are listening for those specific changes. This is the core mechanism that powers our realtime chat functionality. Supabase also offers a generous free tier, making it accessible for developers to experiment and build applications without significant upfront costs.
Connecting Next.js to Supabase
To bridge our Next.js frontend with the Supabase backend, we need to install the Supabase JavaScript client library. Open your terminal in your project directory and run:
npm install @supabase/supabase-js
Next, create a new file, perhaps utils/supabaseClient.js, to initialize your Supabase client. This file will hold your Supabase project URL and public API key. It's crucial to keep your service-role key secret and not expose it in your frontend code. For this tutorial, we'll use the public key which is safe to share.
// utils/supabaseClient.js
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);
Before this code works, you need to set up your environment variables. Create a .env.local file in the root of your Next.js project and add your Supabase keys:
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
Remember to replace YOUR_SUPABASE_URL and YOUR_SUPABASE_ANON_KEY with the actual values from your Supabase project dashboard. The NEXT_PUBLIC_ prefix is important because it makes these variables available on the client-side. This connection setup is fundamental. Without a correctly configured Supabase client, your Next.js application won't be able to communicate with your Supabase database, fetch messages, or listen for realtime updates. We're essentially establishing a secure channel between your frontend and backend services. It's like giving your Next.js app the keys to access the Supabase vault. Once this is done, you can start making authenticated calls and, more importantly, subscribing to database changes. The createClient function from @supabase/supabase-js handles the heavy lifting of establishing this connection, managing authentication tokens, and ensuring secure communication. This step solidifies the integration, preparing us for the core real-time chat logic.
Designing Your Supabase Database Schema
Let's design a simple schema in Supabase for our chat messages. Navigate to your Supabase project dashboard, go to the 'SQL Editor', and create a new query. We'll create a messages table.
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc', now())
);
-- Enable Realtime for the messages table
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Create a policy to allow reading messages
CREATE POLICY "Allow read messages" ON messages FOR SELECT USING (true);
-- Create a policy to allow inserting messages
CREATE POLICY "Allow insert messages" ON messages FOR INSERT WITH CHECK (auth.role() = 'authenticated');
-- Enable realtime broadcasting for the messages table
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
This SQL script creates a messages table with an id, content, user_id, and created_atcolumn. Crucially, we enable Row Level Security (RLS) and define policies to control access. For a chat app, we typically want authenticated users to be able to insert messages and all users (or authenticated users) to read them. TheALTER PUBLICATION supabase_realtime ADD TABLE messages;line is what tells Supabase to broadcast changes for this table. This setup is vital for security and functionality. By enabling RLS, we ensure that users can only perform actions they're authorized for. The insert policy, for example, ensures only authenticated users can post messages. Without these policies, your app might be vulnerable or not function as expected. TheENABLE ROW LEVEL SECURITYcommand is fundamental for controlling data access at a granular level within your PostgreSQL database, which Supabase uses. This means that each query made by a client can be restricted based on user identity, role, or other conditions. For our chat app, we want to ensure that users can only see messages and potentially only insert their own messages (though for a public chat, inserting might be enough). TheCREATE POLICYstatements define these rules. TheAllow read messagespolicy usesUSING (true), which means anyone can read messages. You might want to change this later, perhaps to auth.role() = 'authenticated'. The Allow insert messagespolicy restricts insertion to authenticated users only. Finally,ALTER PUBLICATION supabase_realtime ADD TABLE messages;is the command that actually turns on the realtime subscription capabilities for themessagestable. Supabase uses PostgreSQL's logical replication feature, and this command configures the publication to include ourmessages` table, ensuring that inserts, updates, and deletes are broadcasted. This is the lynchpin for achieving instant updates in our chat application.
Building the Chat UI Components
Now for the fun part – the frontend! In your Next.js project, you'll want to create components for displaying messages and for sending new messages. Let's create a components directory if you don't have one, and inside it, create MessageList.js and MessageInput.js.
components/MessageList.js
import React from 'react';
function MessageList({ messages }) {
return (
<div className="message-list">
{messages.map((msg) => (
<div key={msg.id} className="message">
<p><strong>{msg.user_id}</strong>: {msg.content}</p>
<small>{new Date(msg.created_at).toLocaleTimeString()}</small>
</div>
))}
</div>
);
}
export default MessageList;
components/MessageInput.js
import React, { useState } from 'react';
function MessageInput({ onSendMessage }) {
const [message, setMessage] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (message.trim()) {
onSendMessage(message);
setMessage('');
}
};
return (
<form onSubmit={handleSubmit} className="message-input-form">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
/>
<button type="submit">Send</button>
</form>
);
}
export default MessageInput;
These are basic functional components. MessageList takes an array of messages and renders them, and MessageInput provides a form for users to type and send messages. The onSendMessage prop in MessageInput will be a function passed down from the parent component that handles the actual sending logic. We'll be using some simple CSS classes here, but you can style these however you like using CSS modules, Tailwind CSS, or any styling solution you prefer. The goal is to create reusable components that clearly separate concerns: one for displaying, one for input. We can add more features later, like displaying usernames instead of IDs, user avatars, or timestamps. For now, focusing on the core functionality is key. The key={msg.id} prop is essential for React's list rendering performance, ensuring that React can efficiently update the list when new messages arrive. In MessageInput, the useState hook manages the input field's value. The handleSubmit function prevents the default form submission, checks if the message is not empty, calls the onSendMessage callback, and then clears the input field. This component-based approach in Next.js (which leverages React) makes it easy to build complex UIs by composing smaller, manageable pieces. Each component has a specific job, making the codebase cleaner and easier to maintain. This is a fundamental principle of modern frontend development and a key strength of using frameworks like Next.js.
Implementing Realtime Messaging Logic
Now, let's tie it all together in our main page, likely pages/index.js. We need to fetch existing messages and, crucially, subscribe to new messages in realtime using Supabase.
// pages/index.js
import React, { useState, useEffect } from 'react';
import MessageList from '../components/MessageList';
import MessageInput from '../components/MessageInput';
import { supabase } from '../utils/supabaseClient';
export default function Home() {
const [messages, setMessages] = useState([]);
// Fetch initial messages
useEffect(() => {
const fetchMessages = async () => {
let { data, error } = await supabase
.from('messages')
.select('*')
.order('created_at', { ascending: true });
if (error) console.error('Error fetching messages:', error);
else setMessages(data || []);
};
fetchMessages();
}, []);
// Subscribe to new messages
useEffect(() => {
const subscription = supabase
.from('messages')
.on('*', (payload) => {
console.log('Change received!', payload);
// Handle different types of changes (INSERT, UPDATE, DELETE)
if (payload.new) {
// If it's an insert, add the new message to our state
// Avoid adding duplicates if the initial fetch already included it
setMessages((prevMessages) => {
if (prevMessages.some(msg => msg.id === payload.new.id)) {
return prevMessages;
}
return [...prevMessages, payload.new];
});
} else if (payload.eventType === 'DELETE') {
// Handle deletion if needed
setMessages((prevMessages) => prevMessages.filter(msg => msg.id !== payload.old.id));
}
})
.subscribe();
// Clean up subscription on unmount
return () => {
supabase.removeSubscription(subscription);
};
}, []);
// Function to send a new message
const handleSendMessage = async (content) => {
// Assume user is authenticated - you'll need actual auth implementation
const { data: user } = await supabase.auth.getUser();
const user_id = user?.id || 'anonymous'; // Fallback for unauthenticated users
const { data, error } = await supabase
.from('messages')
.insert([{ content, user_id }]);
if (error) console.error('Error sending message:', error);
// The realtime subscription will handle adding this message to the UI
};
return (
<div>
<h1>Next.js Supabase Realtime Chat</h1>
<MessageList messages={messages} />
<MessageInput onSendMessage={handleSendMessage} />
</div>
);
}
In this index.js file, we use useState to manage our list of messages. The first useEffect hook fetches the initial set of messages from Supabase when the component mounts. The second useEffect is where the realtime magic happens. We use supabase.from('messages').on('*', ...).subscribe() to listen for any changes (inserts, updates, deletes) on the messages table. When a new message comes in (payload.new), we add it to our messages state using setMessages. We also include logic to avoid duplicates and handle deletions. The handleSendMessage function takes the message content, gets the current authenticated user's ID (you'll need to implement Supabase Auth for real user management), and inserts the new message into the messages table. Because we are subscribed to changes, Supabase automatically pushes this new message to all connected clients, including the one that just sent it, updating the UI instantly. This is the essence of realtime functionality. The supabase.removeSubscription(subscription) in the cleanup function is vital to prevent memory leaks and ensure that subscriptions are properly closed when the component unmounts. This prevents unnecessary network activity and potential bugs. The payload.eventType check is also important for handling different database operations gracefully. For a simple chat, we primarily care about INSERT, but you might need UPDATE or DELETE in more complex scenarios. Make sure your supabase.auth.getUser() logic is robust in a production app, perhaps redirecting unauthenticated users or handling anonymous posts differently.
Adding Authentication (Optional but Recommended)
For a real chat application, you'll want user authentication. Supabase makes this incredibly easy with its built-in authentication service. You can implement sign-up, login, and manage user sessions directly.
First, enable authentication in your Supabase project dashboard. Then, you can use the Supabase client to handle user flows:
// Example of handling sign-in
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'userpassword',
});
// To get the current user
const { data: { user } } = await supabase.auth.getUser();
// To listen to auth state changes (e.g., login/logout)
supabase.auth.onAuthStateChange((event, session) => {
console.log(event, session);
// Update UI based on auth status
});
Integrating authentication is key to building a secure and personalized chat experience. Instead of just showing user_id, you can display actual usernames and ensure users can only manage their own messages. You would typically manage the user's session state in your Next.js app, perhaps using Context API or a state management library, and pass the user information down to your components. When sending a message, you'd use the authenticated user.id instead of a fallback. Authentication transforms a basic chat into a user-centric application. This involves more UI changes, like login/signup forms and user profile displays. You'd also refine your Supabase RLS policies to be user-specific, for instance, CREATE POLICY "Allow update own messages" ON messages FOR UPDATE USING (auth.uid() = user_id);. This adds a significant layer of security and personalization. The onAuthStateChange listener is particularly powerful, allowing your entire application to react in real-time to login and logout events, ensuring a seamless user experience. For instance, you might disable the message input field if a user is not logged in, or show a different UI.
Styling Your Chat Interface
While the core functionality is there, a good-looking UI makes all the difference. You can use standard CSS, CSS Modules, or utility-first frameworks like Tailwind CSS within your Next.js project to style your MessageList and MessageInput components. Add some padding, margins, borders, and maybe even user avatars or different message bubble styles to make your chat engaging.
For example, in a global CSS file (e.g., styles/globals.css):
.message-list {
height: 400px;
overflow-y: scroll;
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 10px;
}
.message {
margin-bottom: 8px;
}
.message p {
margin: 0;
}
.message small {
color: #888;
font-size: 0.7em;
}
.message-input-form {
display: flex;
}
.message-input-form input {
flex-grow: 1;
padding: 10px;
margin-right: 5px;
}
.message-input-form button {
padding: 10px 15px;
}
Styling is subjective, but making your chat interface clean and intuitive is crucial for user experience. Consider how messages are displayed, how new messages are highlighted, and the overall layout. A scrollable message list is essential for longer conversations. Using flexbox for the input form allows the input field and button to arrange nicely. Consistent styling across your Next.js application will give it a professional feel. Don't forget responsiveness – ensure your chat works well on different screen sizes, from mobile phones to desktops. This might involve using media queries in your CSS or employing responsive design principles inherent in frameworks like Tailwind CSS. Ultimately, good styling enhances usability and makes your realtime chat application more enjoyable to use.
Conclusion: Your Realtime Chat App is Live!
And there you have it, guys! You've just built a basic realtime chat application using Next.js and Supabase. We covered setting up both projects, connecting them, designing a Supabase database, building UI components, and implementing the core realtime messaging logic. Supabase's realtime subscriptions are incredibly powerful, allowing you to push data changes to your users instantly. This pattern is fundamental for modern collaborative applications. Remember, this is a starting point. You can expand this project by adding features like user profiles, typing indicators, read receipts, private messaging, and more robust error handling. The combination of Next.js's frontend capabilities and Supabase's backend services provides a robust and scalable foundation for almost any web application. Keep experimenting, keep building, and happy coding!
Key Takeaways:
- Next.js provides a powerful framework for building the frontend.
- Supabase offers a managed PostgreSQL database with realtime subscriptions.
- The
@supabase/supabase-jsclient connects your Next.js app to Supabase. - Database RLS policies are essential for security.
supabase.from(...).on(...).subscribe()is the core function for realtime updates.- Authentication enhances user experience and security.
Go forth and build amazing realtime experiences!