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.
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,
Open app/lib/harper.ts and update the database and table values per the names given above.
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!
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.
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.
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.
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