# Meteor Fullstack

> Full-stack Meteor 3.x development with React, MongoDB, async APIs, methods, pub/sub, and GraphQL. Use this skill when working on any Meteor project — writing methods, publications, subscriptions, React data containers, collection helpers, ORM patterns, REST APIs with accounts-express, Meteor-to-React integration via useTracker/withTracker, async migration from Fibers, optimistic UI, DDP, or debugging Meteor-specific issues like circular dependencies, method stubs, and simulation errors. Trigger on: Meteor, Meteor.js, Meteor 3, MeteorJS, callAsync, useTracker, withTracker, Meteor methods, Meteor publications, Meteor subscriptions, SubsManager, Minimongo, DDP, Mongo.Collection, Meteor.Error, optimistic UI, Fibers migration, meteor async, accounts-express. For diagnosing publication observer leaks or async teardown issues, trigger the meteor-observer-leaks skill.

- Skill: `kolyasya/meteor-fullstack` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add kolyasya/meteor-fullstack`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kolyasya/meteor-fullstack/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: kolyasya (https://skillmd.com/u/kolyasya)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kolyasya/meteor-fullstack

---


# 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:

```js
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:

```js
// 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`:

```js
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):

```jsx
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/` and `server/` directories are eagerly loaded on their respective sides
- Files outside `imports/` that aren't in `client/` or `server/` 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:

```js
// 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`:

```js
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`:

```js
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:

```js
// 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:

```js
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()`:

```js
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:

```js
// 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 `title` and pub B publishes `body`, the client doc gets both. Good.
- **Conflicting top-level fields are resolved arbitrarily** — if both pubs publish `status` but 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 entire `profile` object 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:

```js
// 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();
});
```

```js
// 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`/`await` everywhere on the server — never rely on sync collection APIs
- Guard all methods and publications with `this.userId` checks
- Throw `Meteor.Error(errorCode, reason)` for client-visible errors
- Use `npm` as the package manager

