Table of contents
Table of Contents
1. Table of contents
2. Introduction
3. Getting started
4. Creating a project
5. Starting our app
6. The chat collection
7. The chat module
8. The front-end
9. The main page
10. The chat page
11. Deployment
12. Conclusion
Introduction
When creating real-time apps such as chats or dashboards, we usually consider Node.js and its many frameworks. In this tutorial, I’ll show you the most productive to create real-time apps with Zod and TypeScript.
Most of you might think I'll talk about a new JavaScript framework, but if you have been around the JavaScript community long enough, you may have heard of Meteor.js.
In this tutorial, I'll show this new take on creating Meteor applications and why it is so productive. At the end, you will have a working real-time chat app that uses Zod for its validations and full-blown TypeScript support for your APIs
Getting started
Ensure you have meteor
installed on your machine.
In case you do not have it installed, you can run the following command:
npx meteor
This will install the Meteor CLI tool on your machine.
Creating a project
meteor create
It will prompt you for the name of your new app and which skeleton you want to use. I chose super-chat
as the name of our app and the TypeScript starter scaffold.
Then, we can enter the directory and start installing the packages needed to start our guide:
cd super-chat && meteor npm i meteor-rpc @tanstack/react-query zod react-router-dom@6
The packages that we are installing are a requisite for meteor-rpc
to work:
-
zod
: for runtime validation -
@tanstack/react-query
: for querying data in the client -
meteor-rpc
: to abstract Meteor methods and Publications in a nice and modern way.
We are also installing react-router-dom
for routing in our app.
Setting up our React app
Before continuing, we need to set up our React app. You can follow react-query
and react-router
guides or you can paste this snippet in your imports/ui/App.tsx
:
import React, { Suspense } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
const queryClient = new QueryClient();
export const App = () => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route
index
element={
<div>
<Suspense fallback={<h1>Loading...</h1>}>
Hello from Super-Chat!
</Suspense>
</div>
}
/>
<Route path="/chat/:chatId" element={<div>Chat!</div>} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
Then, we will delete Hello.tsx
and Info.tsx
as we will not need them.
Starting our app
The first thing we need to do is remove all code from main.ts
and then create our server entry point with the createModule
from meteor-rpc
; at this point, your main.ts
file should look like this:
import { createModule } from "meteor-rpc";
const server = createModule().build();
export type Server = typeof server;
The chat collection
We must first have our ChatCollection
to store our messages and chat groups. To achieve this, we will create in server/chat/model.ts
our ChatCollection
. The code should look like this:
import { Mongo } from "meteor/mongo";
export interface Message {
text: string;
who: string;
createdAt: Date;
}
export interface Chat {
_id?: string;
messages: Message[];
createdAt: Date;
}
export const ChatCollection = new Mongo.Collection<Chat>("chat");
Our app will not be very complex, and this will be our only collection. We will store every message from every chat, containing who
sent it, text
it sent, and it was created.
The chat module
Now, we must consider what methods or functions our chat will need. Every chat app has these features:
- you can create a chat room;
- you can send a message to a chat room;
- you can see all chat rooms in real-time;
- you can see a conversation in real-time;
With that in mind, use the createModule
from meteor-rpc
and the module.addPublication
for the real-time features and module.addMethod
for the remote calls.
I'll create a TypeScript file in server/chat/module.ts
, which we will use as our entry point for our functions.
import { createModule } from "meteor-rpc";
import { ChatCollection } from "./model";
import { z } from "zod";
export const ChatModule =
createModule("chat")
.addMethod("createRoom", z.void(), async () => {
return ChatCollection.insertAsync({ createdAt: new Date(), messages: [] });
})
.addMethod(
"sendMessage",
z.object({ chatId: z.string(), message: z.string(), user: z.string() }),
async ({ chatId, message, user }) => {
return ChatCollection.updateAsync(
{ _id: chatId },
{
$push: {
messages: { text: message, who: user, createdAt: new Date() },
},
}
);
}
)
.addPublication("room", z.string(), (chatId) => {
return ChatCollection.find({ _id: chatId });
})
.addPublication("rooms", z.void(), () => {
return ChatCollection.find();
})
.buildSubmodule(); // this is very important, don't forget to call this method
Voilá! We have almost everything ready on our server. We now must register this module in our server/main.ts
, and then we can move to the client side.
On server/main.ts
will look like this when we register our chat module:
import { createModule } from "meteor-rpc";
import { ChatModule } from "./chat/module";
const server = createModule().addSubmodule(ChatModule).build();
export type Server = typeof server;
And with that, we have our backend working!
The front-end
Our app currently only has the imports/ui/App.tsx
and has no server-side interaction. To change that, we should have our API
entry point to import our server API calls. In my app, I will call this entry point client.ts,
and it will be located in our imports/api/client.ts
. Also, we should clean up our API folder while there, removing the links.ts
file. Here is what my server.ts
looks like:
import { createClient } from "meteor-rpc";
import type { Server } from "/server/main";
export const client = createClient<Server>();
It is essential to import only the type Server
from the server; if you try importing anything else, our build will fail because Meteor protects us from importing server files and possibly leaking secret files. Types are fine because they are removed at build time.
This variable server
within the client scope is what we will use to interact with our server.
The Main page
The first thing we should create in our front end is the main page, where it should be placed the button to create a new chat room and the links for all available chat rooms; with those requirements in mind, we should make a Main.tsx
file in our imports/ui
directory, this file should look like something like this:
import React from "react";
import { client } from "../api/client";
import { useNavigate } from "react-router-dom";
export const Main = () => {
const navigate = useNavigate();
const { data: rooms } = client.chat.rooms.usePublication();
const createChatRoom = async () => {
const room = await client.chat.createTheRoom();
navigate(`/chat/${room}`);
};
return (
<div>
<h1>Welcome to chat!</h1>
<button onClick={createChatRoom}>
Click me to generate and go to a chat room
</button>
<br />
<span>Or select a chat from the list below to go to that chat room</span>
<h3>Chats:</h3>
{rooms.length === 0 && (
<span>We do not have rooms yet, so why don't you create one?</span>
)}
<ul>
{rooms?.map((room) => (
<li key={room._id}>
<a href={`/chat/${room._id}`}>Chat room {room._id}</a>
</li>
))}
</ul>
</div>
);
};
And we should update our App.tsx
to include our Main.tsx
page:
// ....
<BrowserRouter>
<Routes>
<Route
index
element={
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<Main />
</Suspense>
</div>
}
/>
// ....
Our app should look something like this:
However, our chat page still looks blank when we click to join a room or create a new one. We should solve that!
The chat page
Firstly, we should create a Chat.tsx
in imports/ui
; in this component, we should use our chatId
parameter to pass to the server so that it knows in which room it should add the message and also which room are we listing for updates.
Also, we should create and validate our input fields. In the end, we should have a file that looks something like this:
import React, { useReducer } from "react";
import { client } from "../api/client";
import { useNavigate, useParams } from "react-router-dom";
export const Chat = () => {
let { chatId } = useParams();
const navigate = useNavigate();
const {
data: [chatRoom],
} = client.chat.room.usePublication(chatId as string);
const [state, dispatch] = useReducer(
(
state: { who: string; message: string },
action: { type: string; value: string }
) => {
switch (action.type) {
case "who":
return { ...state, who: action.value };
case "message":
return { ...state, message: action.value };
default:
return state;
}
},
{ who: "", message: "" }
);
const sendMessage = async () => {
if (state.who === "" || state.message === "") {
alert("Please fill in both fields");
return;
}
await client.chat.sendMessage({
chatId: chatId as string,
message: state.message,
user: state.who,
});
dispatch({ type: "message", value: "" });
};
return (
<div>
<button onClick={() => navigate("/")}>Go back to main page</button>
<h2>Chat in room: {chatId}</h2>
<div>
<label htmlFor="who">Who:</label>
<input
id="who"
onChange={(e) => dispatch({ type: "who", value: e.target.value })}
value={state.who}
/>
</div>
<div>
<label htmlFor="message">Message:</label>
<input
id="message"
onChange={(e) => dispatch({ type: "message", value: e.target.value })}
value={state.message}
/>
</div>
<button onClick={sendMessage}>Send message</button>
<h3>Messages:</h3>
{chatRoom.messages.length === 0 && <span>No messages yet</span>}
<ul>
{chatRoom.messages.map((message, i) => (
<li key={i}>
<span>{message.who}:</span> {message.text}
</li>
))}
</ul>
</div>
);
};
It looks big, but bear with me. On this component, we listen for every message within a conversation based on its chatId
. We can call the sendMessage
method defined in the server. Also, we have our validation for when sending messages.
Do not forget to add this component to our
App.tsx
router file.
Let's take a look at it in action:
Deployment
Now that we have our app done, we can deploy it. All you have to do is run this command, and you will have it deployed in Galaxy Cloud for free with an included MongoDB:
meteor deploy <your-app-name>.meteorapp.com --free --mongo
Conclusion
One of the best features that meteor-rpc
brings to our Meteor apps is the structured object to call from; in other words, every method that is defined and has IntelliSense; if you have not tested it yourself, you can see it in this video:
You can check the complete source code here, and if you have any issues with meteor-rpc
package, please let us know either in the repo of the package or get in contact with me via my X account
Top comments (0)