Leandro Nuñez
Posted on August 9, 2023
Table of Contents:
- Introduction
- Background: Understanding Real-time Applications
- What is New in Next.js 13.4: A Glimpse at Server Actions
- Setting the Stage: Our Real-time Project Scope
- Getting Started: Initial Setup
- Building the Backend: Leveraging Server Actions
- Designing the Frontend: A Seamless User Experience
- Testing the Real-time Capabilities
- Enhancements and Optimizations
- Conclusion and Future Prospects
- Resources and Further Reading
1. Introduction:
Hello there!
If you're anything like me, you've probably found yourself marveling at the seamless interactivity of today's real-time web applications—those chatbots that respond instantly, the live notifications that pop up without a page refresh, and collaborative tools that update in the blink of an eye.
Real-time functionality has become less of a luxury and more of an expectation in the digital age.
Now, if you've been tracking the developments in the world of Next.js, you might've caught wind of the buzzworthy features of version 13.4, especially the game-changer: server actions. Are you curious about how this can redefine the way we craft real-time experiences? Well, so was I!
Dive with me into this case study, where we'll embark on a journey to construct a real-time application, leveraging the power and finesse of Next.js server actions.
Whether you're a seasoned developer or just venturing into the realm of real-time apps, there's a trove of insights waiting for you.
Let’s get the ball rolling, shall we?
2. Background: Understanding Real-time Applications
In today's fast-paced digital landscape, the term "real-time" often pops up across various contexts—from gaming and finance to communication and social media. But what exactly does "real-time" mean in the world of web applications? Let's demystify this.
What are Real-time Applications?
Real-time applications are systems or programs that immediately respond to user inputs or external events, offering instant feedback without perceptible delays. In simpler terms, think of them as live, dynamic platforms that evolve in "real-time," mirroring the constant flow of information in the modern digital ecosystem.
Real-life Examples
To put it into perspective, consider some ubiquitous examples:
- Instant Messaging Apps: Platforms like WhatsApp and Telegram where messages are sent, received, and seen without delay.
- Collaborative Tools: Think of Google Docs, where multiple users can edit a document simultaneously, observing each other's changes in real-time.
- Live Stock Tickers: Platforms that display stock prices which update instantaneously with market fluctuations.
- Online Multiplayer Games: Where players interact with each other and the environment with zero latency, ensuring a seamless gaming experience.
The Relevance of Real-time Applications
So, why is real-time functionality so sought after?
- User Expectation: Modern users expect immediacy. Whether it's a chat application or a weather update, any noticeable lag can lead to decreased user satisfaction.
- Enhanced Interactivity: Real-time features enable a more interactive and immersive user experience, promoting user engagement.
- Competitive Advantage: Offering real-time features can set platforms apart in a crowded market, offering a unique selling point that attracts and retains users.
The Challenges Ahead
Building real-time applications is not without its hurdles:
- Scalability Issues: Real-time apps often need to handle numerous simultaneous connections, requiring robust infrastructure.
- Data Integrity: Ensuring that real-time data remains consistent across various user interfaces can be a challenge, especially with multiple simultaneous edits or interactions.
- Latency: A real-time app is only as good as its slowest component. Ensuring minimal delays requires careful optimization and efficient use of resources.
Now that we've set the stage with a foundational understanding of real-time applications, we'll delve into how Next.js 13.4, with its server actions, emerges as a pivotal tool for developers aspiring to craft such immersive experiences.
3. What’s New in Next.js 13.4: A Glimpse at Server Actions
In the ever-evolving landscape of web development, Next.js has consistently been at the forefront, introducing features that redefine how we approach building applications. Version 13.4 is no exception, particularly with its emphasis on server actions. But before we dive deep, let's clarify some terminology:
A Primer on Actions
Actions in the React ecosystem, although still experimental, have brought about a paradigm shift by allowing developers to execute asynchronous code in response to user interactions.
Interestingly, while they aren't exclusive to Next.js or React Server Components, their use through Next.js means you're on the React experimental channel.
For those familiar with HTML forms, you might recall passing URLs to the action
prop. Now, with Actions, you can directly pass a function, making interactions more dynamic and integrated.
<button action={() => { /* async function logic here */ }}>Click me!</button>
React's integration with Actions also offers built-in solutions for optimistic updates. This emphasizes that while Actions are groundbreaking, the patterns are still evolving, and newer APIs might be added to further enrich them.
Embracing Form Actions
Form Actions represent an ingenious amalgamation of React's Actions with the standard <form>
API. They resonate with the primitive formaction
attribute in HTML, making it possible for developers to enhance progressive loading states and other functionalities out-of-the-box.
<!-- Traditional HTML approach -->
<form action="/submit-url">
<!-- form elements -->
</form>
<!-- With Next.js 13.4 Form Actions -->
<form action={asyncFunctionForSubmission}>
<!-- form elements -->
</form>
Server Functions & Server Actions
Server Functions are essentially functions that operate on the server-side but can be invoked from the client. These elevate Next.js's server-side rendering capabilities to a whole new level.
Transitioning to Server Actions, they can be understood as Server Functions, but ones specifically triggered as an action. Their integration with form elements, especially through the action
prop, ensures that the form remains interactive even before the client-side JavaScript loads. This translates to a smoother user experience, with React hydration not being a prerequisite for form submission.
// A simple Server Action in Next.js 13.4
<form action={serverActionFunction}>
<!-- form elements -->
</form>
Understanding Server Mutations
Lastly, we have Server Mutations, which are a subset of Server Actions. These are particularly powerful when you need to modify data on the server and then execute specific responses, such as redirect
, revalidatePath
, or revalidateTag
.
const serverMutationFunction = async () => {
// Modify data logic here...
// ...
return { revalidatePath: '/updated-path' };
}
<form action={serverMutationFunction}>
<!-- form elements -->
</form>
Notes:
In summary, Next.js 13.4's Server Actions framework, underpinned by Actions, Form Actions, Server Functions, and Server Mutations, embodies a transformative approach to real-time web applications.
As we move forward in our case study, you'll witness firsthand the prowess these features bring to the table.
So, let's gear up for the exciting journey ahead!
4. Setting the Stage: Our Real-time Project Scope
In the context of building a real-time application, Next.js 13.4's Server Actions play a crucial role. These alpha features make it easy to manage server-side data mutations, reduce client-side JavaScript, and progressively enhance forms.
Enabling Server Actions
First, you'll need to enable Server Actions in your Next.js project. Simply add the following code to your next.config.js
file:
module.exports = {
experimental: {
serverActions: true,
},
}
Creation and Invocation
Server Actions can be defined either within the Server Component that uses it or in a separate file for reusability between Client and Server Components.
Here’s how you can create and invoke Server Actions:
- Within Server Components: A Server Action can be easily defined within a Server Component, like this:
export default function ServerComponent() {
async function myAction() {
'use server'
// ...
}
}
- With Client Components: When using a Server Action inside a Client Component, create the action in a separate file and then import it.
// app/actions.js
'use server'
export async function myAction() {
// ...
}
Importing and using in Client Component:
// app/client-component.js
import { myAction } from './actions'
export default function ClientComponent() {
return (
<form action={myAction}>
<button type="submit">Add to Cart</button>
</form>
)
}
-
Custom Invocation:
You can use custom methods like
startTransition
to invoke Server Actions outside of forms, buttons, or inputs.
// Example using startTransition
'use client'
import { useTransition } from 'react'
import { addItem } from '../actions'
function ExampleClientComponent({ id }) {
let [isPending, startTransition] = useTransition()
return (
<button onClick={() => startTransition(() => addItem(id))}>
Add To Cart
</button>
)
}
Progressive Enhancement
Next.js 13.4 also offers Progressive Enhancement, allowing a <form>
to function without JavaScript. Server Actions can be passed directly to a <form>
, making the form interactive even if JavaScript is disabled.
// app/components/example-client-component.js
'use client'
import { handleSubmit } from './actions.js'
export default function ExampleClientComponent({ myAction }) {
return (
<form action={handleSubmit}>
{/* ... */}
</form>
)
}
Size Limitation
The maximum request body sent to a Server Action is 1MB by default. If needed, you can configure this limit using the serverActionsBodySizeLimit
option:
module.exports = {
experimental: {
serverActions: true,
serverActionsBodySizeLimit: '2mb',
},
}
5. Getting Started: Initial Setup
Creating a new Next.js 13.4 project
To get started with building a real-time application using Next.js 13.4, the first step is to create a new project. You can use the standard Next.js CLI command to initialize your project:
npx create-next-app@latest my-real-time-app
Replace my-real-time-app
with the desired name for your project. This command sets up a new Next.js project with default configurations.
Required dependencies and packages for real-time functionality
For real-time functionality, there are certain packages and dependencies you may require.
Depending on the specifics of your application, these could range from WebSockets libraries to GraphQL subscriptions and more.
Ensure you've reviewed the project requirements and added the necessary dependencies.
However, with Next.js 13.4's support for Server Actions, there's already a built-in setup that supports server-side processing, which can assist in achieving some of the real-time features.
Brief overview of the project structure and directory setup:
The App Router
With the introduction of Next.js 13.4, the App Router is a significant feature that allows developers to utilize shared layouts, nested routing, error handling, and more. It's designed to work in conjunction with the existing pages
directory, but it's housed within a new directory named app
.
To get started with the App Router:
- Create an
app
directory in the root of your project. - Add your routes or components inside this directory.
By default, components inside the app
directory are Server Components, offering optimal performance and allowing developers to easily adopt them.
Here's an example structure:
my-real-time-app/
│
├── app/ # Main directory for App Router components
│ ├── _error.js # Custom error page
│ ├── _layout.js # Shared layout for the app
│ │
│ ├── dashboard/ # Nested route example
│ │ ├── index.js # Dashboard main view
│ │ └── settings.js # Dashboard settings view
│ │
│ ├── index.js # Landing/Home page
│ ├── profile.js # User profile page
│ ├── login.js # Login page
│ └── register.js # Registration page
│
├── public/ # Static assets go here (images, fonts, etc.)
│ ├── images/
│ └── favicon.ico
│
├── styles/ # Global styles or variables
│ └── global.css
│
├── package.json # Dependencies and scripts
├── next.config.js # Next.js configuration
└── README.md # Project documentation
Server Components vs. Client Components
Thinking about how components render is crucial. In traditional SPAs (Single Page Applications), React renders the entire application on the client side. With Server Components, much of the application renders on the server, leading to performance benefits. Here's a guideline:
Server Components: Ideal for non-interactive parts of your application. These components are rendered on the server and sent to the client as HTML. The advantage here is improved performance, reduced client-side JavaScript, and the ability to fetch data or access backend resources directly.
Client Components: Used for interactive UI elements. They're pre-rendered on the server and then "hydrated" on the client to add interactivity.
To differentiate between these components, Next.js introduced the "use client"
directive. This directive indicates that a component should be treated as a Client Component. It should be placed at the top of a component file, before any imports.
For example, if you have an interactive counter, as in the provided code, you'll use the "use client"
directive to indicate that it's a client-side component.
Recommendations
As you structure your application, here are some guidelines:
- Use Server Components by default (as they are in the
app
directory). - Only opt for Client Components when you have specific use cases like adding interactivity, utilizing browser-only APIs, or leveraging React hooks that depend on state or browser functionalities.
Notes:
Following this structure and setup, you'll be well on your way to building a performant real-time application with Next.js 13.4's Server Actions.
6. Building the Backend: Leveraging Server Actions
The power of Next.js 13.4 shines when integrating real-time backend functionalities into our project. Let's walk through the steps with relevant code examples for our my-real-time-app
.
Introduction to how server actions will be employed in this project
For our my-real-time-app
, server actions act as our primary bridge between the frontend and backend, allowing for efficient data transactions without the need for separate APIs.
// my-real-time-app/app/actions/index.js
export * from './auth-action';
export * from './chat-action';
Setting up server actions for handling user authentication
In my-real-time-app
, we leverage server actions to streamline the authentication process.
// my-real-time-app/app/actions/auth-action.js
export const login = async (credentials) => {
// Logic for authenticating user with credentials
// Return user details or error message
};
export const logout = async (userId) => {
// Logic for logging out the user
// Return success or error message
};
export const register = async (userInfo) => {
// Logic for registering a new user
// Store user in database and return success or error message
};
Creating server actions for sending and receiving real-time messages
For the chat functionality:
// my-real-time-app/app/actions/chat-action.js
export const sendMessage = async (messageDetails) => {
// Logic to send a new message
// Store message in database and inform other users via WebSocket or similar
};
export const receiveMessage = async () => {
// Logic to receive a message in real-time
// Return the message details
};
export const getRecentMessages = async (userId) => {
// Logic to fetch recent messages for the user
// Retrieve messages from the database
};
Integrating a database (e.g., MongoDB) for message persistence
Using MongoDB as our primary data store:
// Initialize MongoDB connection
const { MongoClient } = require('mongodb');
const client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
// Now, use this connection in server actions to interact with the database.
In our chat actions:
// my-real-time-app/app/actions/chat-action.js
export const sendMessage = async (messageDetails) => {
const messagesCollection = client.db('chatDB').collection('messages');
await messagesCollection.insertOne(messageDetails);
// Inform other users via WebSocket or similar
};
Ensuring secure and fast communication through server actions
For security:
// Middleware for validating request data
const validateRequest = (req) => {
// Validation logic here
return isValid;
};
export const sendMessage = async (messageDetails) => {
if (!validateRequest(messageDetails)) {
throw new Error("Invalid request data");
}
// Remaining logic...
};
7. Designing the Frontend: A Seamless User Experience
In this section, we'll construct an intuitive and responsive chat interface for my-real-time-app
. The integration of Next.js 13.4's server components will enable real-time updates for a smooth user experience.
Architecting the main chat interface
Firstly, let's create the main chat interface:
// my-real-time-app/app/chat-interface.js
import { useEffect, useState } from 'react';
import { getRecentMessages } from './actions/chat-action';
export default function ChatInterface() {
const [messages, setMessages] = useState([]);
useEffect(() => {
async function loadMessages() {
const recentMessages = await getRecentMessages();
setMessages(recentMessages);
}
loadMessages();
}, []);
return (
<div className="chatBox">
{messages.map(msg => (
<p key={msg.id}>{msg.content}</p>
))}
</div>
);
}
This component fetches recent messages on load and renders them in a chatbox.
Connecting the frontend to server actions for real-time updates
Now, we'll set up real-time updates using a basic example of WebSockets:
// my-real-time-app/app/chat-interface.js
const [socket, setSocket] = useState(null);
useEffect(() => {
const ws = new WebSocket("ws://your-backend-url/ws");
ws.onmessage = (event) => {
const newMessage = JSON.parse(event.data);
setMessages(prevMessages => [...prevMessages, newMessage]);
};
setSocket(ws);
return () => {
ws.close();
};
}, []);
This hook establishes a WebSocket connection and updates the message list when a new message is received.
Implementing notifications for new messages
For a better UX, let's notify users of new messages:
// my-real-time-app/app/chat-interface.js
useEffect(() => {
if (messages.length && "Notification" in window && Notification.permission === "granted") {
const lastMessage = messages[messages.length - 1];
new Notification(`New message from ${lastMessage.sender}: ${lastMessage.content}`);
}
}, [messages]);
This effect sends a browser notification every time the messages list is updated with a new message.
Techniques for ensuring smooth and lag-free user interactions
To ensure a fluid experience:
- Lazy-load heavy components:
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function Chat() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</React.Suspense>
);
}
- Use Next.js's
React Server Components
to split logic:
Notes:
Remember from the earlier documentation, Server Components can be used for non-interactive parts, while Client Components can handle the interactive parts, reducing the amount of JavaScript sent to the client.
For instance, in our chat, the message history can be a Server Component, while the input field and send button, which require client-side interactivity, can be Client Components.
8. Testing the Real-time Capabilities
With the core components of our real-time application in place, it's essential to ensure that they function as expected, are performant, scalable, and robust. This section sheds light on various testing approaches tailored for real-time systems like our my-real-time-app
.
Tools and strategies for testing real-time functionalities
- End-to-End Testing with Cypress
For real-time applications, end-to-end tests are crucial. Let's set up an example with Cypress:
// cypress/integration/chat.spec.js
describe('Chat functionality', () => {
it('should send and receive messages in real-time', () => {
cy.visit('/chat');
cy.get('[data-cy=messageInput]').type('Hello, World!');
cy.get('[data-cy=sendButton]').click();
cy.contains('Hello, World!').should('exist');
});
});
- Load Testing with Artillery
This will help in understanding how the system behaves under significant numbers of users or messages:
# artillery-config.yml
config:
target: 'http://my-real-time-app.com'
phases:
- duration: 300
arrivalRate: 20
scenarios:
- flow:
- emit:
channel: 'chat'
payload:
message: 'Hello, World!'
$ artillery run artillery-config.yml
Addressing potential bottlenecks and performance issues
- Profiling Server Performance
Node.js provides in-built tools for profiling, and the --inspect
flag can be used with the Next.js development server to enable the Node.js inspector. By using Chrome's DevTools, one can get insights into performance bottlenecks.
- Client-side Performance Analysis
For the client-side, tools like the Performance
tab in Chrome DevTools can help identify rendering bottlenecks. Especially with real-time updates, ensure that unnecessary renders aren't happening.
Ensuring scalability and robustness of the real-time application
- State Management with SWR or React Query
Real-time applications often involve keeping the client's state in sync with the server. Libraries like SWR or React Query help in making this easier by offering features like automatic re-fetching, caching, and real-time synchronization.
Example with SWR:
// my-real-time-app/app/chat-interface.js
import useSWR from 'swr';
function ChatInterface() {
const { data: messages } = useSWR('/api/messages', fetcher);
// ... rest of the component
}
- Horizontal Scaling
For backend scalability, especially with WebSockets, consider using a solution like Redis to manage the state across multiple instances of your server. This way, if one server instance receives a message, it can broadcast it to clients connected to other server instances.
- Database Optimization
Ensure that your database queries, especially those that run frequently in real-time applications, are optimized. Index essential columns, and consider using database caching solutions for frequently accessed data.
Notes:
Testing real-time applications requires a combination of standard software testing techniques and some tailored specifically for the challenges and characteristics of real-time systems.
By ensuring a rigorous testing regime for my-real-time-app
, we can guarantee a smooth and responsive user experience, irrespective of the scale of user traffic or data flow.
9. Enhancements and Optimizations
With the foundational architecture of our real-time application firmly in place, our attention now turns to refining its features and performance. Here are some strategies to enhance the user experience and optimize our my-real-time-app
:
Tips for enhancing the user experience
- Implementing Read Receipts
Provide visual feedback to users when their messages have been read by the recipient. This enhances the interactive nature of real-time chats.
// my-real-time-app/app/components/Message.js
function Message({ content, status }) {
return (
<div>
<p>{content}</p>
{status === 'read' && <span>✓ Read</span>}
</div>
);
}
- Displaying Online Status
Show an indicator next to a user's name or avatar when they are online.
// my-real-time-app/app/components/UserStatus.js
function UserStatus({ isOnline }) {
return (
<div>
{isOnline ? <span className="online-indicator"></span> : <span className="offline-indicator"></span>}
</div>
);
}
Optimizing server actions for reduced latency
- Server-Side Batching
Batch server-side updates where feasible to reduce the number of messages sent to the client.
- Compress WebSocket Messages
For applications with high-frequency updates, consider compressing WebSocket messages to reduce the data transferred and increase speed.
// Example: Setting up compression with a WebSocket server
const WebSocket = require('ws');
const wss = new WebSocket.Server({
perMessageDeflate: {
zlibDeflateOptions: {
// Add compression options here
}
}
});
- Debounce Frequent Updates
If you're noticing rapid consecutive updates from clients, consider debouncing these to consolidate them into fewer, more meaningful updates.
Ensuring data integrity and fault tolerance
- Event Sourcing
For critical sections of your app where data integrity is paramount, consider adopting an event-sourcing pattern. This ensures every change to the application state is captured as an event, allowing for reliable recovery and replay of events.
- Implement Retry Logic
Ensure that if a message fails to send or an update doesn't go through due to network issues, there's a retry mechanism in place.
// Example: Simple retry logic with fetch
let retries = 3;
function fetchData(url) {
fetch(url)
.then(response => response.json())
.catch(error => {
if (retries > 0) {
retries--;
fetchData(url);
} else {
console.error('Failed to fetch data after 3 retries');
}
});
}
- Backup and Recovery Plans
Regularly back up data and ensure you have a clear plan and processes to recover data in case of failures. Use database replication or distributed databases like Cassandra for fault tolerance.
Notes:
The continued success of my-real-time-app
hinges not just on its core functionalities but also on the subtle enhancements and constant optimizations that ensure a frictionless user experience.
By incorporating the strategies listed above, we're poised to offer our users a superior chat experience that's reliable and delightful.
10. Conclusion and Future Prospects
Recap of the journey in building the real-time application
Our journey with my-real-time-app
took us from the initial setup with Next.js 13.4, through backend building with server actions, designing a seamless frontend experience, and ensuring the real-time capabilities were tested and optimized. We delved deep into the nuances of server and client components, ensuring an effective balance between server-side rendering and client-side interactivity.
The impact and importance of Next.js 13.4's server actions in the project
The introduction of server actions in Next.js 13.4 revolutionized our approach to real-time applications. It allowed us to build a highly interactive chat application that leverages the strengths of both server and client rendering. This not only optimized performance but also facilitated seamless user interactions without compromising on security or efficiency.
Future enhancements and features that can be added to the application
While my-real-time-app
has come a long way, the potential for future enhancements remains vast:
- Video Chat Integration: Introduce real-time video chat capabilities.
- Group Chats: Allow users to create, join, or leave group chats.
- End-to-End Encryption: Boost security by encrypting messages so that only the sender and recipient can decipher them.
- Customizable User Profiles: Give users the option to personalize their profile with avatars, status messages, and themes.
- Chatbots: Implement AI-driven chatbots for automated responses.
11. Resources and Further Reading
As you embark on your journey with real-time applications and dive deeper into the functionalities and intricacies of Next.js, here's a curated list of resources that can guide, inspire, and further educate you:
Official Documentation
Next.js Official Documentation: A comprehensive guide to everything that's new and improved in this version. Read here.
Server Actions in Next.js: A deep dive into the workings, best practices, and potentials of server actions, straight from the source. Read more.
The App Router: Understand the App Router's capabilities, especially concerning React Server Components. Explore here.
React Server Components: A primer on how to best utilize server components for optimized performance and flexibility. Learn here.
The end
First off, a massive thank you for journeying with me through this intricate maze of Next.js world.
If you've made it this far, congrats!
If you skimmed through some parts, I don't blame you – there were times when I wanted to skip writing them!
Building real-time applications is, in many ways, a roller coaster of emotions.
Some days I feel like a coding genius, while on others, I questioned every life choice that led me to become a developer.
Ever had those moments where you spend hours debugging an issue, only to realize you missed a semicolon?
Or when you accidentally delete an essential part of your code and wish life had a Ctrl + Z?
Oh, the joys of programming!
But here's the thing: amidst all the facepalms and occasional hair-pulling, there's an indescribable magic in seeing your creation come to life, in real-time. It’s that tiny spark of joy when your code runs without errors, the satisfaction when users love your app, and the pride in knowing you built something from scratch.
To every budding developer reading this: setbacks, frustrations, and 'why is this not working!?' moments are part and parcel of our journey. They aren’t signs that you're failing, but rather, stepping stones to becoming better.
So the next time your code refuses to cooperate, take a deep breath, grab some coffee (or tea, I don’t judge, I'm a matecocido fan myself), and remember you're not alone in this.
Keep pushing boundaries, keep learning, and remember that every line of code, whether it works or breaks, adds a chapter to your developer story.
And if you ever need a chuckle or a shoulder to cry on (virtually, of course), know that I've been there, done that, and have gotten frustrated enough to consider throwing my laptop out the window!
Here's to more coding adventures and fewer semicolon-induced bugs!
Cheers, and happy coding!
Posted on August 9, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.