StoryBlobs: An adventure with Netlify Blobs
Tags: netlifychallenge, devchallenge, webdev, javascript
Published: 2024-05-12 20:25:21

*This is a submission for the [Netlify Dynamic Site Challenge](https://dev.to/challenges/netlify): Build with Blobs.*


## What I Built
[StoryBlobs](https://storyblobs.netlify.app/) is collaborative story writing app. The way it works is:

1. A user starts a story by adding a title, the story premise and the opening paragraph, and an optional cover image
2. On publishing the story it becomes visible in the home feed
3. Now any one can read the story till now, and continue writing it after logging in

## Demo
Here is the live app: [StoryBlobs](https://storyblobs.netlify.app/)

### Home Feed
![story blobs home feed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p1yeb7eax7eklhtqgsys.png)

### Story Page

![story page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mp2wukhrtyihkydifsea.png)

### Create Story Page

![create story page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qf9d7dazmn40gc6f0rnq.png)

### Login Page

![Login page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8euub8mwvxwvb2julhak.png)

### Mobile Display

![story blobs home feed mobile](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/491tycnmg8lmui3cffr2.png)

![story blobs sidebar mobile](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g0baorrmutdh4tdxe9kg.png)

## Platform Primitives
To remain true to the challenge's objective of utilizing Netlify Platform primitives, StoryBlobs doesn't use any external database or storage. 

It uses Netlify Blobs not only to store the app's users details and story blobs, but also the story cover images. Of course, the skeptic in me says this might not be ideal for a production load, but we're in it for the fun, right?

So how is everything tied up together? Let's see.

The app uses three different `blob namespaces` (`stores`, in Netlify Blobs lingo)

```ts
enum BlobStores {
  User = 'users',
  Stories = 'stories',
  Images = 'images',
}
```

### User Data Handling
To handle `email/password` login, for every new user create, 2 entries are added to the `users` store, one using `id` as the key, while the other one using `email`. The latter is used to query against email to check for an existing user. Here is the `createUser` function.

```ts
export const createUser = async (user: User & { password: string }) => {
  const store = getStore(BlobStores.User);

  const existingUser = await store.getMetadata(user.email, {
    consistency: 'strong',
  });
  if (existingUser) {
    throw new Error('User already exists');
  }

  // save with email key
  await store.setJSON(user.email, user);

  // also save with id key
  await store.setJSON(user.id, user);
};
```

Write now I'm not doing query against `id` and have put it for future usage. Old habits die hard you say?

Now you can pretty much guess the login functionality. We fetch the user, verify against the hashed password and done.

```ts
export const getUserByEmail = async (email: string) => {
  const store = getStore(BlobStores.User);

  return await store.get(email, { type: 'json' });
};
```

### Story Text Data Handling
Any story consists of one story head (the meta part), and multiple story blobs written by same or multiple people. To organize the head, and other blobs the pattern used is as shown below

```ts
// for story head
const key = '<story-slug>'

// for story blobs
const key = '<story-slug>/<blob-id>'
```
Having the above pattern allows me to list all parts of a story using a store `list` method call with prefix `<story-slug>`.

Here are the relevant functions for this part

#### Create Story
```ts
export const createStory = async (story: Story) => {
  const store = getStore(BlobStores.Stories);

  await store.setJSON(story.head.slug, story.head);

  const blobKey = `${story.head.slug}${SEPARATOR}${story.blobs[0].id}`;

  await store.setJSON(blobKey, story.blobs[0]);
};
```

#### Add to Story
```ts
export const addToStory = async (slug: string, blob: StoryBlob) => {
  const store = getStore(BlobStores.Stories);

  const blobKey = `${slug}${SEPARATOR}${blob.id}`;

  await store.setJSON(blobKey, blob);
};
```

#### Get Story by Slug

```ts
export const getStoryBySlug = async (slug: string) => {
  const store = getStore(BlobStores.Stories);

  let head;
  const parts = [];
  const { blobs } = await store.list({ prefix: slug });

  for (const blob of blobs) {
    const part = await store.get(blob.key, { type: 'json' });
    if (part.slug) {
      head = part;
    } else {
      parts.push(part);
    }
  }

  return {
    head,
    blobs: parts,
  };
};
```

#### Get All Stories
While populating the home page feed, we just want the story heads of different stories. To achieve this we can turn on the `directories` flag while making the store list call.

```ts
export const getAllStories = async () => {
  const store = getStore(BlobStores.Stories);

  const { blobs } = await store.list({ directories: true });
  const promises = [];
  for (const blob of blobs) {
    promises.push(store.get(blob.key, { type: 'json' }));
  }

  const stories = await Promise.all(promises);
  stories.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
  return stories;
};
```

### Story Cover Image Handling
While publishing a story we first save the image as a blob with a new key of the form of `${cuid}.${image file extension}`. Here are the relevant code parts for image handling

#### Image Upload from Nuxt Frontend
```ts
const uploadCover = async () => {
  // selectedFile.value is a File object
  if (selectedFile.value) {
    const formData = new FormData();
    formData.append('file', selectedFile.value);

    const res = await $fetch('/api/images/upload', {
      method: 'POST',
      body: formData,
    });

    return res.fileName;
  }
};
```

#### Image Upload Server Side

```ts
// ...
const formData = await readFormData(event);
const file = formData.get('file') as File;
const extension = file.name.split('.').pop();
const fileName = `${createId()}.${extension}`;

await saveImage(fileName, file);
// ...
```

The fileName created above is used as the key for saving the blob
```ts
export const saveImage = async (key: string, file: File) => {
  const store = getStore(BlobStores.Images);

  return await store.set(key, file);
};
```

While reading back the image file we read it as a blob so that everything works as expected
```ts
export const getImage = (key: string) => {
  const store = getStore(BlobStores.Images);

  return store.get(key, { type: 'blob' });
};
```

### Netlify Image CDN
Apart from Netlify Blobs, the app uses Netlify Image CDN to optimize image display. To do this, it uses the `@nuxt/image` package which is integrated with Netlify Image CDN. This makes it easier to do image manipulation on the fly.

### The Complete Source Code
{% embed https://github.com/ra-jeev/story-blobs %}

## Problems Faced
Some of the problems I faced during the development of StoryBlobs.

### Nuxt Blobs Configuration Context
`StoryBlobs` is a Nuxt app deployed on Netlify (the whole app is deployed as a Netlify function) yet Netlify Blobs doesn't work out of the box. I got some configuration errors. 

As per the [Netlify Blobs repo](https://github.com/netlify/blobs), configuration context can be read automatically from the execution environment. Since that didn't happen, I assume that the `server` function created for the app is in Lambda Compatibility mode. So I need to call `connectLambda(event)` method. But the `event` types are a mismatch. 

Instead of putting more effort into finding out a solution, I simply set the `NETLIFY_BLOBS_CONTEXT` environment variable which is a `base64` encoded string created using the below code

```ts
const obj = {
  siteId: "<your_site_id>",
  token: "<your_personal_access_token>" //created from Netlify Dashboard
}

const val = btoa(JSON.stringify(obj))
```

Things started working as expected after setting the environment variable. 

**Do not forget to redeploy if no code was changed.**

### Lack of Directories flag support for local development
We can use almost every functionality of Netlify Blobs locally by using the Netlify CLI and running the `netlify dev` command from the app root dir. This sets the needed configuration context for Netlify Blobs.

But the pattern I used above (`directories`) doesn't work locally. On using the `'/'` separator locally, I got `500 Status Code` from Netlify Blobs. As an alternative, I tried using the `'#'` as separator and then querying with prefix `${slug}#`, but that also didn't work and returned all entries starting with `${slug}`. So had to put some workarounds for local testing.

## Before we part
Despite the above issues, working with Netlify Blobs was quite smooth. If they can support sorting the listing results natively, and maybe add a bulk get method, one can go quite far using Netlify Blobs :-).

I hope you enjoyed reading the post. Please do say "hi" in the comments section, to keep the conversation going.

Until next time...