DEV Community

Cover image for The fastest way of creating a real-time app with Zod and TypeScript
Gabriel Grubba for Meteor

Posted on

The fastest way of creating a real-time app with Zod and TypeScript

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.

Meteor meme

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
Enter fullscreen mode Exit fullscreen mode

This will install the Meteor CLI tool on your machine.

Creating a project

meteor create
Enter fullscreen mode Exit fullscreen mode

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.

meteor cli creating app

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
Enter fullscreen mode Exit fullscreen mode

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>
);

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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>
          }
        />
// ....

Enter fullscreen mode Exit fullscreen mode

Our app should look something like this:

main page

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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:

Chat 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
Enter fullscreen mode Exit fullscreen mode

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:

intellisense in action

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)