Meteor Full-Stack Development (v3.x + React)
Modern Meteor 3.x (and 3.5+) full-stack development guide covering async-first patterns, React integration, MongoDB collections, methods, pub/sub, and project architecture.
Meteor 3 removed Fibers entirely — all server-side I/O is standard async/await. This is the single most important thing to internalize: every collection operation, every method body, every publication setup function that touches the database must be async. Node 24 is the standard runtime starting in Meteor 3.5.
Quick Reference: Async Collection APIs
Always use the *Async variants on the server. Sync versions exist only for client-side Minimongo.
| Operation | Async API (server) | Sync API (client Minimongo only) |
|---|---|---|
| Insert | insertAsync(doc) |
insert(doc) |
| Find one | findOneAsync(selector) |
findOne(selector) |
| Update | updateAsync(selector, modifier) |
update(selector, modifier) |
| Upsert | upsertAsync(selector, modifier) |
upsert(selector, modifier) |
| Remove | removeAsync(selector) |
remove(selector) |
| Count | countAsync() (on cursor) |
count() |
| Fetch | fetchAsync() (on cursor) |
fetch() |
| forEach | forEachAsync(fn) (on cursor) |
forEach(fn) |
| map | mapAsync(fn) (on cursor) |
map(fn) |
| Observe | observeAsync(callbacks) |
observe(callbacks) |
| Create index | createIndexAsync(index, options) |
— |
| Send email | Email.sendAsync(options) |
Email.send(options) |
Publications still return sync cursors via collection.find(...) for live-query reactivity — that hasn't changed.
Core Concepts at a Glance
Methods (RPC)
Server functions callable from the client. In v3, always async:
Meteor.methods({
async 'todos.create'(text) {
if (!this.userId) throw new Meteor.Error('not-authorized');
return await Todos.insertAsync({ text, createdAt: new Date(), userId: this.userId });
},
});
// Client
const id = await Meteor.callAsync('todos.create', 'Buy milk');
Read references/methods-rpc.md for stubs, optimistic UI, applyAsync options, and the simulation timing trap.
Publications & Subscriptions
Server pushes reactive data to the client over DDP:
// Server
Meteor.publish('todos.byUser', function () {
if (!this.userId) return this.ready();
return Todos.find({ userId: this.userId });
});
// Client (React)
const { todos, isLoading } = useTracker(() => {
const handle = Meteor.subscribe('todos.byUser');
return {
isLoading: !handle.ready(),
todos: Todos.find({}, { sort: { createdAt: -1 } }).fetch(),
};
}, []);
Read references/pubsub.md for composite publications, SubsManager caching, counts, publication wrappers, and MongoDB Change Streams configuration.
REST APIs (accounts-express)
Build authenticated REST endpoints seamlessly using Express and accounts-express:
import express from 'express';
import { WebApp } from 'meteor/webapp';
import { createAuthMiddleware } from 'meteor/accounts-express';
const app = express();
app.use('/api', createAuthMiddleware({ required: true }));
app.get('/api/me', async (req, res) => {
const user = await Meteor.userAsync();
res.json({ userId: Meteor.userId(), email: user?.emails?.[0]?.address });
});
WebApp.handlers.use(app);
Read references/architecture.md for REST API module organization.
React Integration
Meteor's reactive data layer connects to React through useTracker (hooks) or withTracker (HOC):
import { useTracker } from 'meteor/react-meteor-data';
function TodoList() {
const { todos, user } = useTracker(() => ({
todos: Todos.find().fetch(),
user: Meteor.user(),
}));
return todos.map(t => <TodoItem key={t._id} todo={t} user={user} />);
}
Read references/react-integration.md for withTracker patterns, subscription lifecycle in components, and common pitfalls.
Project Structure
A typical Meteor 3 + React project:
my-app/
├── client/ # Client entry, main.jsx, global styles
│ └── main.jsx # Meteor.startup(() => render(<App />))
├── server/ # Server entry, publications, startup
│ ├── main.js # Meteor.startup, indexes, seeds
│ └── publications/ # Pub definitions by domain
├── imports/ # Shared code (lazy-loaded by convention)
│ ├── api/ # Collections, methods, schemas
│ │ ├── todos/
│ │ │ ├── collection.js
│ │ │ ├── methods.js
│ │ │ └── publications.js
│ │ └── users/
│ ├── ui/ # React components
│ │ ├── components/ # Reusable UI
│ │ ├── pages/ # Route-level components
│ │ └── layouts/ # Layout wrappers
│ └── startup/ # Client/server bootstrap
├── public/ # Static assets (served as-is)
├── private/ # Server-only assets (Assets API)
├── .meteor/ # Meteor internals, packages, versions
└── package.json
Key conventions:
- Everything under
imports/is lazy — only loaded when explicitly imported client/andserver/directories are eagerly loaded on their respective sides- Files outside
imports/that aren't inclient/orserver/load on both sides
Read references/architecture.md for circular dependency prevention, import rules, and module organization patterns.
Common Patterns
Error Handling in Methods
Only Meteor.Error reaches the client — other exceptions are sanitized to a generic 500:
// Server method
async 'orders.cancel'(orderId) {
const order = await Orders.findOneAsync(orderId);
if (!order) throw new Meteor.Error('not-found', 'Order not found');
if (order.userId !== this.userId) throw new Meteor.Error('not-authorized', 'Not your order');
await Orders.updateAsync(orderId, { $set: { status: 'cancelled' } });
}
// Client
try {
await Meteor.callAsync('orders.cancel', orderId);
} catch (err) {
if (err.error === 'not-found') showToast(err.reason);
}
Collection Helpers
Attach computed properties and methods to documents using dburles:collection-helpers:
Todos.helpers({
isOverdue() {
return this.dueDate && this.dueDate < new Date();
},
owner() {
return Meteor.users.findOne(this.userId);
},
});
// Usage — any document from Todos.find/findOne gets these methods
const todo = Todos.findOne(id);
if (todo.isOverdue()) { /* ... */ }
Authorization Pattern
Guard methods and publications with this.userId:
Meteor.methods({
async 'projects.archive'(projectId) {
if (!this.userId) throw new Meteor.Error('not-authorized');
const project = await Projects.findOneAsync(projectId);
if (project.ownerId !== this.userId) {
throw new Meteor.Error('forbidden', 'Only the owner can archive');
}
return await Projects.updateAsync(projectId, { $set: { archived: true } });
},
});
Accounts & Users
Meteor's built-in accounts system provides Meteor.userId(), Meteor.user(), and their async equivalents:
// Server — async required in v3
const user = await Meteor.userAsync();
// Client — sync is fine (reads from Minimongo)
const user = Meteor.user();
const userId = Meteor.userId();
// Reactive in useTracker
const user = useTracker(() => Meteor.user(), []);
// Async client logins
await Meteor.loginWithPasswordAsync(email, password);
await Meteor.loginWithTokenAsync(token);
Sending Email
Always use Email.sendAsync on the server — it returns a Promise and fits the async-first model:
import { Email } from 'meteor/email';
Meteor.methods({
async 'notifications.send'(to, subject, html) {
if (!this.userId) throw new Meteor.Error('not-authorized');
await Email.sendAsync({ from: 'noreply@example.com', to, subject, html });
},
});
What to Watch Out For
1. "Can't set timers inside simulations"
This browser error occurs when an async method stub is running (simulation context is active) and a withTracker/useTracker component re-renders, calling Meteor.defer. Fix: add if (Meteor.isClient) return; at the top of complex stubs to make them no-ops on the client. The server still runs the full logic; Minimongo updates via the subscription.
2. Meteor.call with async stubs
Meteor.call was designed for sync stubs. Always use Meteor.callAsync in v3. Mixing them causes stubs to not resolve properly.
3. Circular dependencies in barrel imports
Mixed barrels that re-export models, components, actions, and schemas from a single index file are the #1 source of Element type is invalid and undefined errors in Meteor apps. Prefer direct file imports on hot paths.
4. Returning non-EJSON values from methods
Method return values must be EJSON-serializable (plain objects, arrays, strings, numbers, dates, binary, ObjectID). Functions, class instances, and circular references will fail.
5. Publication setup vs cursor return
Publication functions can be async for setup work, but must return a sync cursor or call this.ready():
Meteor.publish('items.forTeam', async function (teamId) {
const team = await Teams.findOneAsync(teamId);
if (!team.members.includes(this.userId)) return this.ready();
return Items.find({ teamId }); // sync cursor for reactivity
});
6. Always use field projections in find() / findOneAsync()
Fetching full documents when you only need a few fields wastes memory, bandwidth, and serialization time. Pass a fields (or projection) option whenever you don't need the whole document:
// Bad — fetches every field on every matching document
const names = await Users.find({ active: true }).fetchAsync();
// Good — only pull what you need
const names = await Users.find({ active: true }, { fields: { username: 1, email: 1 } }).fetchAsync();
// In publications — reduces data pushed over DDP
Meteor.publish('todos.titles', function () {
return Todos.find({ userId: this.userId }, { fields: { text: 1, done: 1, createdAt: 1 } });
});
This matters especially in publications: every extra field is serialized and pushed to every subscribed client. See references/performance.md for projection strategies.
7. rawCollection() bypasses collection hooks
rawCollection() (used for bulk operations/native Mongo methods) bypasses Meteor's collection hooks. You must manually replicate side-effects (e.g., updatedAt, sync logic). See references/collections-models.md.
8. DDP queue blocking — async methods still block each other
Even in Meteor 3, where methods are natively async, the DDP server preserves sequential per-client execution by default. A method awaiting a slow external API will block all subsequent method calls from that same client until it resolves. Fix: call this.unblock() at the top of methods that are safe to run in parallel (after auth guards). Never unblock write methods whose results are consumed immediately by a follow-up method from the same client (race condition). See references/performance.md for the full guide including the this.unblock() vs. Meteor.defer() decision matrix.
9. Multiple publications, same collection, different projections — MergeBox wins unpredictably
When two active subscriptions publish the same document _id into the same collection name but with different fields projections, Meteor's MergeBox merges them on the client. The rules:
- Top-level fields are unioned — if pub A publishes
titleand pub B publishesbody, the client doc gets both. Good. - Conflicting top-level fields are resolved arbitrarily — if both pubs publish
statusbut with different values, one wins. Which one? Unspecified. - No deep merge — if pub A sends
{ profile: { name: 'Alice' } }and pub B sends{ profile: { age: 30 } }, the entireprofileobject comes from whichever publication "wins" for that field. The other sub-fields silently disappear. - Unsub surprise — when one subscription stops, the MergeBox removes the fields it contributed. A component that assumed a field exists may suddenly see
undefined.
The fix: virtual collections. When you need the same document published with genuinely different shapes (e.g., list view vs. full detail), publish into separate client-side collection names:
// server — two publications, two DDP collection namespaces
Meteor.publish('messages.list', function (channelId) {
// Lightweight: just what the list UI needs
return Messages.find(
{ channelId },
{ fields: { authorId: 1, preview: 1, createdAt: 1 } }
);
// DDP collection name defaults to 'messages'
});
Meteor.publish('messages.full', function (messageId) {
// Use low-level API to push into a DIFFERENT client collection name
const self = this;
const doc = Messages.findOne(messageId); // or use cursor + observe
if (doc) self.added('messagesFull', doc._id, doc);
self.ready();
});
// client — two separate Minimongo collections, no merge conflict
export const Messages = new Mongo.Collection('messages'); // list view
export const MessagesFull = new Mongo.Collection('messagesFull'); // detail view
Naming convention: MessagesFull, MessagesStripped, MessagesList — whatever communicates the intended field shape. The key is that each virtual collection has a single, stable field contract.
Use this pattern whenever: (a) you need different field shapes of the same document in the same session, (b) you have a public list projection and a richer authenticated detail projection, or (c) you've seen fields mysteriously disappear when a second subscription activates.
10. observeChangesAsync teardown leaks
In Meteor 3, observeChangesAsync returns a Promise of a handle, not the handle itself. Promises have no .stop() method. If you use it in a publication and don't await it properly before registering onStop, the observer will leak and keep running forever, or throw stop is not a function. Ensure you use a stopped flag, register onStop first, and only call .stop() on the resolved handle.
Reference Files
For deeper coverage, read these when working on specific areas:
| File | When to read |
|---|---|
references/methods-rpc.md |
Stubs, optimistic UI, callAsync vs applyAsync, error handling, this.name |
references/pubsub.md |
Composite publications, SubsManager, reactive counts, data flow, MergeBox / virtual collection pattern |
references/react-integration.md |
useTracker vs withTracker, component lifecycle, container patterns |
references/collections-models.md |
Schemas, helpers, indexes, aggregation, rawCollection hooks pitfall, MongoDB Collation |
references/async-patterns.md |
Fibers→async migration, async method/publication patterns |
references/architecture.md |
Project structure, import rules, circular dependency prevention, REST API (accounts-express) |
references/performance.md |
this.unblock() / DDP queue parallelism, Meteor.defer(), MongoDB Change Streams, DDP Session Resumption, publication polling optimization, auth separation, field projections |
Code Style Defaults
Unless the project specifies otherwise:
- Use named exports (avoid default exports)
- Name files after the primary export
- Prefer direct file imports over barrel imports for React components, actions, and schemas
- Use
async/awaiteverywhere on the server — never rely on sync collection APIs - Guard all methods and publications with
this.userIdchecks - Throw
Meteor.Error(errorCode, reason)for client-visible errors - Use
npmas the package manager