Table of Contents
- Introduction
- Tech Stack
- Project Overview
- Firebase Setup
- Message Schema with Zod
- Real-Time Messaging with Firestore
- Pagination and Lazy Loading
- Scroll Behavior
- UI with shadcn/ui and Tailwind
- Handling Edge Cases
- Conclusion
Introduction
This post walks through the process of building a modern real-time chat application using Next.js, Firebase Firestore, and Tailwind CSS. The app allows users to enter a display name and join a live chat where messages are synced instantly across clients. It supports lazy loading of older messages, smooth scroll behavior, and a clean, responsive UI built with minimal dependencies.
Tech Stack
- Next.js for the React framework
- Firebase Firestore for real-time database
- Tailwind CSS for styling
- shadcn/ui for prebuilt UI components
- Zod for runtime validation of messages
Project Overview
The chat app begins with a simple prompt asking for your display name. Once submitted, you're taken to the main chat interface. It displays the 25 most recent messages by default and anchors the view to the bottom. As users scroll up, older messages are fetched and added seamlessly. All messages are updated in real-time using Firestore subscriptions, ensuring that new content appears instantly across all clients.
Firebase Setup
To get started with Firebase and Firestore:
1. Create a Firebase project:
- Go to Firebase Console.
- Click “Add project” and follow the setup wizard (you can skip Google Analytics).
2. Add Firestore to your project:
- In the Firebase Console, navigate to Build > Firestore Database.
- Click “Create database”, select Start in test mode, and choose your region.
3. Create a Web App in Firebase:
- Go to Project Settings > General.
- Scroll down to “Your apps” and click the
>
icon to add a new web app. - Register your app and Firebase will generate your configuration object.
4. Set up environment variables:
In your project root, create a file named .env.local
and add the following using your config values:
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_auth_domain
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_storage_bucket
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
NEXT_PUBLIC_FIREBASE_COLLECTION=your_unique_collection_name
5. Initialize Firebase in your app:
Create a file like lib/firebase.ts
:
import { initializeApp } from "firebase/app"
import { getFirestore } from "firebase/firestore"
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}
const app = initializeApp(firebaseConfig)
export const db = getFirestore(app)
You're now ready to use Firestore to store and retrieve chat messages in real time.
Message Schema with Zod
In this project, we take a code-first approach when working with Firestore. Instead of relying on Firestore rules or ad-hoc validation, we define the shape and structure of a valid message directly in our application code using Zod.
Zod is a TypeScript-first schema declaration and validation library. It allows us to describe the expected structure of a message object and ensure that any data coming from or going to Firestore matches this schema.
This improves data consistency and safety. For example, it prevents malformed data from being saved or rendered, and avoids bugs caused by missing or invalid fields.
Here’s the schema definition:
import { z } from "zod"
import { Timestamp } from "firebase/firestore"
export const messageSchema = z.object({
id: z.string(),
text: z.string().min(1),
sender: z.string(),
timestamp: z.instanceof(Timestamp),
})
export type Message = z.infer<typeof messageSchema>
We use this schema in three main places:
- When sending messages – to ensure the message has the right shape before adding it to Firestore.
- When fetching messages – to validate all messages returned from Firestore queries.
- When listening to new messages in real time – to discard or warn about any data that doesn’t match the schema.
This helps enforce strict typing and protects your frontend from unexpected data issues, especially in real-time applications.
Real-Time Messaging with Firestore
To enable real-time chat functionality, the app leverages Firestore’s onSnapshot()
method. This sets up a listener on the collection that receives updates whenever new documents (messages) are added that match the query criteria.
In our implementation, we subscribe only to messages with a timestamp
greater than the most recent message already displayed. This prevents re-fetching old messages. Every new message is parsed and validated with zod
before it's added to the UI, ensuring both real-time updates and schema safety.
Pagination and Lazy Loading
The chat is optimized for performance and user experience using a lazy loading strategy. On initial load, the app fetches only the 25 most recent messages using a descending order query. These are then reversed to maintain chronological order.
When the user scrolls upwards, additional older messages are fetched using startAfter(lastDoc)
to avoid duplicates. This paginated approach ensures scalability and smooth UX without overloading the UI with too many messages at once.
Scroll Behavior
The scroll behavior is intelligently managed:
- On first load or when a new message is sent/received, the chat automatically scrolls to the bottom.
- When loading older messages, the app maintains the user's current scroll position to avoid disorienting jumps.
This logic provides a seamless experience, especially in long conversations, and mimics the behavior of popular chat apps.
UI with shadcn/ui and Tailwind
The user interface is built using components from shadcn/ui
, a utility-focused library that integrates seamlessly with Tailwind CSS. For example, we use Textarea
for message input and Button
for actions like sending messages. These components provide a clean foundation with minimal styling, allowing us to maintain design consistency while focusing on function.
Tailwind CSS is used throughout for layout and spacing. It ensures that the chat UI is responsive and visually balanced across different screen sizes without the need for custom CSS.
Handling Edge Cases
To provide a smooth and robust user experience, several edge cases are considered and handled:
- Empty Chats: If there are no messages yet, the app handles the empty state gracefully without crashing or rendering errors.
- Realtime Handling of Own Sent Message: Messages sent by the current user appear instantly and are not duplicated when received via the real-time listener.
- Debouncing Scroll Fetches: Scroll-triggered fetching of older messages is debounced to avoid spamming the backend with requests.
- Avoiding Duplicate Message Entries: Before adding any new message to the list, the app checks for existing message IDs to prevent duplicates in the UI.
Conclusion
This project demonstrates how to build a full-featured real-time chat app using Firebase Firestore, Next.js, and modern UI tools like Tailwind CSS and shadcn/ui.
It showcases best practices such as:
- Code-first schema validation with Zod
- Real-time data updates using Firestore subscriptions
- Efficient lazy loading and scroll handling
- Clean and minimal UI with reusable components
The architecture is intentionally kept simple and readable to emphasize clarity over complexity. Whether you're testing this for a coding challenge or building on top of it for a larger project, this foundation provides a strong starting point.
Feel free to reach out with questions, feedback, or suggestions. I'd love to hear what you think!