January 4, 2024

Building your own Image Gallery with Remix, HarperDB, ImageKit and Vercel

Welcome to Community Posts
Click below to read the full article.
Arrow
Summary of What to Expect
Table of Contents

In this post, I talk about how to create an Image Gallery with Remix, HarperDB, ImageKit and Vercel. We learn how to create dynamic routes in Remix, process form submissions on the server, handle client side image uploads, sync data with HarperDB and search conditionally using HarperDB NoSQL Operations.

Tech Stack

Prerequisites

Setting up the project

To set up, just clone the app repo and follow this tutorial to learn everything that's in it. To fork the project, run:

git clone https://github.com/rishi-raj-jain/remix-imagekit-harperdb-image-gallery
cd remix-imagekit-harperdb-image-gallery
npm install

Once you have cloned the repo, you are going to create a .env file. You are going to add the values we obtain from the steps below.

Setting up HarperDB

Let’s start by creating our database instance. Sign in to Harper Studio and click on Create New HarperDB Cloud Instance.

Fill in the database instance information, like here, we’ve added chatbot as the instance name with a username and password.

Go with the default instance setup for RAM and Storage Size while optimizing the Instance Region to be as close to your serverless functions region in Vercel.

After some time, you’d see the instance (here, image-gallery) ready to have databases and it’s tables. The dashboard would look something like as below:

Let’s start by creating a database (here, list) inside which we’ll spin our storage table, make sure to click the check icon to successfully spin up the database.

Let’s start by creating a table (here, collection) with a hashing key (here, id) which will be the named primary key of the table. Make sure to click the check icon to successfully spin up the table.

Once done,

  1. Open app/lib/harper.ts and update the database and table values per the names given above.
  2. Click on config at the top right corner in the dashboard, and:
  • Copy the Instance URL and save it as HARPER_DB_URL in your .env file
  • Copy the Instance API Auth Header and save it as HARPER_AUTH_TOKEN in your .env file

Awesome, you’re good to go. This is how the data looks for a record of each image.

Configuring NoSQL CRUD helpers for HarperDB for Vercel Edge and Middleware Compatibility

To interact with the HarperDB database, we’ll use NoSQL HarperDB REST APIs called over fetch. This approach will help us opt out of any specific runtime requirements, and keep things simple and ready to deploy to Vercel Edge and Middleware.

In the code below, we’ve defined the CRUD helpers, namely insert, update, deleteRecords, searchByValue and searchByConditions for respective actions.

// Function to make requests to HarperDB
const harperFetch = (body: { [k: string]: any }) => {
  // Check if HARPER_DB_URL environment variable is set
  if (!process.env.HARPER_DB_URL) {
    throw new Error('No HARPER_DB_URL environment variable found.')
  }
  // Make a POST request to HarperDB
  return fetch(process.env.HARPER_DB_URL, {
    method: 'POST',
    body: JSON.stringify({
      ...body,
      database: 'list',
      table: 'collection',
    }),
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Basic ' + process.env.HARPER_AUTH_TOKEN,
    },
  })
}

// Function to insert records into the database
export const insert = async (records: any[] = []) => {
  const t = await harperFetch({
    records,
    operation: 'insert',
  })
  if (!t.ok) return {}
  return await t.json()
}

// Function to update records in the database
export const update = async (records = []) => {
  await harperFetch({
    records,
    operation: 'update',
  })
}

// Function to delete records from the database
export const deleteRecords = async (ids = []) => {
  await harperFetch({
    ids,
    operation: 'delete',
  })
}

// Function to search for records by a specific value
export const searchByValue = async (search_value: string, search_attribute: string = 'id', get_attributes: string[] = ['*']) => {
  const t = await harperFetch({
    search_value,
    get_attributes,
    search_attribute,
    operation: 'search_by_value',
  })
  if (!t.ok) return []
  return await t.json()
}

// Function to search for records based on conditions
export const searchByConditions = async (conditions: any[] = [], get_attributes: string[] = ['*']) => {
  const t = await harperFetch({
    conditions,
    get_attributes,
    operator: 'or',
    operation: 'search_by_conditions',
  })
  if (!t.ok) return []
  return await t.json()
}

Handling Client Side Image Uploads with ImageKit

I was always intrigued by how very large images were uploaded with forms on the server. Then reality hit me, they are not. Large files are usually uploaded from the client side directly to the Storage, and the asset URL obtained is then used in form processing on the server.

Using ImageKit React, handling client side image uploads to ImageKit storage is a cakewalk. Just initialize the ImageKit Context (IKContext) with authenticator endpoint (here, /imagekit) and url endpoint to your particular ImageKit Instance URL (here, https://ik.imagekit.io/vjeqenuhn). Programmatically, click the hidden IKUpload component which handles the callback process of uploading the asset directly to ImageKit storage and you’re done!

// File: app/components/Upload.tsx

import { useState } from 'react'
import UploadIcon from './Upload-Icon'
import { UploadProps } from '@/lib/types'
import { IKContext, IKUpload } from 'imagekitio-react'

export default function ({ selector, className }: UploadProps) {
  const [uploadedImage, setUploadedImage] = useState()
	const [uploadedImageH, setUploadedImageH] = useState()
  const [uploadedImageW, setUploadedImageW] = useState()

  const publicKey = 'public_yV9dop1iOZyFb2FnBjsdQpB+rXQ='

  const openLoader = () => {
    const tmp = document.querySelector('#' + selector) as HTMLInputElement
    if (tmp) tmp.click()
  }

  return (
    <IKContext
      publicKey={publicKey}
      urlEndpoint="https://ik.imagekit.io/vjeqenuhn"
      authenticator={async () => {
        return await (await fetch('/imagekit')).json()
      }}
    >
			{uploadedImageH && <input value={uploadedImageH} className="hidden" id={attribute + '_h'} name={attribute + '_h'} />}
      {uploadedImageW && <input value={uploadedImageW} className="hidden" id={attribute + '_w'} name={attribute + '_w'} />}
      <div
        onClick={openLoader}
        className={[className, uploadedImage && 'hidden', 'cursor-pointer border rounded flex flex-col items-center justify-center py-12'].filter((i) => i).join(' ')}
      >
        <UploadIcon />
        <span>Upload Image</span>
        <IKUpload
          id={selector}
          className="hidden"
          useUniqueFileName={true}
					onClick={() => {
            setUploadedImage(undefined)
            setUploadedImageW(undefined)
            setUploadedImageH(undefined)
          }}
          onSuccess={(res) => {
            const { url, height, width } = res
            setUploadedImage(url)
            setUploadedImageW(width)
            setUploadedImageH(height)
          }}
        />
      </div>
    </IKContext>
  )
}

To setup ImageKit Authenticator Endpoint for client side uploads, we’ll use the imagekit package. As the authenticator configured in the IKContext is pointing to /imagekit as the endpoint, we’re gonna create app/routes/imagekit.tsx to serve the requests.

First, we initialise the ImageKit instance and then use the baked-in getAuthenticationParameters function to serve the required JSON response while authentication is performed for initiating client side uploads.

// File: app/routes/imagekit.tsx

import ImageKit from 'imagekit'

export async function loader() {
  // Check if IMAGEKIT_PUBLIC_KEY and IMAGEKIT_PRIVATE_KEY environment variables are set
	if (!process.env.IMAGEKIT_PUBLIC_KEY || !process.env.IMAGEKIT_PRIVATE_KEY) {
    return new Response(null, { status: 500 })
  }
	// Create an ImageKit instance with provided credentials and URL endpoint
  var imagekit = new ImageKit({
    publicKey: process.env.IMAGEKIT_PUBLIC_KEY,
    privateKey: process.env.IMAGEKIT_PRIVATE_KEY,
    urlEndpoint: 'https://ik.imagekit.io/vjeqenuhn',
  })
	// Return a JSON response containing ImageKit authentication parameters
  return new Response(JSON.stringify(imagekit.getAuthenticationParameters()), {
    headers: {
      'Content-Type': 'application/json',
    },
  })
}

Handling Form Submissions in Remix

With Remix, Route Actions are the way to perform data mutations (such as processing form POST request) with web standard & semantics and loaded with DX of modern frameworks. Here’s how we’ve enabled form actions with Remix in app/routes/index.tsx 👇🏻

// File: app/routes/index.tsx

import { Form } from '@remix-run/react'
import Upload from '@/components/Upload'
import { ActionFunctionArgs, redirect } from '@remix-run/node'

// Process a Form POST submission via Remix Actions
export async function action({ request }: ActionFunctionArgs) {

	// Obtain form fields as object
	const body = await request.formData()

	// Get value associated with each <input> element
	const alt = body.get('alt') as string
  const slug = body.get('slug') as string
  const name = body.get('name') as string
  const tagline = body.get('tagline') as string
  const photographURL = body.get('_photograph') as string
  const photographWidth = body.get('_photograph_w') as string
  const photographHeight = body.get('_photograph_h') as string
	const photographerURL = body.get('_photographer-image') as string
  const photographerWidth = body.get('_photographer-image_w') as string
  const photographerHeight = body.get('_photographer-image_h') as string
	
	// Create Blur Images Base64 String
	// Insert record with all detail for a given image into HarperDB
}

export default function Index() {
  return (
    <Form navigate={false} method="post">
      <span>Upload New Photograph</span>
      <span>Photographer's Image</span>
      <Upload selector="photographer-image" />
      <span>Photographer's Name</span>
      <input autoComplete="off" id="name" name="name" placeholder="Photographer's Name" />
      <span>Photograph's Tagline</span>
      <input autoComplete="off" id="tagline" name="tagline" placeholder="Photograph's Tagline" />
      <span>Photograph</span>
      <Upload selector="photograph" />
      <span>Photograph's Alt Text</span>
      <input autoComplete="off" id="alt" name="alt" placeholder="Photograph's Alt Text" />
      <span>Slug</span>
      <input autoComplete="off" id="slug" name="slug" placeholder="Slug" />
      <button type="submit"> Publish → </button>
    </Form>
  )
}

Using ImageKit Image Transformations to create Blur Images

To create blur images from the image uploaded to ImageKit, we’re gonna use the ImageKit Image Transformations. By appending ?tr=bl-50 to the original image URL, ImageKit takes care of creating and responding with 50% blurred image. Further, we store the blurred image’s buffer as base64 string to be synced into the HarperDB database.

// File: app/routes/index.tsx

export async function action({ request }: ActionFunctionArgs) {
	...
	let photographDataURL, photographerDataURL
	if (photographURL) {
	    const tmp = await fetch(photographURL + '?tr=w-608,bl-50')
	    const buffer = Buffer.from(await tmp.arrayBuffer())
	    photographDataURL = `data:image/jpeg;base64,${buffer.toString('base64')}`
	}
	if (photographerURL) {
	  const tmp = await fetch(photographerURL + '?tr=w-608,bl-50')
	  const buffer = Buffer.from(await tmp.arrayBuffer())
	  photographerDataURL = `data:image/jpeg;base64,${buffer.toString('base64')}`
	}
	...
}

For creating images that are near to ideal for User Experience, we create the blur-up effect. By blurring-up an image, I’m referring to the images where the image is instantly visible but is blurred and one can’t see the content inside it, while the original (whole) image loads in parallel.

To create such blur-up effects, we set the base64 string of blur version of the image as the background, and image source as the original image’s remote URL.

// File: app/components/Image.tsx

import { ImageProps } from '@/lib/types'

export default function ({ url, alt, width, height, loading, className, backgroundImage }: ImageProps) {
  return (
    <img
      src={url}
      width={width}
      height={height}
      alt={alt || ''}
      decoding="async"
      loading={loading || 'lazy'}
      style={{ backgroundImage: `url(${backgroundImage || url + '?tr=bl-50'})`, transform: 'translate3d(0, 0, 0)' }}
      className={[className, 'bg-cover bg-center bg-no-repeat transform will-change-auto'].filter((i) => i).join(' ')}
    />
  )
}

Insert Image Record(s) in the HarperDB Database

Great! Now, we’ve obtained all the attributes related to the photograph, including the blurred version of the photograph and photographer’s details. The last step in syncing this information to database is to insert it into the HarperDB database using Insert NoSQL Operation.

// File: app/routes/index.tsx

import { insert } from '@/lib/harper.server'

export async function action({ request }: ActionFunctionArgs) {
	// ...
	const { inserted_hashes } = await insert([
    {
      alt,
      slug,
      name,
      tagline,
      photographerURL,
      photographerWidth,
      photographerHeight,
      photographerDataURL,
      photographURL,
      photographWidth,
      photographHeight,
      photographDataURL,
    },
  ])
  if (inserted_hashes && inserted_hashes[0]) return redirect('/pics/' + slug)
}

Implementing Images Wide Search with HarperDB Search By Conditions Operation

For fetching all the image records, in the Route loader function, we search by value (HarperDB NoSQL operation) matching anything (denoted by *) from the records in HarperDB. This helps us show all the images as soon as someone opens up the /pics page.

To implement the search functionality, we make use of Remix Form Actions and HarperDB Search By Conditions NoSQL Operation. Via this operation, we're able to create our matching conditions and not only look for exact values in the records.

In our case, as one submits something into the search bar, the Route action is invoked with the request containing the search query. Using the searchByConditions helper, which basically looks if the search string is present as the substring in each attribute’s value in each record.

// File: app/routes/pics_._index.tsx

import { Record } from '@/lib/types'
import Image from '@/components/Image'
import { ActionFunctionArgs, json, redirect } from '@remix-run/node'
import { searchByConditions, searchByValue } from '@/lib/harper.server'
import { Form, Link, useActionData, useLoaderData } from '@remix-run/react'

// Fetch all records for the page load
export async function loader() {
  return await searchByValue('*', 'id')
}

// Filter from all records searching for substring in
// name, tagline, slug and alt attribute
export async function action({ request }: ActionFunctionArgs) {

	// Obtain form fields as object
  const body = await request.formData()

	// Get the search value entered into the search bar
  const search = body.get('search') as string
  if (!search) return redirect('/pics')
	
	// Get all the matching results from HarperDB
  const result = await searchByConditions(
    ['name', 'tagline', 'slug', 'alt'].map((i) => ({
      search_attribute: i,
      search_value: search,
      search_type: 'contains',
    }))
  )
  return json({ search, result })
}

export default function Pics() {
	// Fetch the GET route data
  const images = useLoaderData<typeof loader>()
	// Fetch the data returned from search query
  const actionData = useActionData<typeof action>()

  return (
    <div>
			{/* ... */}
			
			{/* If search query result is available, use that else fallback to all the images loaded */}
      {(actionData?.result || images)
        .filter((i: Record) => i.slug && i.photographURL && i.photographWidth)
        .sort((a: Record, b: Record) => (a.__updatedtime__ < b.__updatedtime__ ? 1 : -1))
        .map((i: Record, _: number) => (
          <Link key={_} to={'/pics/' + i.slug}>
            <div>
				      {/* Blur Up Lazy Load Photographer's Image */}
              <Image
                alt={i.name}
                url={i.photographerURL}
                width={i.photographerWidth}
                height={i.photographerHeight}
                backgroundImage={i.photographerDataURL}
              />
              <span>{i.name}</span>
            </div>
			      {/* Blur Up Eagerly Load First Photograph */}
            <Image
              alt={i.alt}
              url={i.photographURL}
              width={i.photographWidth}
              height={i.photographHeight}
              loading={_ === 0 ? 'eager' : 'lazy'}
              backgroundImage={i.photographDataURL}
            />
          </Link>
        ))}
    </div>
  )
}

Creating Dynamic Routes in Remix

To create a page dynamically for each image, we're gonna use Remix Dynamic Routes. To create a dynamic route, we use the $ symbol in the route’s filename.

Here, pics_.$id.tsx specify a dynamic route where each part of the URL for e.g. for /pics/1, /pics/2 or /pics/anything captures the last segment into id param.

Using the dynamic param (here, id) we fetch the record from HarperDB containing slug attribute equal to id value via loader function.

Using the data fetched from HarperDB, we use our Blur Up Image component to lazy load the photographer's image while eagerly load the photograph uploaded.

// File: app/routes/pics_.$id.tsx

import Image from '@/components/Image'
import { useLoaderData } from '@remix-run/react'
import { searchByValue } from '@/lib/harper.server'
import { LoaderFunctionArgs, redirect } from '@remix-run/node'

// Fetch the specific record for the image's page load
// Redirect to 404 if not found
export async function loader({ params }: LoaderFunctionArgs) {
  if (!params.id) return redirect('/404')
  const images = await searchByValue(params.id, 'slug')
  if (images && images[0]) return images[0]
  return redirect('/404')
}

export default function Pic() {
	// Fetch the GET route data
  const image = useLoaderData<typeof loader>()

  return (
    <div>
      {/* Display Image Attributes */}
			<span>{image.name}</span>
      <div>
	      {/* Blur Up Lazy Load Photographer's Image */}
        <Image
          alt={image.name}
          url={image.photographerURL}
          width={image.photographerWidth}
          height={image.photographerHeight}
          backgroundImage={image.photographerDataURL}
        />
        <div>
          <span>{image.name}</span>
          <span>{image.tagline}</span>
        </div>
      </div>
      {/* Blur Up Eagerly Load Photograph */}
      <Image
        alt={image.alt}
        loading="eager"
        url={image.photographURL}
        width={image.photographWidth}
        height={image.photographHeight}
        backgroundImage={image.photographDataURL}
      />
    </div>
  )
}

Whew! All done, let's deploy our Remix app to Vercel 👇

Deploy to Vercel

The repository is ready to deploy to Vercel. Follow the steps below to deploy seamlessly with Vercel 👇🏻

  • Create a GitHub Repository with the app code
  • Create a New Project in Vercel Dashboard
  • Link the created GitHub Repository as your new project
  • Scroll down and update the Environment Variables from the .env locally
  • Deploy! 🚀

References

Demo: https://remix-imagekit-harperdb-image-gallery.vercel.app/
GitHub Repo: https://github.com/rishi-raj-jain/remix-imagekit-harperdb-image-gallery

NoSQL Operations: https://docs.harperdb.io/docs/developers/operations-api/nosql-operations
ImageKit (React) Docs: https://docs.imagekit.io/getting-started/quickstart-guides/react#setup-imagekit-react-sdk