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:
- A user starts a story by adding a title, the story premise and the opening paragraph, and an optional cover image
- On publishing the story it becomes visible in the home feed
- 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 Page
Create Story Page
Login Page
Mobile Display
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',
}
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);
};
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' });
};
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>'
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]);
};
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);
};
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,
};
};
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;
};
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;
}
};
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);
// ...
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);
};
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' });
};
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
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
Production
Build the application for production:
# npm
npm run build
# pnpm
pnpm run build
# yarn
yarn build
# bun
bun run build
Locally preview production build:
# npm
npm run preview
# pnpm
pnpm run preview
# yarn
yarn preview
# bun
bun run preview
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))
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)