DEV Community

Cover image for How to Build a Full-Stack App With TanStack Start and MongoDB
Didicodes for MongoDB

Posted on

How to Build a Full-Stack App With TanStack Start and MongoDB

What you'll learn

  • How TanStack Start unifies routing, data loading, server functions, and SSR in a single full-stack workflow
  • How to integrate MongoDB with TanStack Start using a reusable, serverless-friendly connection
  • How to build a CRUD Notes app with real-time UI updates using server functions instead of API routes

Building full-stack JavaScript applications often means choosing between two extremes: heavyweight frameworks that make too many decisions for you, or bare-bones setups that leave you wiring everything together manually.

TanStack Start offers a different approach. It's a full-stack React framework designed to make building web applications simple, fast, and flexible. Built on top of TanStack Router, Vite, and TypeScript, it brings together type-safe routing, client-side interactivity, server functions, SSR, and universal deployment in a clean, composable setup that's easy to work with.

Although TanStack Start is still in early release, it has quickly gained attention for its lightweight, explicit design. Rather than acting like a monolithic "batteries-included" framework, it gives you full control. You choose your backend, database, and architecture while still benefiting from solid conventions and powerful tools.

In this tutorial, you'll learn how TanStack Start works with MongoDB by building a simple full-stack Notes application.

Prerequisites

This tutorial assumes that you have the following:

  • Node.js 18+ installed (npm is included by default)
  • MongoDB Atlas account or MongoDB Community Server/Edition
  • Basic knowledge of React and TypeScript

Using TanStack Start and MongoDB

To build our Notes app (see the code repository), we'll use TanStack Start alongside MongoDB. TanStack Start takes care of things like routing, server logic, and data fetching in a way that feels clean and predictable, and MongoDB gives us flexible, document-based storage that fits nicely into server functions. Put together, they give us a simple full-stack workflow without the overhead of a heavier framework. Let's start building. πŸ‘‡

Creating a new TanStack Start projectΒ 

There are many ways to get a TanStack Start project up and running, but for this tutorial, we will use their Start-Basic starter template. To use it, we'll use gitpick (a tool for cloning specific folders from a Git repository) to clone just the starter template into a folder called tanstack-start-mongodb-notesapp:

npx gitpick TanStack/router/tree/main/examples/react/start-basic tanstack-start-mongodb-notesapp
Enter fullscreen mode Exit fullscreen mode

Now, run the commands below to switch to the tanstack-start-mongodb-notesapp directory, install the required dependencies, and start the development server.

cd tanstack-start-mongodb-notesapp
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

After doing this, the starter template application will run in your browser at http://localhost:3000. It should look similar to the one in the image below. Feel free to play around with it to get a sense of how TanStack Start works.

TanStack Start's starter template application

Now, head back to your terminal and install the additional dependencies listed below, as we will need them to build our Notes application.

# 1. Installs the official MongoDB Node.js driver
# 2. Installs a TypeScript-first schema validation
# 3. Installs a utility for merging Tailwind classes
npm install mongodb zod tailwind-merge

# Installs dev dependencies for environment variables and running TypeScript files
npm install -D dotenv tsx
Enter fullscreen mode Exit fullscreen mode

Understanding the project structure for the app

With our TanStack Start project now up and running, the next step is to remove the boilerplate and set up the project structure for the Notes app we will be building. You can do this automatically by running the command below in your tanstack-start-mongodb-notesapp directory or manually by deleting the example routes (posts, users, etc.) and creating the new folder structure in your code editor.

curl -o setup.sh https://gist.githubusercontent.com/didicodes/82b5c23647691c4b7b65b1e1558963f5/raw/bbf9d406a82458b236b37652b92212daa1f80f85/setup.sh && chmod +x setup.sh && ./setup.sh
Enter fullscreen mode Exit fullscreen mode

After running the setup script (or completing the manual setup), your project structure should match the one shown below πŸ‘‡

IProject structure of the Notes app

Creating all the files upfront will make developing the Notes app smoother and give you a clear picture of where each part of the app will live. As you keep reading this tutorial, you'll learn what each file is responsible for.

Setting up MongoDB with TanStack server functions

Before building any features, we need to set up MongoDB to work well with TanStack Start and serverless environments. In the next few steps, we'll configure environment variables, define serverless-friendly connection settings, create a reusable MongoDB client, and add type-safe validation so our database layer is efficient and reliable.Β 

Step 1: Set up environment variablesΒ 

Before our app can save or fetch any data, it needs to connect to a database. For this project, we'll use MongoDB Atlas, MongoDB's fully managed cloud database. To connect to it, you need a connection string. Since this connection string includes sensitive information, we'll store it in an environment variable and load it securely at runtime. This keeps our credentials secure and allows us to switch configurations across different environments easily.

To set this up, go to the .env file you'd created earlier in your project's folder and add the variables shown below, replacing the value with your MongoDB connection string. To get the connection string, log in to MongoDB Atlas, create a cluster, then navigate to Connect β†’ Drivers β†’ Node.js, select a version, and copy the displayed connection string. Need additional help getting started with MongoDB Atlas or finding your connection string? Check out the MongoDB Atlas Getting Started guide.

# MongoDB Connection String
MONGODB_URI = mongodb+srv://<username>:<password>@<cluster>.mongodb.net/<database>
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure MongoDB settings

TanStack Start's server functions use a serverless execution model, so we have to optimize the connection pool accordingly. To do this, go to the src/config/mongodb.ts file, copy and add the code below.

// Database and collection names
export  const DB_NAME = "notes-app";
export  const COLLECTION_NAME = "notes";

// Connection pool configuration optimized for serverless
export  const MONGODB_CONNECTION_CONFIG = {
minPoolSize: 1,
maxIdleTimeMS: 60000,
} as  const;
Enter fullscreen mode Exit fullscreen mode

In the code above, we've specified our database and collection names and configured a serverless-optimized connection pool. We set minPoolSize to 1 to keep at least one connection warm for faster subsequent requests, and set maxIdleTimeMS to 60000 to close idle connections quickly and free resources.

Step 3: Create a reusable MongoDB client

TanStack Start executes server functions on the server, so we need a reliable and efficient way to connect to MongoDB. Instead of opening a new database connection on every request, we'll create a shared MongoDB client that can be reused across server function invocations.

Head back to your code editor and add the code snippet below to the src/lib/mongodb.ts file.

import { MongoClient, type Collection, type Db } from "mongodb";
import {
  COLLECTION_NAME,
  DB_NAME,
  MONGODB_CONNECTION_CONFIG,
} from "../config/mongodb";
import type { NoteResponse } from "./types";

const MONGODB_URI = process.env.MONGODB_URI;

/**
 * Global cache for MongoDB client
 * This survives across serverless function invocations
 */
interface CachedConnection {
  client: MongoClient | null;
  db: Db | null;
  promise: Promise<{ client: MongoClient; db: Db }> | null;
}

const cached: CachedConnection = {
  client: null,
  db: null,
  promise: null,
};

/**
 * Get or create a MongoDB connection
 *
 * This is the heart of our serverless optimization.
 * It implements a three-tier caching strategy:
 *
 * 1. Return existing connection if available (fastest)
 * 2. Wait for in-flight connection if one is being created (prevents duplicates)
 * 3. Create new connection if neither exists (slowest, but necessary)
 */
export async function connectToDatabase(): Promise<{
  client: MongoClient;
  db: Db;
}> {
  // Tier 1: Return cached connection if available
  if (cached.client && cached.db) {
    return { client: cached.client, db: cached.db };
  }

  // Tier 2: Return in-flight connection promise if exists
  // This prevents multiple simultaneous connection attempts
  if (cached.promise) {
    return cached.promise;
  }

  // Validate connection string
  if (!MONGODB_URI) {
    throw new Error(
      "Missing MONGODB_URI environment variable. " +
        "Please add it to your .env file."
    );
  }

  // Tier 3: Create a new connection
  cached.promise = MongoClient.connect(MONGODB_URI, {
    appName: "tanstack-notes-app",
    ...MONGODB_CONNECTION_CONFIG,
  })
    .then((client) => {
      const db = client.db(DB_NAME);

      cached.client = client;
      cached.db = db;
      cached.promise = null; // Clear promise since we're done

      return { client, db };
    })
    .catch((error) => {
      // Reset promise on error to allow retry
      cached.promise = null;
      throw error;
    });

  return cached.promise;
}

/**
 * Get typed collection accessor
 *
 * This provides type-safe access to MongoDB collections.
 * The NoteResponse type ensures we get proper TypeScript
 * autocomplete and type checking.
 */

export async function getNotesCollection(): Promise<Collection<NoteResponse>> {
  const { db } = await connectToDatabase();
  return db.collection<NoteResponse>(COLLECTION_NAME);
}
Enter fullscreen mode Exit fullscreen mode

With this in place, our Notes app includes a MongoDB connection layer that integrates efficiently with TanStack Start's server function model. The reusable MongoDB client we've just implemented works well with TanStack Start because it reuses a single MongoDB connection across requests and safely handles concurrent access, thereby avoiding connection pool exhaustion.

Step 4: Define types with Zod

Before inserting data into MongoDB, we need to validate it first to ensure our MongoDB database and the UI of the Notes application receive consistent values. To do this, go to your src/lib/types.ts file and add the code snippet below:

import { z } from "zod";
import { ObjectId } from "mongodb";

/**
 * Zod schemas for input validation
 * These validate data coming from the client
 */
export const createNoteSchema = z.object({
  title: z
    .string()
    .min(1, "Title is required")
    .max(200, "Title must be 200 characters or less"),
  content: z.string().max(10000, "Content must be 10,000 characters or less"),
});

export const updateNoteSchema = z.object({
  id: z.string().min(1, "Note ID is required"),
  title: z.string().min(1).max(200).optional(),
  content: z.string().max(10000).optional(),
});

export const deleteNoteSchema = z.object({
  id: z.string().min(1, "Note ID is required"),
});

/**
 * TypeScript types inferred from Zod schemas
 * This ensures our types always match our validation rules
 */
export type CreateNoteInput = z.infer<typeof createNoteSchema>;
export type UpdateNoteInput = z.infer<typeof updateNoteSchema>;
export type DeleteNoteInput = z.infer<typeof deleteNoteSchema>;

/**
 * MongoDB document structure
 * This represents how data is stored in the database
 */
export interface NoteResponse {
  _id: ObjectId; // MongoDB's internal ID
  title: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
}

/**
 * Client-facing note type
 * This represents how data is sent to the browser
 */

export interface Note {
  id: string; // ObjectId converted to string
  title: string;
  content: string;
  createdAt: string; // Date converted to ISO string
  updatedAt: string; // Date converted to ISO string
}

/**
 * Document type without _id (for insertOne)
 */
export type NoteDocument = Omit<NoteResponse, "_id">;

/**
 * Converter function: MongoDB document β†’ Client-friendly note
 *
 * This is important because:
 * - ObjectId isn't JSON-serializable
 * - Dates need to be ISO strings for JSON
 */

export function documentToNote(doc: NoteResponse): Note {
  return {
    id: doc._id.toString(),
    title: doc.title,
    content: doc.content,
    createdAt: doc.createdAt.toISOString(),
    updatedAt: doc.updatedAt.toISOString(),
  };
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we are using Zod for runtime validation and TypeScript type inference. This gives us both compile-time and runtime type safety.

Building server functions for CRUD operations

Now that our database connection layer is in place, let's create server functions for CRUD operations. To create the server functions for our Notes app, go to your src/server/notes.ts file and add the following code.

import { createServerFn } from "@tanstack/react-start";
import { ObjectId } from "mongodb";
import { getNotesCollection } from "../lib/mongodb";
import {
  createNoteSchema,
  updateNoteSchema,
  deleteNoteSchema,
  documentToNote,
  type Note,
  type NoteDocument,
} from "../lib/types";

export const getNotes = createServerFn({ method: "GET" }).handler(
  async (): Promise<Note[]> => {
    try {
      const collection = await getNotesCollection();

      // Query database with sort
      const docs = await collection.find({}).sort({ updatedAt: -1 }).toArray();

      // Convert MongoDB documents to client-friendly format
      return docs.map(documentToNote);
    } catch (error) {
      console.error("Error fetching notes:", error);
      throw new Error("Failed to fetch notes");
    }
  }
);

export const createNote = createServerFn({ method: "POST" })
  .inputValidator(createNoteSchema) // ← Automatic validation!
  .handler(async ({ data }) => {
    try {
      const collection = await getNotesCollection();
      const now = new Date();

      // Create note document
      const newNote: NoteDocument = {
        title: data.title,
        content: data.content,
        createdAt: now,
        updatedAt: now,
      };

      // Insert into database
      const result = await collection.insertOne(newNote as any);

      // Fetch the created note
      const created = await collection.findOne({ _id: result.insertedId });

      if (!created) {
        throw new Error("Note created but could not be retrieved");
      }

      return documentToNote(created);
    } catch (error) {
      console.error("Error creating note:", error);
      throw new Error("Failed to create note");
    }
  });

export const updateNote = createServerFn({ method: "POST" })
  .inputValidator(updateNoteSchema)
  .handler(async ({ data }) => {
    try {
      const collection = await getNotesCollection();

      // Build update object (only include provided fields)
      const updateFields: Partial<Omit<NoteDocument, "_id">> = {
        updatedAt: new Date(),
      };

      if (data.title !== undefined) updateFields.title = data.title;
      if (data.content !== undefined) updateFields.content = data.content;

      // Update in database
      const result = await collection.findOneAndUpdate(
        { _id: new ObjectId(data.id) },
        { $set: updateFields },
        { returnDocument: "after" }
      );

      if (!result) {
        throw new Error("Note not found");
      }

      return documentToNote(result);
    } catch (error) {
      console.error("Error updating note:", error);
      if (error instanceof Error && error.message === "Note not found") {
        throw error;
      }
      throw new Error("Failed to update note");
    }
  });

export const deleteNote = createServerFn({ method: "POST" })
  .inputValidator(deleteNoteSchema)
  .handler(async ({ data }) => {
    try {
      const collection = await getNotesCollection();

      const result = await collection.deleteOne({
        _id: new ObjectId(data.id),
      });

      if (result.deletedCount === 0) {
        throw new Error("Note not found");
      }

      return { success: true };
    } catch (error) {
      console.error("Error deleting note:", error);
      if (error instanceof Error && error.message === "Note not found") {
        throw error;
      }
      throw new Error("Failed to delete note");
    }
  });
Enter fullscreen mode Exit fullscreen mode

These functions are created with createServerFn, which lets us define logic that runs only on the server in TanStack Start. Even though we can call them from the client, the code itself never leaves the server, so database access stays secure. We also use Zod to validate incoming data before it touches the database, and a shared MongoDB client to efficiently reuse connections, which is especially important in serverless environments.

Before returning data to the client, MongoDB documents are converted into a JSON-safe format, so the UI always receives consistent, serializable data. This approach eliminates the need for separate API routes and manual data fetching, letting you focus on business logic while TanStack Start handles the rest. This is where TanStack Start really shines!

Creating the UI with real-time updates

With our server functions in place, we can now build the user interface that consumes them. In this step, we'll create the main Notes page, wire it up to our server functions, and ensure the UI stays in sync with the database as users create, update, and delete notes.

The home route for the app is going to live in src/routes/index.tsx. This file will define the Notes route and UI and act as the main entry point for displaying and managing notes. Since the implementation is fairly long, I've linked the full file on GitHub instead of pasting it here. So, go to this GitHub file, then copy the code and paste it into your src/routes/index.tsx file.

The code you just added uses TanStack Router's loader to fetch notes on the server before the page renders. That means the initial list of notes is already available when the page loads, giving us fast and server-rendered content by default. On the client side, we keep a local copy of the notes in React state. Whenever a user creates, updates, or deletes a note, we call the corresponding server function (createNote, updateNote, or deleteNote) and then refresh the notes from the database. This gives us instant, real-time updates (instant UI updates driven by server functions) without needing WebSockets or complex state management. The key takeaway here is how TanStack Start allows the UI to communicate directly with server functions, eliminating the need for manual API routes while keeping everything fully type-safe.

The src/routes/index.tsx file in our Notes app

The frontend and backend are now connected, so let's see what our Notes app looks like. To do this, run npm run dev in your terminal and go to http://localhost:3000 in your browser. You should see a form to create new notes, be able to edit and delete notes, and see immediate UI updates when changes are made.

Version one of the Notes app built with TanStack Start and MongoDB

Try creating a new note. If everything is set up correctly, it should appear instantly. The data is persisted in MongoDB, and the UI stays in sync automatically thanks to TanStack Start's server functions. Here's what the app looks like with a few notes created πŸ‘‡

The full-stack Notes app built with TanStack Start and MongoDB

Implementing SSR and routing

At this point, we have a fully functional Notes app with server functions handling all database operations. The final step is to configure routing and server-side rendering, which ties everything we've built so far together.

As you've already seen with route loaders, TanStack Start runs data fetching on the server for the initial request and sends fully rendered HTML to the browser. Subsequent navigations run loaders on the client, call server functions as needed, and update the UI without a full-page reload. This gives us fast first loads, SEO-friendly pages, and smooth client-side navigation by default.

To configure this, go to your src/router.tsx and add the following code:

import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "./components/DefaultCatchBoundary";
import { NotFound } from "./components/NotFound";

/**
 * Create and configure the application router
 */
export function getRouter() {
  const router = createRouter({
    routeTree,
    defaultPreload: "intent", // Preload on hover
    defaultErrorComponent: DefaultCatchBoundary,
    defaultNotFoundComponent: () => <NotFound />,
    scrollRestoration: true,
  });
  return router;
}

/**
 * Type declaration for full type inference
 */
declare module "@tanstack/react-router" {
  interface Register {
    router: ReturnType<typeof getRouter>;
  }
}
Enter fullscreen mode Exit fullscreen mode

This setup gives us file-based routing through the generated routeTree, server-side rendering on the initial page load, and smooth client-side navigation with automatic data fetching. Things like error handling, 404 pages, scroll restoration, and full TypeScript inference all work out of the box and plug directly into the loaders and server functions we set up earlier.

To confirm that server-side rendering is working, run:

npm run build
npm run preview
Enter fullscreen mode Exit fullscreen mode

Open the generated preview URL in your browser, right-click the page, and select "View the page source." There, you should see a fully rendered HTML containing your notes, not just an empty root element. This confirms that your data is fetched on the server and sent as HTML.

At this point, you have a full-stack application built with TanStack Start and MongoDB. From here, you can build on this foundation by adding authentication, pagination, optimistic updates, or deployment optimizations as your application grows.

Summary

TanStack Start is pushing full-stack development in a more unified direction, where routing, data fetching, server logic, and rendering all live in the same place and work together naturally. Rather than hiding complexity behind heavy conventions, it gives developers clear, composable building blocks, along with strong performance and end-to-end type safety.

In this article, we used TanStack Start with MongoDB to build a full-stack Notes app and saw how server functions, loaders, and server-side rendering come together in a real project. MongoDB fits neatly into this setup through server functions, making it a good option for teams that like document-based data models and want explicit control over their backend.

As the React ecosystem continues to evolve, tools like TanStack Start hint at a future where full-stack development feels simpler, more transparent, and more flexible without sacrificing power or control.

Key takeaways

  • TanStack Start offers lightweight full-stack development with strong type safety.
  • MongoDB integrates naturally with TanStack Start's server function model using reusable connections.
  • Server functions replace traditional API routes while keeping data access secure.

Top comments (0)