Development••14 minutes read
![]()
Stepan Parunashvili
Guest Author
From 😅 to 🙌 in milliseconds—build a full-stack emoji chat app with Expo + Instant. Real-time, offline, and no backend blues.

This is a guest post by Stepan Parunashvili of Instant. Instant makes you more productive by giving your frontend a real-time database. By the end of the post you’ll build a full-stack, multiplayer chat app!
...
Expo makes frontends easier to build. Write once and you get app on iOS, Android and web. Make a change and your app hot reloads, no need to wait for binaries to build. Fix a bug and you can send patches directly to your users, you’re not stuck in app store submissions. And when it’s time to push to Testflight, don’t worry about provisioning profiles or XCode environments, run npx testflight and you’re ready to go.
A development experience like that can change both how you work and what you can build. When every save means you wait a few seconds, programming feels like drudgery. Get that down to milliseconds and programming feels like flow. When introducing a bug means hours of downtime, you’re in for a stressful release process. Get that down to live patches and you ship quickly again.
Write with flow, ship quickly, get faster feedback, and now you’re building a different app in a different way. That’s the story of frontend. But what happens when you get to the backend?
How do you save your user’s data? Suddenly your work is full of schleps again. Let’s remember what we need to do.
Most of the time you start with a server. You spin up a database and a backend to host your endpoints (But don’t forget Redis and a good ORM). And when you set up your endpoints, return as little data as possible or you’ll send unnecessarily large payloads to your users. Remember folks may still use old clients, so every backend update has to be backwards-compatible:
And we’re only getting started. Now you need to connect your backend to your app. First you coordinate calls to different endpoints. (Since we returned as little data as possible, we often have to make multiple fetches to paint a single screen). Then you normalize those responses and stick them into stores. Then you write selectors to get just what each screens needs. Then to handle mutations. For every save, you write your change to the store, then send it to the server, and then handle rollback:
Do this and you have a fully-fledged app. But you can’t stop there. When you build mobile apps offline mode is a real concern. Time to dust off expo-sqlite and cache your data. Make sure to keep your mutations saved too, so you can send them to the server when you’re back online. Then what if you want multiplayer? Time to add stateful servers and to worry about broadcasting changes:
Oi. When lines in a diagram cross like this, you know you’re in for trouble. And to make things worse, every new feature requires the same kind of song and dance: add models, write endpoints, stores, selectors, and finally the UI.
That’s a lot of steps to go through to save your user’s data. Does it have to be this way? In 2021, we realized that most of these schleps were actually database problems in disguise.
Maybe a database-looking solution could solve them. For example, if you had a database inside your Expo app, you wouldn't need to think about stores, selectors, endpoints, or local caches: you’d just write queries. If these queries were multiplayer by default, you wouldn't have to worry about stateful servers. And if your database supported rollback, you'd get optimistic updates for free.
Well, we tried to build a database-looking solution, and 2 years later released Instant. Instant makes you productive by giving your frontend a real-time database.
You write queries that stay in sync and work offline. Every save updates immediately, and it comes with built-in auth and permissions. How does it look? Here’s a snippet that connects to your backend, syncs data and lets you change it too:
import { init, tx, id } from "@instantdb/react-native";
const db = init({
appId: process.env.EXPO_PUBLIC_INSTANT_APP_ID,
});
function Chat() {
const { isLoading, error, data } = db.useQuery({
messages: {},
});
const addMessage = (message) => {
db.transact(tx.messages[id()].update(message));
};
return <UI data={data} onAdd={addMessage} />;
}
The only missing piece is in this code snippet is the UI component, everything else comes as you see it.
If you squint, useQuery and db.transact start to resemble a useState hook. For example, if you just wanted to save messages with useState, you’d write:
function Chat() {
const [messages, setMessages] = useState([]);
}
Well, replace messages with db.useQuery, and setMessages with db.transact, and you have our code snippet:
function Chat() {
const { isLoading, error, data } = db.useQuery({
messages: {},
});
const addMessage = (message) => {
db.transact(tx.messages[id()].update(message));
};
}
A few extra lines but now your data is persisted, works offline, and is multiplayer! Pretty cool.
Is it really so simple though? Well, let’s put Expo and Instant to a bit of a test right inside this essay, and build an app. I suggest…EmojiChat!
Here’s the idea. WhatsApp is great, but why the need for a text box? Sometimes, all you need to communicate is a few emojis. That’s where Emoji Chat comes in.
You can use EmojiChat to express your deepest thoughts with a few well-curated emojis: “🔥”, “❤️”, “👻”, “🙌”.
Instead of writing “I miss you, want to meet up?”, you could simply send “👻❤️🔥🙌”. Now that’s expression!
Okay, let’s build EmojiChat. Here’s what we’ll do: we’ll start with a basic Expo template and hack our app out from there. We’ll take it step by step:
- We’ll scaffold our UI.
- We’ll create our Instant backend.
- We’ll add our
messagestable. - Then, we’ll connect our UI to our backend
Once that’s done, we’ll have real-time chat! Users will be able to send emojis and it will show up everywhere. We won’t stop there though. We can’t forget about permissions:
- In the end we’ll add permissions to lock down who can change what.
Once we do that we can celebrate a bit and talk about what else we can build. Okay, let’s get hacking.
First things first, time for some fun with Expo : )
create-expo
We can use our trusty npx create-expo-app and pick a starter. I really like the with-tailwindcss template:
- npx create-expo-app -e with-tailwindcss emoji-chat
- cd emoji-chat
# 🔥 and we're off to the races
- npm run start
Now we have a repo with expo-router and Nativewind. We can replace src/app/index.tsx with something we’ll start to evolve:
import React from "react";
import { Text } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function Page() {
return (
<SafeAreaView className="flex flex-1 items-center justify-center">
<Text className="text-5xl text-blue-500">Hello, world!</Text>
</SafeAreaView>
);
}
Take a look at your Expo Go, and you’ll see a hello world!
Okay, next step is the core of our UI: our button.
A slick button
Normally tutorials only show you a few minimal components. But for EmojiChat we need to go deeper — at least for our Emoji Button. Our button is the primary medium for our users to express themselves, so it should feel slick. Here’s my suggestion:
When we press our button, it’s going to feel like it sinks inside the screen.
EmojiPill
To start, we’ll create a little EmojiPill component. This is our rounded pill that we can place emojis in. Here’s src/components/emoji.tsx:
import { Text, View } from "react-native";
export const EMOJIS = ["🔥", "❤️", "👻", "🙌"] as const;
export type Emoji = (typeof EMOJIS)[number];
const EMOJI_SIZE = 54;
export function EmojiPill({ label }: { label: Emoji }) {
return (
<View
className="bg-gray-200 rounded-xl flex items-center justify-center"
style={{
width: EMOJI_SIZE,
height: EMOJI_SIZE,
}}
>
<Text className="text-2xl">{label}</Text>
</View>
);
}
Now to use it in our index.tsx file:
import React from "react";
import { EmojiPill } from "@/components/emoji";
import { SafeAreaView } from "react-native-safe-area-context";
export default function Page() {
return (
<SafeAreaView className="flex flex-1 items-center justify-center">
<EmojiPill label="👻" />
</SafeAreaView>
);
}
And we’ve got a cool pill!
Time to turn this pill into a button.
ScaleButton
React Native’s animated library is going to come in real handy for us. Let’s head on over to src/components/ScaleButton.tsx and sketch it out:
import React, { useRef } from "react";
import { Animated, Pressable, PressableProps } from "react-native";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function ScaleButton(props: PressableProps) {
const scale = useRef(new Animated.Value(1)).current;
return (
<AnimatedPressable
onPressIn={() => {
Animated.spring(scale, {
toValue: 0.9,
useNativeDriver: true,
}).start();
}}
onPressOut={() => {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
}).start();
}}
style={{
transform: [{ scale }],
}}
{...props}
/>
);
}
What have we done here? We’ve created a scale animated value. By default, the value is 1 , which means our button is going to be the same size. As we press in, we change the scale to 0.9, causing our button to get smaller and feel like it “sinked”! As we press out, the button goes back to 1 (normal) again.
Let’s try it out:
import React from "react";
import { EmojiPill } from "@/components/emoji";
import { SafeAreaView } from "react-native-safe-area-context";
import { ScaleButton } from "@/components/ScaleButton";
export default function Page() {
return (
<SafeAreaView className="flex flex-1 items-center justify-center">
<ScaleButton>
<EmojiPill label="👻" />
</ScaleButton>
</SafeAreaView>
);
}
And now we have our sinking button!
Now that we have our button, it’s time to build out a screen! Here’s what we want to do:
We’ll have a little bar at the bottom with our emoji buttons. Then we’ll have a scrolled container of all our messages. The tricky part in our UI is in the alignment: our emojis should line up both horizontally and vertically. For example, the “ghost” emoji should stay in the “ghost” row, the “heart” emoji in in the “heart” row, and so on.
The simplest way to get this done is use fixed widths.
emojiOffset
If we know the size of our emoji pills and the gaps between them, we can figure out where to place a particular emoji. Here’s emojiOffset to do just that:
export const EMOJIS = ["🔥", "❤️", "👻", "🙌"] as const;
export type Emoji = (typeof EMOJIS)[number];
export const EMOJI_SIZE = 54;
export const EMOJI_GAP = 16;
export function emojiOffset(emoji: Emoji) {
const idx = EMOJIS.indexOf(emoji);
return idx * EMOJI_SIZE + idx * EMOJI_GAP;
}
To put it into action, this is how emojiOffset would figure out the placement for “🙌”:
Pretttty cool!
EmojiBar
Our phones can get large and FlexBox could stretch out the gaps for our emoji buttons. To make sure we place our emojis correctly, we can write up an EmojiBar, a fixed-width wrapper around our emojis:
export const BAR_WIDTH =
EMOJIS.length * EMOJI_SIZE + (EMOJIS.length - 1) * EMOJI_GAP;
export function EmojiBar({ children }: React.PropsWithChildren) {
return (
<View className="flex-row justify-center">
<View
className="flex-row"
style={{
width: BAR_WIDTH,
}}
>
{children}
</View>
</View>
);
}
Put it all together, and our emoji.tsx file looks like this:
import React from "react";
import { View, Text } from "react-native";
export const EMOJIS = ["🔥", "❤️", "👻", "🙌"] as const;
export type Emoji = (typeof EMOJIS)[number];
export const EMOJI_SIZE = 54;
export const EMOJI_GAP = 16;
export function emojiOffset(emoji: Emoji) {
const idx = EMOJIS.indexOf(emoji);
return idx * EMOJI_SIZE + idx * EMOJI_GAP;
}
export const BAR_WIDTH =
EMOJIS.length * EMOJI_SIZE + (EMOJIS.length - 1) * EMOJI_GAP;
export function EmojiBar({ children }: React.PropsWithChildren) {
return (
<View className="flex-row justify-center">
<View
className="flex-row"
style={{
width: BAR_WIDTH,
}}
>
{children}
</View>
</View>
);
}
export function EmojiPill({ label }: { label: Emoji }) {
return (
<View
className="bg-gray-200 rounded-xl flex items-center justify-center"
style={{
width: EMOJI_SIZE,
height: EMOJI_SIZE,
}}
>
<Text className="text-2xl">{label}</Text>
</View>
);
}
Building the screen
Okay, we have all the components we need for our screen. Let’s use them. Here it goes:
import {
EMOJI_GAP,
EmojiBar,
EmojiPill,
EMOJIS,
Emoji,
emojiOffset,
} from "@/components/emoji";
import React, { useState } from "react";
import { SafeAreaView, ScrollView, View, Text } from "react-native";
import { ScaleButton } from "../components/ScaleButton";
type Message = {
id: string;
body: Emoji;
createdAt: number;
};
export default function Page() {
const [messages, setMessages] = useState<Message[]>([]);
return (
<SafeAreaView className="flex-1 m-4">
<ScrollView className="py-4">
{messages.toReversed().map((msg) => {
const marginLeft = emojiOffset(msg.body);
return (
<View className="mb-4" key={msg.id}>
<EmojiBar>
<View style={{ marginLeft }}>
<EmojiPill label={msg.body} />
</View>
</EmojiBar>
</View>
);
})}
</ScrollView>
<EmojiBar>
{EMOJIS.map((emoji, idx) => (
<ScaleButton
key={emoji}
onPress={() => {
setMessages((prev) => {
return [
...prev,
{
id: prev.length.toString(),
body: emoji,
createdAt: Date.now(),
},
];
});
}}
>
<View style={{ marginLeft: idx == 0 ? 0 : EMOJI_GAP }}>
<EmojiPill label={emoji} />
</View>
</ScaleButton>
))}
</EmojiBar>
</SafeAreaView>
);
}
Load our page, and we have our UI!
Nice! Let’s look at what we just did.
We added a Message type:
type Message = {
id: string;
body: Emoji;
createdAt: number;
};
Then we created a useState hook:
export default function Page() {
const [messages, setMessages] = useState<Message[]>([]);
}
We render our messages into a ScrollView, and use emojiOffset to position each emoji:
export default function Page() {
return (
<ScrollView className="py-4">
{messages.toReversed().map((msg) => {
const marginLeft = emojiOffset(msg.body);
return (
<View className="mb-4" key={msg.id}>
<EmojiBar>
<View style={{ marginLeft }}>
<EmojiPill label={msg.body} />
</View>
</EmojiBar>
</View>
);
})}
</ScrollView>
)
}
Then we built out our buttons. Every time we press an emoji, we setMessages and add a new message:
export default function Page() {
return (
<EmojiBar>
{EMOJIS.map((emoji, idx) => (
<ScaleButton
key={emoji}
onPress={() => {
setMessages((prev) => {
return [
...prev,
{
id: prev.length.toString(),
body: emoji,
createdAt: Date.now(),
},
];
});
}}
>
<View style={{ marginLeft: idx == 0 ? 0 : EMOJI_GAP }}>
<EmojiPill label={emoji} />
</View>
</ScaleButton>
))}
</EmojiBar>
)
}
With that, we have our screen!
Okay, now to create our backend. At Instant we call each backend an “app”. We can create an app right inside our terminal with npx instant-cli.
login
To start we’ll log into Instant:
Terminal
- Let's log you in!
? This will open instantdb.com in your browser, OK to proceed? yes
Waiting for authentication...Successfully logged in as stopa@instantdb.com!
init
Now that you’re logged in, you can create an app:
Terminal
Checking for an Instant SDK...Couldn't find an Instant SDK in your package.json, let's install one!? Which package would you like to use? @instantdb/react-native✔ Installed @instantdb/react-native using npm.? What would you like to do? Create a new app? What would you like to call it? emoji-chatLooks like you don't have a `.env` file yet.If we set `EXPO_PUBLIC_INSTANT_APP_ID`, we can remember the app that you chose for all future commands.? Want us to create this env file for you? yesCreated `.env` file!Pulling schema...✅ Wrote schema to instant.schema.tsPulling perms...✅ Wrote permissions to instant.perms.ts
Woohoo. This just:
- Installed
@instantdb/react-native, - Created a schema and a perms file.
- Added
EXPO_PUBLIC_INSTANT_APP_IDto your env with your band new app!
Prettyy cool. If you look at your Instant Dashboard, you’ll see your app there 🙂
Let’s take a peak at the instant.schema.ts file that instant-cli created:
import { i } from "@instantdb/react-native";
const schema = i.schema({
entities: {
$files: i.entity({
path: i.string().unique(),
url: i.any().optional(),
}),
$users: i.entity({
email: i.string().unique(),
}),
},
links: {},
rooms: {},
});
type AppSchema = typeof schema;
export type { AppSchema };
export default schema;
Aptly titled, you can manage your app’s schema in instant.schema.ts.
You’ll notice $users and $files. These are built-in entities in Instant: when a user signs up we’ll create a row in $users. When you upload a file we’ll create a row in $files. So what should happen when someone wants to send an emoji?
The messages entity
Well, let’s create a row in messages. To do that, we can need to add a messages entity to the schema:
import { i } from "@instantdb/react-native";
import { Emoji } from "@/components/emoji";
const schema = i.schema({
entities: {
messages: i.entity({
body: i.string<Emoji>(),
createdAt: i.date(),
}),
},
links: {},
rooms: {},
});
export default schema;
This looks good. Now run push, and see your backend update!
Checking for an Instant SDK...
Found @instantdb/react-native in your package.json.
Found EXPO_PUBLIC_INSTANT_APP_ID: d3aecdf7-e27f-4fc7-85ef-c7c772f85f1e
Planning schema...
The following changes will be applied to your production schema:
ADD ENTITY messages.id
ADD ATTR messages.body :: unique=false, indexed=false
ADD ATTR messages.createdAt :: unique=false, indexed=false
? OK to proceed? yes
Schema updated!
Planning perms...
No perms changes detected. Skipping.
If you check the Explorer in your Instant Dashboard, you’ll see messages!
Okay, we have a backend, now let’s connect our frontend! Here’s what we need to do.
db.ts
Head on over and create a src/db.ts file. This is where we’ll connect to Instant:
import { init } from "@instantdb/react-native";
import schema from "instant.schema";
const db = init({
appId: process.env.EXPO_PUBLIC_INSTANT_APP_ID,
schema: schema,
});
export default db;
Aand that’s it. We can use db to make queries and transactions. When we do, our data will persist, queries will work offline, and everything will be multiplayer!
Update index.ts
Let’s put db to the test. Go ahead and update src/app/index.tsx. First, we’ll import our db and Instant’s handy id function:
import db from "@/db";
import { id } from "@instantdb/react-native";
We can replace our useState with a query for messages:
export default function Page() {
- const [messages, setMessages] = useState<Message[]>([]);
+ const { data } = db.useQuery({ messages: {} });
+ const messages = data?.messages || [];
And we can use db.transact to create messages:
- setMessages((prev) => {
- return [
- ...prev,
- {
- id: prev.length.toString(),
- body: emoji,
- createdAt: Date.now(),
- },
- ];
- });
+ db.transact(
+ db.tx.messages[id()].update({
+ body: emoji,
+ createdAt: Date.now(),
+ })
+ );
Put it all together, and here’s how src/app/index.tsx looks:
import {
EMOJI_GAP,
EmojiBar,
EmojiPill,
EMOJIS,
emojiOffset,
} from "@/components/emoji";
import { SafeAreaView, ScrollView, View } from "react-native";
import { ScaleButton } from "../components/ScaleButton";
import db from "@/db";
import { id } from "@instantdb/react-native";
export default function Page() {
const { data } = db.useQuery({ messages: {} });
const messages = data?.messages || [];
return (
<SafeAreaView className="flex-1 m-4">
<ScrollView className="py-4">
{messages.toReversed().map((msg) => {
const marginLeft = emojiOffset(msg.body);
return (
<View className="mb-4" key={msg.id}>
<EmojiBar>
<View style={{ marginLeft }}>
<EmojiPill label={msg.body} />
</View>
</EmojiBar>
</View>
);
})}
</ScrollView>
<EmojiBar>
{EMOJIS.map((emoji, idx) => (
<ScaleButton
key={emoji}
onPress={() => {
db.transact(
db.tx.messages[id()].update({
body: emoji,
createdAt: Date.now(),
})
);
}}
>
<View style={{ marginLeft: idx == 0 ? 0 : EMOJI_GAP }}>
<EmojiPill label={emoji} />
</View>
</ScaleButton>
))}
</EmojiBar>
</SafeAreaView>
);
}
Load Expo Go, and you have a multiplayer EmojiChat! If you look at your Explorer, you’ll see emojis update in real-time too:
Wow. Let’s pause for a moment.
You now have an app with a full backend!
As we’d say in EmojiChat, 👻👻🔥🙌🙌
You may think to yourself, we’ve saved data, but what about permissions? What if a hacker sniffs network traffic and saves a “💩” emoji? Or what if they try to delete all messages?
This is where permissions come in. Head on over and take a look at instant.perms.ts:
import type { InstantRules } from "@instantdb/react-native";
const rules = {
} satisfies InstantRules;
export default rules;
Instant comes with permissions baked in. You can define permission rules that run for every object returned in a query or changed in a transaction.
Disable deletes
For example, to disable deletes, we could write:
import type { InstantRules } from "@instantdb/react-native";
const rules = {
messages: {
allow: {
delete: "false",
},
},
} satisfies InstantRules;
export default rules;
This defaults the delete rule for messages to false. Once we push it:
Checking for an Instant SDK...
Found @instantdb/react-native in your package.json.
Found EXPO_PUBLIC_INSTANT_APP_ID: d3aecdf7-e27f-4fc7-85ef-c7c772f85f1e
Planning schema...
No schema changes detected. Skipping.
Planning perms...
The following changes will be applied to your perms:
{
+ messages: {
+ allow: {
+ delete: "false"
+ }
+ }
}
? OK to proceed? yes
Permissions updated!
If any nefarious hacker tries to delete an object, they’re out luck!
Locking down emojis
Let’s get to the 💩 problem. What if someone tries to hack the system and send us a “💩”? We can add a rule for that too:
import type { InstantRules } from "@instantdb/react-native";
const rules = {
messages: {
allow: {
delete: "false",
update: "false",
create: 'data.body in ["🔥", "❤️", "👻", "🙌"]',
},
},
} satisfies InstantRules;
export default rules;
This makes sure that you can only create a message with a valid emoji, and you can’t change an emoji after you create it. If we push it:
Checking for an Instant SDK...
Found @instantdb/react-native in your package.json.
Found EXPO_PUBLIC_INSTANT_APP_ID: d3aecdf7-e27f-4fc7-85ef-c7c772f85f1e
Planning schema...
No schema changes detected. Skipping.
Planning perms...
The following changes will be applied to your perms:
{
messages: {
allow: {
+ create: "data.body in [\\"🔥\\", \\"❤️\\", \\"👻\\", \\"🙌\\"]"
+ update: "false"
}
}
}
? OK to proceed? yes
Permissions updated!
Locking down everything
We’ve anticipated a few things a hacker could change, but what about stuff we haven’t thought about? To be on the safer side, we can lock down the database completely, so everything that we haven’t been explicit about gets denied:
import type { InstantRules } from "@instantdb/react-native";
const rules = {
$default: {
allow: {
$default: "false",
},
},
messages: {
allow: {
view: "true",
delete: "false",
update: "false",
create: 'data.body in ["🔥", "❤️", "👻", "🙌"]',
},
},
} satisfies InstantRules;
export default rules;
The $default rule says, if there’s some object that we haven’t made a rule for, we’ll make sure it’s false. There’s so much more you can do, just check out the permissions docs.
Heck yeah! We’ve just created a chat app for iOS, Android, and web. It works offline, is multiplayer by default and comes with permissions. This is what happens when you use tools that help you get into flow and blast through schleps.
You now have a pretty cool app, but there’s more we could do!
Sometimes emojis don’t quite cut it. What if we let you express yourself with your thumbs? We could add finger kisses:
And right now we just have one giant arena for all conversations. What if we added Auth and channels? You can start thinking about how to do this by reading the Instant docs (one, two), but we hope to continue the tutorial another day too 🙂.