visit
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.
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 that 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.
So, why is real-time functionality so sought after?
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.
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>
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 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>
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!
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,
},
}
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>
)
}
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>
)
}
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',
},
}
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.
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
.
Create an app
directory in the root of your project.
By default, components inside the app
directory are Server Components, offering optimal performance and allowing developers to easily adopt them.
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: 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.
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.
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
.
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';
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
};
// 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
};
// 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.
// 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
};
// 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...
};
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.
// 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>
);
}
// 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();
};
}, []);
// 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]);
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function Chat() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</React.Suspense>
);
}
React Server Components
to split logic:
With the core components of our real-time application in place, it's essential to ensure that they function as expected, and are performant, scalable, and robust. This section sheds light on various testing approaches tailored for real-time systems like our my-real-time-app
.
// 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');
});
});
# artillery-config.yml
config:
target: '//my-real-time-app.com'
phases:
- duration: 300
arrivalRate: 20
scenarios:
- flow:
- emit:
channel: 'chat'
payload:
message: 'Hello, World!'
$ artillery run artillery-config.yml
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.
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.
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
}
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. 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.
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
:
// my-real-time-app/app/components/Message.js
function Message({ content, status }) {
return (
<div>
<p>{content}</p>
{status === 'read' && <span>✓ Read</span>}
</div>
);
}
// 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>
);
}
// Example: Setting up compression with a WebSocket server
const WebSocket = require('ws');
const wss = new WebSocket.Server({
perMessageDeflate: {
zlibDeflateOptions: {
// Add compression options here
}
}
});
// 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');
}
});
}
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.
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.
While my-real-time-app
has come a long way, the potential for future enhancements remains vast:
First off, a massive thank you for journeying with me through this intricate maze of the 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!
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!
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.