DEV Community

Cover image for SolidStart: Integration with MongoDB
Damien Le Dantec for Zenika

Posted on

SolidStart: Integration with MongoDB

Introduction

SolidStart is a meta-framework based on SolidJS and Solid router (Like Next.js for React or Nuxt for VueJS).

It is still in Beta but we can create great applications already.
In this article, we will see how to integrate a MongoDB database with SolidStart.

To illustrate the method, we will create a basic application: a "Shopping list" webapp.

Image description

We assume you have a running MongoDB instance accessible on port 27017.

Preparation

Before integrating MongoDB, we need to prepare our application.

Generate the application

First, we generate a SolidStart application. We use the recommended starter:

mkdir shopping-list && cd shopping-list
npm init solid@latest
Enter fullscreen mode Exit fullscreen mode

And select the bare template:

? Which template do you want to use?
>    bare
     hackernews
     todomvc
     with-auth
     with-authjs
     with-mdx
     with-prisma
     with-solid-styled
     with-tailwindcss
     with-vitest
Enter fullscreen mode Exit fullscreen mode

In the rest of the template generator, we select the usage of Server Side Rendering and TypeScript.

Finally, we install NPM dependencies and run dev script:

npm install
npm run dev -- --open
Enter fullscreen mode Exit fullscreen mode

At this step, our application is ready to be developed!

Before adding MongoDB integration, we prepare our application by making some changes.

Model

We create the ShoppingListItem TypeScript type by creating the file src/models/shopping.ts with the following content:

src/models/shopping.ts

export type ShoppingListItem = {
  _id: string;
  label: string;
};

export type NewShoppingListItem = Omit<ShoppingListItem, "_id">;
Enter fullscreen mode Exit fullscreen mode

Style

We add some CSS at the end of src/root.css.

src/root.css

ul {
  display: flex;
  flex-direction: column;
  align-items: baseline;
}

li {
  margin-top: 10px;
}

li > button {
  margin-left: 10px;
}
Enter fullscreen mode Exit fullscreen mode

Components

To help us in our app, we create a ListInput component that lets us type a new item. We create src/components/ListInput.tsx:

src/components/ListInput.tsx

import { createSignal } from "solid-js";

interface ListInputProps {
  onValidate: (value: string) => void;
}

export default function ListInput(props: ListInputProps) {
  const [value, setValue] = createSignal("");

  const onValidateValue = () => {
    props.onValidate(value());
    setValue("");
  };

  return (
    <div>
      <input
        value={value()}
        onInput={(evt) => setValue(evt.currentTarget.value)}
        type="text"
        placeholder="New item"
        onKeyPress={(evt) => {
          if (evt.key === "Enter") {
            evt.preventDefault();
            onValidateValue();
          }
        }}
      />

      <button type="button" onClick={onValidateValue}>
        Add
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Remove the navigation header

The default navigation header from the template is not necessary, so we delete it.
To do this, you just need to remove the <A /> tags in the src/root.tsx file.

index.tsx page

At this step, we handle the shopping list item with createSignal. No persistence, but the logic works.

We replace src/routes/index.tsx with the following content:

src/routes/index.tsx

import { createSignal, For } from "solid-js";
import ListInput from "~/components/ListInput";
import { ShoppingListItem } from "~/models/shopping";

export default function Home() {
  const [items, setItems] = createSignal<ShoppingListItem[]>([]);

  const addNewItem = (label: string) => {
    setItems((prev) => [...prev, { label, _id: Date.now().toString() }]);
  };

  const deleteItem = (itemId: string) => {
    setItems((prev) => prev.filter((item) => item._id !== itemId));
  };

  return (
    <main>
      <ul>
        <For each={items()}>
          {(item) => (
            <li>
              <span>{item.label}</span>
              <button type="button" onClick={() => deleteItem(item._id)}>
                Delete
              </button>
            </li>
          )}
        </For>
      </ul>
      <ListInput onValidate={addNewItem} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add this point, the application works without state persistence.

Integration of MongoDB

Add MongoDB

First, we install mongodb and @types/mongodb NPM dependencies:

npm install --save mongodb
npm install --save-dev @types/mongodb
Enter fullscreen mode Exit fullscreen mode

Then, we create a class to manage connection with the database. We create the src/lib/database.ts with the following content:

src/lib/database.ts

import { Db, MongoClient } from "mongodb";

const MONGODB_HOST = "localhost";
const MONGODB_PORT = 27017;

export class Database {
  private db?: Db;
  private static instance: Database;

  private constructor() {}

  async init() {
    const client = new MongoClient(`mongodb://${MONGODB_HOST}:${MONGODB_PORT}`);
    await client.connect();
    this.db = client.db("shopping-list");
  }

  public static getInstance() {
    if (!Database.instance) {
      Database.instance = new Database();
    }

    return Database.instance;
  }

  getDb() {
    if (!this.db) {
      throw new Error("DB is not init");
    }

    return this.db;
  }
}
Enter fullscreen mode Exit fullscreen mode

Init the database connection

When our application is launched, we need to launch the database connection.
This is done using the init() method of the Database class. The usage of the Singleton pattern helps us.

We edit the src/entry-server.tsx file to add the database initialization before the createHandler function:

src/entry-server.tsx

import {
  createHandler,
  renderAsync,
  StartServer,
} from "solid-start/entry-server";
import { Database } from "./lib/database";

// Initialization of the database (connection + retrieve db instance)
const database = Database.getInstance();
database.init();

export default createHandler(
  renderAsync((event) => <StartServer event={event} />)
);
Enter fullscreen mode Exit fullscreen mode

Create a ShoppingService to interact with the Database

We create a ShoppingService to interact with the Database class and add / list / remove shopping list items.

You can create the file src/lib/shopping-service.ts with the following content:

src/lib/shopping-service.ts

import { Db, ObjectId } from "mongodb";
import { NewShoppingListItem, ShoppingListItem } from "../models/shopping";
import { Database } from "./database";

export class ShoppingService {
  db: Db;
  collectionName = "items";
  private static instance: ShoppingService;

  private constructor() {
    this.db = Database.getInstance().getDb();
  }

  public static getInstance() {
    if (!ShoppingService.instance) {
      ShoppingService.instance = new ShoppingService();
    }

    return ShoppingService.instance;
  }

  async getAllItems(): Promise<ShoppingListItem[]> {
    const itemsFromDb = await this.db
      .collection(this.collectionName)
      .find()
      .toArray();
    return itemsFromDb.map((elt) => ({
      ...elt,
      _id: elt._id.toString(), // Get _id as String
    })) as ShoppingListItem[];
  }

  async addNewItem(item: NewShoppingListItem) {
    await this.db.collection(this.collectionName).insertOne(item);
  }

  async deleteItem(itemId: string) {
    await this.db
      .collection(this.collectionName)
      .deleteOne({ _id: new ObjectId(itemId) });
  }
}
Enter fullscreen mode Exit fullscreen mode

Integration with the front pages

At this point, we need to add the interactions between the ShoppingService and our index.tsx page. This will be done in 2 steps:

  1. Create the routeData to retrieve our items and use them via useRouteData
  2. Create the server side actions to add and delete items with createServerAction$

Retrieve our data using routeData

To retrieve data in our page, SolidStart uses routeData. We create a function named routeData and use createServerData$ to do actions on the server side and fetch our items.

In our component, we have the useRouteData function to retrieve our items.

To identify our request (and to be able to invalidate it when we update our data), we create a constant in the src/constants.ts file.

src/constants.ts

export const SHOPPING_ITEMS_REQ_KEY = "shoppingItems";
Enter fullscreen mode Exit fullscreen mode

Then, we create the function routeData in our src/routes/index.tsx file:

src/routes/index.tsx

import { createServerData$ } from "solid-start/server";
import { ShoppingService } from "~/lib/shopping-service";
import { ShoppingListItem } from "~/models/shopping";
import { SHOPPING_ITEMS_REQ_KEY } from "~/constants";

export function routeData() {
  return createServerData$(
    async () => {
      const shoppingService = ShoppingService.getInstance();
      const data = await shoppingService.getAllItems();
      return data as ShoppingListItem[];
    },
    { key: SHOPPING_ITEMS_REQ_KEY }
  );
}

export default function Home() {
  // Content of our page
}
Enter fullscreen mode Exit fullscreen mode

In routeData, we use ShoppingService to fetch our items and return them.
To use our retrieved items, we just need to use useRouteData in our Home component:

src/routes/index.tsx

import { useRouteData } from "solid-start";

export default function Home() {
  const items = useRouteData<typeof routeData>();

  // Rest of our component
}
Enter fullscreen mode Exit fullscreen mode

Because we have duplication due to our previous createSignal logic, we can delete the createSignal function and edit our addNewItem and deleteItem like below:

src/routes/index.tsx

import { useRouteData } from "solid-start";

export default function Home() {
  const items = useRouteData<typeof routeData>();

  const addNewItem = (label: string) => {};
  const deleteItem = (itemId: string) => {};

  // Rest of our component
}
Enter fullscreen mode Exit fullscreen mode

For now, our items are retrieved from the database! (But we have no data in the database, we will now add actions to add / delete items to fix this)

Add server side actions

For add and delete actions, we will create some functions that will be executed on the server side. Look at the functions under the routeData function:

src/routes/index.tsx

import { ShoppingListItem, NewShoppingListItem } from "~/models/shopping";

export function routeData() {
  // routeData content
}

async function addNewItemAction(newItem: NewShoppingListItem) {
  const shoppingService = ShoppingService.getInstance();
  await shoppingService.addNewItem(newItem);
}

async function deleteItemAction(itemId: string) {
  const shoppingService = ShoppingService.getInstance();
  await shoppingService.deleteItem(itemId);
}
Enter fullscreen mode Exit fullscreen mode

These functions will be called by our Home component using createServerAction$ that let us call a server side function. It is pecially designed for updating data because this function lets us indicate which data we want to invalidate (to let SolidStart automatically re-fetch them).

We add our actions in our Home component, under our useRouteData like this:

src/routes/index.tsx

import { createServerAction$ } from "solid-start/server";
import { SHOPPING_ITEMS_REQ_KEY } from "~/constants.ts";

export default function Home() {
  const items = useRouteData<typeof routeData>();
  const [_adding, addNewItem] = createServerAction$(addNewItemAction, {
    invalidate: SHOPPING_ITEMS_REQ_KEY,
  });
  const [_deleting, deleteItem] = createServerAction$(deleteItemAction, {
    invalidate: SHOPPING_ITEMS_REQ_KEY,
  });

  // Rest of our component
}
Enter fullscreen mode Exit fullscreen mode

We also can delete our empty addNewItem and deleteItem functions. They are replaced by our server actions. For more details about the usage of our actions, please refer to the documentation.

In a nutshell, createServerAction$ is returning a tuple containing:

  1. In first index: The state of the action (pending, error, etc...)
  2. In the second index: The function that will call our server side action

In our case, we are not using the first index of the tuple.

In options, we also indicate that we invalidate data of the fetch request (using SHOPPING_ITEMS_REQ_KEY).

Finally, we update the onValidate prop of ListInput to match parameters:

src/routes/index.tsx

<ListInput onValidate={(label: string) => addNewItem({ label })} />
Enter fullscreen mode Exit fullscreen mode

And thats it!

Our component looks like this:

src/routes/index.tsx

import { For } from "solid-js";
import { useRouteData } from "solid-start";
import { createServerAction$, createServerData$ } from "solid-start/server";
import ListInput from "~/components/ListInput";
import { ShoppingService } from "~/lib/shopping-service";
import { NewShoppingListItem, ShoppingListItem } from "~/models/shopping";
import { SHOPPING_ITEMS_REQ_KEY } from "~/constants.ts";

export function routeData() {
  return createServerData$(
    async () => {
      const shoppingService = ShoppingService.getInstance();
      const data = await shoppingService.getAllItems();
      return data as ShoppingListItem[];
    },
    { key: SHOPPING_ITEMS_REQ_KEY }
  );
}

async function addNewItemAction(newItem: NewShoppingListItem) {
  const shoppingService = ShoppingService.getInstance();
  await shoppingService.addNewItem(newItem);
}

async function deleteItemAction(itemId: string) {
  const shoppingService = ShoppingService.getInstance();
  await shoppingService.deleteItem(itemId);
}

export default function Home() {
  const items = useRouteData<typeof routeData>();
  const [_adding, addNewItem] = createServerAction$(addNewItemAction, {
    invalidate: SHOPPING_ITEMS_REQ_KEY,
  });
  const [_deleting, deleteItem] = createServerAction$(deleteItemAction, {
    invalidate: SHOPPING_ITEMS_REQ_KEY,
  });

  return (
    <main>
      <ul>
        <For each={items()}>
          {(item) => (
            <li>
              <span>{item.label}</span>
              <button type="button" onClick={() => deleteItem(item._id)}>
                Del
              </button>
            </li>
          )}
        </For>
      </ul>
      <ListInput onValidate={(label: string) => addNewItem({ label })} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Now you know how to integrate MongoDB with SolidStart!

We have seen how to:

  • Generate a SolidStart app with the starter
  • Use TypeScript with it
  • Create a MongoDB connection
  • Do server side requests to MongoDB from our components

Feel free to comment to give me your feedback or provide some improvements.

Thanks for reading!

Top comments (0)