DEV Community

Cover image for StoryBlobs: An adventure with Netlify Blobs
Rajeev R. Sharma
Rajeev R. Sharma Subscriber

Posted on

StoryBlobs: An adventure with Netlify Blobs

This is a submission for the Netlify Dynamic Site Challenge: Build with Blobs.

What I Built

StoryBlobs 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

Home Feed

story blobs home feed

Story Page

story page

Create Story Page

create story page

Login Page

Login page

Mobile Display

story blobs home feed mobile

story blobs sidebar mobile

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)

enum BlobStores {
  User = 'users',
  Stories = 'stories',
  Images = 'images',
}
Enter fullscreen mode Exit fullscreen mode

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.

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);
};
Enter fullscreen mode Exit fullscreen mode

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.

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

  return await store.get(email, { type: 'json' });
};
Enter fullscreen mode Exit fullscreen mode

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

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

// for story blobs
const key = '<story-slug>/<blob-id>'
Enter fullscreen mode Exit fullscreen mode

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

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]);
};
Enter fullscreen mode Exit fullscreen mode

Add to Story

export const addToStory = async (slug: string, blob: StoryBlob) => {
  const store = getStore(BlobStores.Stories);

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

  await store.setJSON(blobKey, blob);
};
Enter fullscreen mode Exit fullscreen mode

Get Story by Slug

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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.

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;
};
Enter fullscreen mode Exit fullscreen mode

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

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

Image Upload Server Side

// ...
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);
// ...
Enter fullscreen mode Exit fullscreen mode

The fileName created above is used as the key for saving the blob

export const saveImage = async (key: string, file: File) => {
  const store = getStore(BlobStores.Images);

  return await store.set(key, file);
};
Enter fullscreen mode Exit fullscreen mode

While reading back the image file we read it as a blob so that everything works as expected

export const getImage = (key: string) => {
  const store = getStore(BlobStores.Images);

  return store.get(key, { type: 'blob' });
};
Enter fullscreen mode Exit fullscreen mode

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

Nuxt UI Minimal Starter

Look at Nuxt docs and Nuxt UI docs to learn more.

Setup

Make sure to install the dependencies:

# npm
npm install

# pnpm
pnpm install

# yarn
yarn install

# bun
bun install
Enter fullscreen mode Exit fullscreen mode

Development Server

Start the development server on http://localhost:3000:

# npm
npm run dev

# pnpm
pnpm run dev

# yarn
yarn dev

# bun
bun run dev
Enter fullscreen mode Exit fullscreen mode

Production

Build the application for production:

# npm
npm run build

# pnpm
pnpm run build

# yarn
yarn build

# bun
bun run build
Enter fullscreen mode Exit fullscreen mode

Locally preview production build:

# npm
npm run preview

# pnpm
pnpm run preview

# yarn
yarn preview

# bun
bun run preview
Enter fullscreen mode Exit fullscreen mode

Check out the deployment documentation for more information.

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, 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

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

const val = btoa(JSON.stringify(obj))
Enter fullscreen mode Exit fullscreen mode

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...

Top comments (0)