You can build a database-powered app with end-to-end type safety and real-time updates without needing to learn how to manage databases. This tutorial will show you how.

Prefer to watch instead of reading? I’ve got you!

If you learn better by watching and listening, I’ve got a video version of this tutorial up on YouTube. (Don’t forget to subscribe if it’s useful!)

For frontend devs, databases can be intimidating

If you’re a web developer like me, you probably have exciting ideas for new apps all the time.

Most of my app ideas hit the same challenge: building them would require setting up a database.

And I don’t know you, but when I start thinking about all the work that goes into setting up and managing a database, I always tend to land on the same solution: I convince myself that my app idea wasn’t that good, and I give up.

At least that’s how I used to feel. These days, there’s a new wave of innovation happening in the database space that’s flattening the learning curve for web developers who want to add data to their apps, even if they’d describe themselves as a frontend developer or — as Brad Frost once said — a “front-of-the-frontend” developer.

Build a data-powered app for reacting to dogs

This tutorial will step through adding a Convex database to a web app and using it to store information about dogs and how people have reacted to those dogs. It will have a home page where people can react, and a stats view for showing aggregate data about reactions for each dog.

The app is built using React started from the Vite React TypeScript starter. (npm create vite --template react-ts)

Clone the repo and get local development running

For this app, we’ll start with the frontend already built out and styled. Clone the start branch of the tutorial repo, install dependencies, and start the dev server:

Terminal window
# clone the start branch
git clone git@github.com:learnwithjason/dashboard-convex-react.git -b start
# move into the project
cd dashboard-convex-react/
# install dependencies
npm install
# start the local dev server
npm run dev

This is a Vite-powered project, so the local dev server starts at http://localhost:5173. Open it in your browser and you’ll see the app:

Screenshot of the demo app. It shows a grid of cute dog photos with reaction
buttons displayed on each
one.

Hot or Not for dogs. My best app idea yet!

The home page shows a list of pups, each with a few buttons to let us react. If we toggle to the stats page, we can see the aggregate of how many times each reaction has been hit for each pup.

Right now all the data is hard-coded. We’re going to use Convex to store both our pup data and reaction data.

Install and start Convex in the web app

Keep the local dev server running in your terminal. Open up a second terminal and install the convex package.

Terminal window
npm i convex

Next, start Convex in dev mode:

Terminal window
npx convex dev

This will take you through the process of setting up a Convex account, then ask you for the name of your project and generate .env files for local and production development.

The dev command will stay running while we work to keep our local app connected to the Convex datastore.

Define a type-safe database schema

Because we want type safety and autocomplete, our first step is to define a schema. The convex package provides the helpers we need to define the overall schema, data tables, and the field types within the table.

Create a new file at convex/schema.ts:

import { defineSchema, defineTable, s } from 'convex/schema';
export default defineSchema({
pups: defineTable({
name: s.string(),
photo: s.string(),
}),
reactions: defineTable({
pup: s.id('pups'),
type: s.union(
s.literal('heart'),
s.literal('cute'),
s.literal('star_eyes')
),
}),
});

(see this change on GitHub)

The Convex CLI will automatically update once this file is saved, configuring everything on the backend for us so we don’t need to deal with dashboards or additional config.

Wrap the React app with Convex provider

To make sure our app has access to Convex data, wrap it in the Convex provider component by making the following changes in src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
import { Toggle } from './components/toggle';
import './styles/global.css';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ConvexProvider client={convex}>
<Toggle />
</ConvexProvider>
<footer>
a <a href="https://www.learnwithjason.dev/">Learn With Jason</a> creation
·{' '}
<a href="https://github.com/learnwithjason/dashboard-convex-react">
source code
</a>
</footer>
</React.StrictMode>,
);

(see this change on GitHub)

Creating a client lets the provider know which Convex project we’re working with, and the provider makes sure we can run queries and mutations from any component that’s rendered inside the provider.

The environment variable (import.meta.env.VITE_CONVEX_URL) was automatically added to .env.local when we started Convex.

Create and display pup entries

Next, we can define how we create and read data to the tables we just defined. Let’s start with the pups.

Create a new file at convex/pups.ts:

import { mutation, query } from './_generated/server';
export const add = mutation(async ({ db }, name, photo) => {
await db.insert('pups', {
name,
photo,
});
});
export const get = query(async ({ db }) => {
return await db.query('pups').collect();
});

(see this change on GitHub)

Make sure to type out the db.insert() part of this code in your editor. It’s extremely cool that the function autocompletes with the names of our two schema tables. Plus, once we select a table, the second argument to db.insert() knows what the fields are.

This is a killer feature of Convex: the database is automatically type safe and we haven’t defined any types ourselves, let alone had to mess with configuration files. It Just Works™.

Next up, let’s modify the home page to add pups to the database and read them out on screen.

Open src/components/list.tsx and make the following changes:

import { useQuery, useMutation } from '../../convex/_generated/react';
import { seedPups, reactionTypes } from '../util/helpers';
export function List() {
const pups = seedPups;
const pups = useQuery('pups:get');
const addPup = (..._args: any) => {};
const addReaction = (..._args: any) => {};
if (!pups) {
return null;
}
if (Array.isArray(pups) && pups.length === 0) {
seedPups.forEach((pup) => {
addPup(pup.name, pup.photo);
});
}
return (
<div className="pups">
{pups?.map((pup) => {
return (
<div className="pup" key={pup._id.toString()}>
<h2>{pup.name}</h2>
<img src={pup.photo} alt={pup.name} />
<div className="reactions">
{reactionTypes.map((reaction) => {
return (
<button
onClick={() => addReaction(pup._id, reaction.name)}
key={reaction.label + pup._id}
>
<span role="img" aria-label={reaction.name}>
{reaction.label}
</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}

(see this change on GitHub)

Did you see useQuery() autocomplete with our pup query? Again: this Just Works™. Hover over pups and you’ll see what data each pup includes.

Hovering over the pups variable in VS Code, showing the popover card with
type
data

Autocomplete for nothin' and your types for free.

Right now, the home page will be empty since there aren’t any pups in the database yet. Let’s fix that.

Update the addPup() function to use a Convex mutation by making the following change:

import { useQuery, useMutation } from '../../convex/_generated/react';
import { seedPups, reactionTypes } from '../util/helpers';
export function List() {
const pups = useQuery('pups:get');
const addPup = (..._args: any) => {};
const addPup = useMutation('pups:add');
const addReaction = (..._args: any) => {};
if (!pups) {
return null;
}
if (Array.isArray(pups) && pups.length === 0) {
seedPups.forEach((pup) => {
addPup(pup.name, pup.photo);
});
}
return (
<div className="pups">
{/* ...unchanged... */}
</div>
);
}

(see this change on GitHub)

The code is already written to check if the pups array is empty and add the seed data if so, which means once we save this file, our pup data will be sent to Convex and we’ll see it loaded on the page.

Create and display reactions

Next, we want users in our app to be able to react to each pup and see how other people have reacted to them as well.

Let’s start by defining a mutation to store new reactions and a query to read them out grouped by reaction type and the pup that was reacted to. In a new file at convex/reactions.ts, add the following:

import { mutation, query } from './_generated/server';
import { reactionTypes } from '../src/util/helpers';
interface DataPoint {
name: string;
count: number;
}
interface DataSeries {
label: string;
data: DataPoint[];
}
export const add = mutation(async ({ db }, pup, type) => {
await db.insert('reactions', {
pup,
type,
});
});
export const getByPup = query(async ({ db }) => {
const reactionsRaw = await db.query('reactions').collect();
const reactions = await Promise.all(
reactionsRaw.map(async (reaction) => {
const pupEntry = await db.get(reaction.pup);
return { ...reaction, name: pupEntry?.name };
})
);
return reactionTypes.reduce<DataSeries[]>((dataseries, { name, label }) => {
return [
...dataseries,
{
label,
data: reactions
.filter((r) => r.type === name)
.reduce<DataPoint[]>((datapoints, r) => {
const index = datapoints.findIndex((d) => d.name === r.name);
if (index >= 0) {
datapoints[index].count += 1;
} else if (r.name) {
datapoints.push({
name: r.name,
count: 1,
});
}
return datapoints;
}, []),
},
];
}, []);
});

(see this change on GitHub)

The mutation is pretty straightforward, but that query might look a little intense. For the bar chart to work, we need to deliver reaction data in the following format:

[
{
"label": "💜",
"data": [
{
"name": "Floof",
"count": 23
},
...
]
},
...
]

The code inside the query uses the reaction data to load the right pup data, then uses nested reducers (😅) to shape the data into the format we need.

Next, let’s hook up our buttons to save a new reaction in the database. Make one last change to src/components/list.tsx:

import { useQuery, useMutation } from '../../convex/_generated/react';
import { seedPups, reactionTypes } from '../util/helpers';
export function List() {
const pups = useQuery('pups:get');
const addPup = useMutation('pups:add');
const addReaction = (..._args: any) => {};
const addReaction = useMutation('reactions:add');
if (!pups) {
return null;
}
if (Array.isArray(pups) && pups.length === 0) {
seedPups.forEach((pup) => {
addPup(pup.name, pup.photo);
});
}
return (
<div className="pups">
{/* ...unchanged... */}
</div>
);
}

(see this change in GitHub)

Save, then click a few of the reactions on your favorite pups to add some reaction entries to the database.

To display these reaction stats, open up src/components/bar-chart.tsx and make the following change:

import React from 'react';
import { useMemo } from 'react';
import { Chart } from 'react-charts';
import { getPlaceholderReactionData } from '../util/helpers';
import { useQuery } from '../../convex/_generated/react';
interface DataPoint {
name: string;
count: number;
}
export function BarChart() {
const primaryAxis = React.useMemo(
() => ({
getValue: (datum: DataPoint) => datum.name,
showGrid: false,
innerBandPadding: 0.3,
innerSeriesBandPadding: 0.05,
}),
[],
);
const secondaryAxes = useMemo(() => {
return [
{
getValue: (d: DataPoint) => d.count,
hardMin: 0,
showGrid: false,
},
];
}, []);
const reactions = getPlaceholderReactionData();
const reactions = useQuery('reactions:getByPup');
if (!reactions) {
return null;
}
return (
<div className="chart-container">
<Chart
options={{
data: reactions,
primaryAxis,
secondaryAxes,
interactionMode: 'primary',
}}
/>
</div>
);
}

(see this change in GitHub)

Save and navigate to the stats and you’ll see that the reaction data now corresponds to how many reactions you’ve added.

Deploy a Convex app to production

To deploy the app, follow the deployment instructions in the Convex docs.

  • Commit all your changes (including the .env file — the Convex URL is not sensitive) and get them up on GitHub
  • Go to app.netlify.com and either sign in or create an account using your GitHub login
  • Get your deploy key from the Convex Dashboard
  • Add it as an environment variable called CONVEX_DEPLOY_KEY in Netlify
  • Set your build command to npx convex deploy && npm run build

The site will build and deploy to a public URL. Open it up and add some reactions! Your database-powered app is now deployed and running!

But wait, there’s more!

If getting a fully type-safe data workflow isn’t cool enough for you, there’s one more built-in bonus when you choose Convex as your data layer: real-time updates Just Work™.

Desktop, mobile, wherever — real-time just works.

Open your live site in two browser windows. On one, load the home page with all the pups. In the other, load the stats. Add some reactions and watch the chart update in real time! For even more fun, open the site on your phone and watch the browser update as you tap away on your favorite pups.

This reactivity is automatic, so you don’t have to configure anything for it to work. You’ll even see realtime updates if you edit your data directly in the Convex dashboard!

Databases are for everyone now — even front-of-the-frontend developers

For web devs who would describe themselves as frontend developers or even “front of the frontend devs”, working with data might feel like a lot — but hopefully this tutorial made it feel a little more approachable.

Get out there and build all those fun app ideas!

Resources