Hyo Jang's avatar
Hyo Jang
2 months ago

5. Building a Real-World TypeScript App with Convex and Expo

Leveraging what I learned from the four-part Convex and Expo workshop blog series (User Authentication, Real-Time Profiles, AI Chat, AI Content Scheduling), I tackled a practical TypeScript project: a quiz app to build daily general knowledge. Using Convex and TypeScript, I successfully deployed it to the Play Store and App Store.

In this post, I'll walk you through how I combined Convex and Expo to go from idea to published app in just a month and a half. I'll share:

  • How I combined Convex and Expo to rapidly develop and deploy a full-featured quiz app
  • My implementation of Email OTP authentication using Convex Auth
  • Designing APIs using Convex's CQRS architecture for quiz management and review features
  • Strategies for data migration and seeding in Convex
  • Building a server-side solution for multilingual support

Combining Convex and Expo

Expo, a cross-platform framework, has matured over time, making it ideal for lightweight toy app development. Pairing it with Convex as the backend significantly sped up the process, allowing me to launch the app in just one and a half months with only one weekend of work. Especially satisfying as a developer was running npx convex dev to spin up the server and code while syncing with the database in real time.

While developing with Convex, I could quickly implement application logic by entering data in real time and checking live logs, as shown on the right screen.

User Authentication with Convex Auth

In my previous blog post on User Authentication, I used Clerk. This time, however, I didn’t want to support diverse login options and preferred simplicity. Since the beta version of Convex Auth seemed sufficient, I went with it.

The authentication method I chose was Email OTP.

With Convex Auth, implementing Email OTP authentication is remarkably straightforward. You can follow the steps outlined in the Convex official documentation. Here’s the three-step process I went through:

  1. Setup: Integrate Convex Auth with the Resend API.
  2. Testing: In the development environment, it’s limited to test emails only.
  3. Deployment: Domain verification is required for production.

Notes on Using the Resend API

  • Development Environment: Limited to test emails only.
  • Production Environment: Domain verification is required.
  • HTML Templates: By default, it supports text only; additional work is needed for HTML customization. I referred to the Auth.js documentation, but since ConvexAdapter isn’t officially supported, I didn’t implement it. If you’re up for the challenge, check out this blog.

Limitations and Workarounds for Multilingual Support

I tried adding a locale parameter as shown below for multilingual emails, but Convex Auth doesn’t currently support this.

1await signIn('resend-otp', { email, locale });
2

After reaching out to the Convex team, I received a helpful workaround suggestion:

Hello Hyo, I agree that sounds like a really useful capability. I cut this GitHub issue to track it publicly, feel free to add more context there if it's insufficient. I wonder if as a workaround for now you could make two email providers (different ids), one for english and one for korean. Then on the client side, use the provider for the user's language? Thanks for writing in, Ians

This approach temporarily solved the issue, but since the Convex team is aware of it, official support might be added in the future. Convex Auth simplifies Email OTP implementation, but advanced features like multilingual support and HTML templates still need improvement. I’m looking forward to upcoming updates!

Application API Design and Implementation

The two standout features of my Martie app are as follows:

  1. Solving Trivia Quizzes: Offers an experience of building knowledge by answering quizzes.
  2. Reviewing Past Quizzes: Allows users to revisit and review previously solved quizzes.

These features were built on the codebase I explored in my Building AI Chat with Convex and ChatGPT blog.

1. Implementing Trivia Quiz Solving

Developing the Martie app deepened my understanding of Convex. Much like GraphQL, Convex strictly adheres to the CQRS (Command Query Responsibility Segregation) principle, which significantly shapes the development approach.

When I first encountered Convex, the distinctions between mutation, internalMutation, internalQuery, query, internalAction, and action felt unclear. I initially saw them as mere security measures (server-only calls) or client-side necessities. However, working with complex queries helped me grasp their true purpose.

Even when the client needed mutation or query, CQRS required breaking them down into action or mutation. For instance, to save a user's viewed quiz list during getQuizzes pagination, I had to define an internalMutation like this:

1internalMutation(async ({ db }, { userId, quizIds }) => {
2  await db.insert("userQuizzes", { userId, quizIds });
3});
4

Additionally, if an external API call is needed, you must implement a separate endpoint using internalAction.

1internalAction(async ({ fetch }, { endpoint }) => {
2  const response = await fetch(endpoint);
3  return response.json();
4});
5

Through this process, I came to appreciate the design philosophy of Convex and the practical value of CQRS.

2. Quiz Review Function

The screen above shows a feature where users can review their general knowledge by looking at a list of solved quizzes.

  • getQuizResults: Fetches the quiz list with a paginated query.
  • reviewQuizQuestion: Handles the review process by calling an action from the app.
  • checkUserPrompt: Tracks the user's prompt count using an internalMutation.

Through this process, you'll gain a clearer understanding of how to design queries and actions in Convex.

Data Migration in Convex & TypeScript

Convex uses its own DBMS, so it doesn’t manage traditional database migration queries separately. However, during app development, when the data structure changes—such as adding a new column or modifying values—migration becomes necessary.

Migration Process

For example, to add a boolean column isActive to the quizzes table with a default value of true:

  1. Schema Update: Add isActive as optional in schema.ts.
  2. Migration Execution: Write a script to update existing data.
  3. Make Required: Remove the optional attribute after migration.

I managed migration files in the convex/migrations folder. Here's an example:

1// convex/migrations/addIsActive.ts
2export const addIsActive = mutation({
3  handler: async (ctx) => {
4    const quizzes = await ctx.db.query("quizzes").collect();
5    for (const quiz of quizzes) {
6      await ctx.db.patch(quiz._id, { isActive: true });
7    }
8    console.log("✅ isActive column added successfully");
9  },
10});
11

Managing Data Seeding

I also created a convex/migrations/seed.ts file to import quiz data into the Convex DB. Here's an example of inserting categories:

1// convex/migrations/seed.ts
2import { mutation } from './_generated/server';
3
4export const insertCategories = mutation({
5  args: {},
6  handler: async (ctx) => {
7    const CATEGORIES = [
8      { name: 'GENERAL_KNOWLEDGE', description: 'General Knowledge' },
9      { name: 'BASIC_SCIENCE', description: 'Basic Science' },
10    ];
11    await Promise.all(CATEGORIES.map(cat => ctx.db.insert('categories', cat)));
12    console.log('✅ Categories inserted successfully!');
13  },
14});
15

Multilingual Support on the Convex Server

To provide quizzes in multiple languages in the Martie app, efficient management of internationalization (i18n) strings on both client and server is crucial. Including large amounts of strings on the client increases app size, so I explored server-side multilingual solutions.

Initial Approaches and Limitations

  1. JSON File Management: Load en.json, ko.json, etc., on the server.
    • Limitation: Due to Convex's serverless nature, loading JSON files on every call is inefficient.
  2. Convex Storage: Upload files to storage and retrieve them.
    • Limitation: Similar inefficiencies to JSON loading.

Final Choice: languages Table

I opted to create a languages table in Convex to manage multilingual strings in a key-value format.

  • Advantages: Adding indexes increases storage but improves read speed and management efficiency.
  • Implementation: Maintain local JSON files (en.json, ko.json, ja.json) and sync them to the server with upsert.

Code Example

Below is the core logic for importing multilingual data and handling missing or erroneous keys:

1// convex/actions/importLanguages.ts
2export const importAllQuizLanguages = action({
3  handler: async (ctx) => {
4    const insertBatch = [];
5    const updateBatch = [];
6    const existingKeysMap = new Map(); // Map for existing keys
7
8    // Load existing data (pagination)
9    const allExisting = await ctx.runQuery(api.quizLanguage.queries.getPaginatedKeys, { limit: 8192 });
10    allExisting.items.forEach(entry => {
11      const key = `${entry.key}_${entry.language}`;
12      existingKeysMap.set(key, { types: new Set([entry.type]), entryIds: new Map([[entry.type, entry._id]]) });
13    });
14
15    // Process JSON files (e.g., Korean)
16    const languages = { ko, en, ja };
17    for (const [lang, data] of Object.entries(languages)) {
18      if (!data) continue;
19      for (const [key, value] of Object.entries(data)) {
20        const type = key.endsWith('_key') ? 'question' : key.endsWith('_answer') ? 'answer' : 'option';
21        const quizKey = key.replace(/(_answer|_opt.*)$/, '_key');
22        const existingKey = `${key}_${lang}`;
23
24        if (!existingKeysMap.has(existingKey) || !existingKeysMap.get(existingKey).types.has(type)) {
25          insertBatch.push({ key, language: lang, value, type });
26        } else {
27          updateBatch.push({ id: existingKeysMap.get(existingKey).entryIds.get(type), value });
28        }
29      }
30    }
31
32    // Batch processing
33    const chunkSize = 5000;
34    if (insertBatch.length) {
35      for (let i = 0; i < insertBatch.length; i += chunkSize) {
36        await ctx.runMutation(api.quizLanguage.mutations.insertQuizLanguageBatch, {
37          languages: insertBatch.slice(i, i + chunkSize),
38        });
39      }
40    }
41    if (updateBatch.length) {
42      for (let i = 0; i < updateBatch.length; i += chunkSize) {
43        await ctx.runMutation(api.quizLanguage.mutations.updateQuizLanguageBatch, {
44          updates: updateBatch.slice(i, i + chunkSize),
45        });
46      }
47    }
48  },
49});
50

Translation Key Management

This logic uses existingKeysMap to check existing data, adding new keys to insertBatch and updates to updateBatch. This effectively manages missing or incorrect translation keys. The reason for batching is that Convex imposes a limit on the number of inserts at once (specifically, 8096), so splitting the operations into smaller chunks ensures smooth execution.

And that wraps up my first TypeScript-driven Convex app journey.

I hope this serves as a helpful reference for anyone considering Convex as a backend for production-level projects!

Build in minutes, scale forever.

Convex is the backend platform with everything you need to build your full-stack AI project. Cloud functions, a database, file storage, scheduling, workflow, vector search, and realtime updates fit together seamlessly.

Get started