Build your own image gallery CMS
Tags: ai, database, tutorial
Published: 2024-06-12 11:00:00

In this post, I talk about how to create an image gallery CMS with Astro, Xata and Cloudflare Pages. You'll learn how to:

- Set up Xata
- Create a schema with different column types
- Resize and blur images
- Fetch all records without pagination
- Handle forms in Astro using view transitions

## Before you begin

### Prerequisites

You'll need the following:

- A [Xata](https://app.xata.io/signin) account
- [Node.js 18](https://nodejs.org/en/download) or later
- A [Cloudflare](https://workers.cloudflare.com/) account

### Tech stack

| Technology                                          | Description                                                                   |
| --------------------------------------------------- | ----------------------------------------------------------------------------- |
| [Xata](https://xata.io)                             | Serverless database platform for scalable, real-time applications.            |
| [Astro](https://astro.build)                        | Framework for building fast, modern websites with serverless backend support. |
| [Tailwind CSS](https://tailwindcss.com/)            | CSS framework for building custom designs                                     |
| [Cloudflare Pages](https://workers.cloudflare.com/) | Platform for deploying and hosting web apps with global distribution.         |

## Setting up a Xata database

After you've created a Xata account and are logged in, create a database.

![Create a database](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tfk5e1hr54vhua951nfd.png)

The next step is to create a table, in this instance `uploads`, that contains all the uploaded images.

![Create a table](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x6hi8dq14akkztlca06t.png)

Great! Now, click **Schema** in the left sidebar and create two more tables `profiles` and `photographs`. You can do this by clicking **Add a table**. The newly created tables will contain user profile data and user uploaded photograph(s) data respectively.

![Newly created table](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3r5innliq035mfdpmrqn.png)

With that completed, you will see the schema.

![Schema displayed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hj6jo5z9o1gtmybog87p.png)

Let’s move on to adding relevant columns in the tables you've just created.

## Creating the schema

In the `uploads` table, you want to store all the images only (and no other attributes) so that you can create references to the same image object again, if needed.

Proceed with adding the column named `image`. This column is responsible for storing the `file` type objects. In our case, the `file` type object is for images, but you can use this for storing any kind of blob (e.g. PDF, fonts, etc.) that’s sized up to 1 GB.

First, click **+ Add column** and select **File**.

![Add column](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m3rqdn7benw5gol0m5qn.png)

Set the column name to `image` and to make files public (so that they can be shown to users when they visit the image gallery), check the **Make files public by default** option.

![Make files public](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bcljoqiz4wafu5ifvf9r.png)

In the `profiles` table, we want to store the attributes such as user’s unique slug (the path of the url where gallery will be displayed of that user), their name, their image with it’s dimension, and it’s transformed image’s base64 hash. You’ll reap the benefits of storing the hash to create literally 0 Cumulative Layout Shift (CLS) page(s).

Proceed with adding the column named `slug`. It is responsible for maintaining the uniqueness of each profile that gets created. Click **+ Add a column**, select `String` type and enter the column name as `slug`. To associate a slug with only one user, check the `Unique` attribute to make sure that duplicate entries do not get inserted.

![Add slug column](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wza0psek5iq1hu0df29i.png)

In similar fashion, create `name`, `image`, `height` and `width` columns as `String` type (but not `Unique`).

Great, you can also store `imageHash` as `Text` type so that you can instantly retrieve the image’s blur hash sized up to `200 KB`. While `String` is a great default type, storing more than 2048 characters would require you to switch to the `Text` type. Read more about the limits in [Xata Column limits](https://xata.io/docs/rest-api/limits#column-limits).

Click **+ Add a column** and select the `Text` type.

![Add a column](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b9k5ycjgjegr73b2dm1k.png)

Enter the column name as `imageHash` and press `Create column`.

![Enter the column name](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xyiurs8po34ca2o6ufld.png)

Much similar what we did above, in the `photographs` table, we create `name`, `tagline`, `image`, `height`, `width`, `profile-slug`, and `slug` as `String` type and `imageHash` as the `Text` type column. The columns `slug` and `profile-slug` refer to photograph’s and user profile’s slug respectively.

Lovely! With all that done, the final schema will look something like the following...

![Final schema](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y0555isfim5jgyntzj9p.png)

## Setting up your project

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

```bash
git clone https://github.com/rishi-raj-jain/image-gallery-cms-with-astro-xata-cloudflare
cd image-gallery-cms-with-astro-xata-cloudflare
pnpm install
```

## Configure Xata with Astro

To use Xata with Astro seamlessly, install the Xata CLI globally:

```bash
npm install @xata.io/cli -g
```

Then, authorize the Xata CLI so it is associated with the logged in account:

```bash
xata auth login
```

![Authorize the CLI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k2frsra80yrjxdf33823.png)

Great! Now, initialize your project locally with the Xata CLI command:

```bash
xata init --db https://Rishi-Raj-Jain-s-workspace-80514q.ap-southeast-2.xata.sh/db/image-gallery-cms-with-xata-astro-cloudflare
```

Answer some quick one-time questions from the CLI to integrate with Astro.

![Xata init with Astro project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cvx8xjkqcdj82pckujvw.png)

## Implementing form actions in Astro

You can also allow transitions on form submissions by adding the `ViewTransitions` component.
Here’s an example of the enabled form actions with view transitions in `src/layouts/Layout.astro`:

```js
---
// File: src/layouts/Layout.astro

import { ViewTransitions } from "astro:transitions";
---

<html>
  <head>
    <ViewTransitions />
    <!-- stuff here -->
  </head>
  <body>
    <!-- stuff here -->
  </body>
</html>
```

This allows you to colocate the backend and frontend flow for a given page in Astro. Say, you accept a form submission containing the name, slug, and the image URL of the user, process it on the server to generate a blur Base64 hash, and sync it with your Xata serverless database. Here’s how you’d do all of that in a single Astro route (`src/pages/profie/create.astro`).

```js
---
// File: src/pages/profile/create.astro

const response = { form: false, message: '', created: false, redirect: null }

// ...

if (Astro.request.method === 'POST') {
  try {
    // Indicate that the request is being processed
    response.form = true

    // Get the user email from the form submissions
    const data = await Astro.request.formData()

		// Get the user slug, name, and image: URL, width, and height from the form submissions
    const userSlug = data.get('slug') as string
    const userName = data.get('name') as string
    const userImage = data.get('custom_upload_user__uploaded_image_url') as string
    const userImageW = data.get('custom_upload_user__uploaded_w') as string
    const userImageH = data.get('custom_upload_user__uploaded_h') as string

		// Create a blur url of the user image

		// Create the user record with the slug

		// Redirect user to the next step
  } catch (e) {
    // pass
  }
}
---

<form method="post" autocomplete="off">
  <Upload selector="user" />
  <input required name="name" type="text" placeholder="Name" />
  <input
    required
    name="slug"
    type="text"
    placeholder="Slug (e.g. rishi-raj-jain)"
  />
  <button type="submit">
    Create Profile &rarr;
  </button>
</form>
```

## Handling image uploads server-side with the Xata SDK

![(/photograph/create - where users upload their photographs)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vcwk4g45jlw1fceez17s.png)

As Cloudflare Pages request body size allows up to 100 MB, you’ll be able to handle image uploads on the server-side. Create an Astro endpoint (`src/pages/api/upload/index.ts`) to receive POST requests containing image binaries and use the Xata SDK to store them in the `uploads` table.

After doing sanity checks on the request body, first create a new (empty) record in your `uploads` table, and then use it as a reference to place the image (buffer) using the Xata TypeScript SDK. Once successfully completed, the endpoint responds with the image’s `public URL`, `height` and `width` back to the front-end to include in the form fields.

```tsx
// File: src/pages/api/upload/index.ts

import { json } from '@/lib/response';
import { getXataClient } from '@/xata';
import type { APIContext } from 'astro';

// Import the Xata Client created by the Xata CLI in src/xata.ts
const xata = getXataClient();

export async function POST({ request }: APIContext) {
  const data = await request.formData();
  const file = data.get('file');

  // Do sanity checks on file

  try {
    // Obtain the uploaded file as an ArrayBuffer
    const fileBuffer = await file.arrayBuffer();

    // Create an empty record in the uploads table
    const record = await xata.db.uploads.create({});
    // Using the id of the record, insert the file using upload method
    await xata.files.upload({ table: 'uploads', record: record.id, column: 'image' }, fileBuffer, {
      mediaType: file.type
    });

    // Read the inserted image
    const { image } = await xata.db.uploads.read(record.id);

    // Destructure its dimension and public URL
    const { url, attributes } = image;
    const { height, width } = attributes;
    return json({ height, width, url }, 200);
  } catch (error) {
    // Handle errors
  }
}
```

## Using Xata image transformations to create blurred images

Once a user submits their profile on the `/profile/create` page, before creating a record in the `profiles` table, generate a Base64 buffer of their blurred images. To create blured images from the originals, use Xata image transformations. With Xata image transformations, you’re able to request an on-demand public URL which resizes the image to given height and width, and blur the image. In this particular example, you can resize the image to 100 x 100 dimensions and blur it up to 75% from the original.

```js
---
// File: src/pages/profile/create.astro

// Import the Xata Client created by the Xata CLI in src/xata.ts
import { getXataClient } from '@/xata'

// Import the transformImage function by Xata Client
import { transformImage } from '@xata.io/client'

const response = { form: false, message: '', created: false, redirect: null }

// ...

if (Astro.request.method === 'POST') {

	// Fetch the Xata instance
	const xata = getXataClient()

	// ...

	// Create a blur URL of the user image

  // Using Xata image transformations to obtain the image URL
	// with a fixed height and width and 75% of it blurred
  const userBlurURL = transformImage(userImageURL, {
    blur: 75,
    width: 100,
    height: 100,
  })

  // Create a Base64 hash of the blur image URL
  const userBlurHash = await createBlurHash(userBlurURL)

	// Create the user record with the slug

	// Redirect user to the next step
}
---
```

## Syncing profiles using the Xata SDK

After you have generated blurred images, the last step in publishing profiles (similar to what’s done in publishing photographs) is to create a user record with relevant details using the Xata TypeScript SDK’s `create` command. In Astro, we then set the successful message for the user before they are redirected to the photograph upload page. The integrated conditional rendering ensures a visual cue of the operation's success or failure, providing a responsive and user-friendly experience.

```js
---
// File: src/pages/profile/create.astro

// Import the Xata Client created by Xata CLI in src/xata.ts
import { getXataClient } from '@/xata'

const response = { form: false, message: '', created: false, redirect: null }

// ...

if (Astro.request.method === 'POST') {

	// ...

	// Create the user record with the slug
  await xata.db.profiles.create({
    slug: userSlug,
    name: userName,
    image: userImage,
    width: userImageW,
    height: userImageH,
    imageHash: userBlurHash,
  })

	// Send the user to photograph upload page
  response.redirect = '/photograph/create'

  // Set the relevant message for the user
  response.message = 'Published profile succesfully. Redirecting you to upload your first photograph...'
}
---

<!-- Render conditional states using the onboarded flag -->
{
  response.form &&
    (response.created ? (
      <p class="rounded bg-green-100 px-3 py-1">{response.message}</p>
    ) : (
      <p class="rounded bg-red-100 px-3 py-1">{response.message}</p>
    ))
}

<!-- Profile Form -->

<!-- Redirect user to the next location -->
<script define:vars={{ success: response.created, location: response.redirect }}>
  if (success) {
    setTimeout(() => {
      window.location.href = location
    }, 1000)
  }
</script>
```

## Using Xata query

The user profile page (`src/pages/[profile]/index.astro`) leverages the Xata Client to dynamically fetch and display all the photographs (not paginated) for a specific user profile. To engage users visually as soon as they open the gallery, we use the stored `imageHash` value as the background of the images (to be loaded). To prevent CLS, we use the stored `width` and `height` values to inform the browser of the expected dimensions of the images.

```js
---
// File: src/pages/[profile]/index.astro

// Import the Xata Client created by Xata CLI in src/xata.ts
import { getXataClient } from '@/xata'

import Layout from '@/layouts/Layout.astro'

// Get the profile slug from url path
const { profile } = Astro.params

// Fetch the Xata instance
const xata = getXataClient()

// Get all the photographs related to the profile
const profilePhotographs = await xata.db.photographs
														// Filter the results to the specific profile
														.filter({ 'profile-slug': profile })
														// Get all the photographs
														.getAll()
---

<Layout className="flex flex-col">
  <div class="columns-1 gap-0 md:columns-2 lg:columns-3">
    {
      profilePhotographs.map(
        ({ width: photoW, height: photoH, name: photoName, image: photoImageURL, tagline: photoTagline, imageHash: photoImageHash }, _) => (
					{/* Destructure the width and height to prevent CLS */}
          <img
            width={photoW}
            height={photoH}
            alt={photoName}
            src={photoImageURL}
						{/* Do not lazy load the first image that's loaded into the DOM */}
            loading={_ === 0 ? 'eager' : 'lazy'}
            class="transform bg-cover bg-center bg-no-repeat will-change-auto"
						{/* Create a blur effect with the imageHash stored */}
            style={`background-image: url(${photoImageHash}); transform: translate3d(0px, 0px, 0px);`}
          />
        ),
      )
    }
  </div>
</Layout>
```

![(Image gallery - presenting blur images initially)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rm7igjlt8jnwwvkzsldm.png)

![(Image gallery - after image loads complete)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j9pl4fg9oa42bum8bce6.png)

## Deploy to Cloudflare Pages

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

1. Create a GitHub repository with the app code.
1. Click **Create application** in the Workers & Pages section of Cloudflare dashboard.
1. Navigate to the **Pages** tab and select **Connect to Git**.
1. Link the created GitHub repository` as your new project.
1. Scroll down and update the **Framework preset** to **Astro**.
1. Update the environment variables from the `.env` locally.
1. Click **Save and Deploy** and go back to the project **Settings** > **Functions**.
1. Add `nodejs_compat` to the **Compatibility flags** section
1. Deploy! 🚀

### Why Cloudflare Pages?

Cloudflare Pages stood out for this particular use case as it [offers up to 100 MB request body size in their Free plan](https://developers.cloudflare.com/workers/platform/limits/#request-limits). This helped to bypass the 4.5MB request body size limit in various serverless hosting providers.

## More information

For a more detailed insights, explore the references cited in this post.

| Demo Image Gallery          | https://image-gallery-cms-with-astro-xata-cloudflare.pages.dev/rishi           |
| --------------------------- | ------------------------------------------------------------------------------ |
| GitHub Repo                 | https://github.com/rishi-raj-jain/image-gallery-cms-with-astro-xata-cloudflare |
| Astro with Xata             | https://xata.io/docs/getting-started/astro                                     |
| Astro View Transition Forms | https://docs.astro.build/en/guides/view-transitions/#transitions-with-forms    |
| Xata File Attachments       | https://xata.io/docs/sdk/file-attachments#upload-a-file-using-file-apis        |
| Xata Transformations        | https://xata.io/docs/sdk/image-transformations                                 |
| Xata Get Records            | https://xata.io/docs/sdk/get#the-typescript-sdk-functions-for-querying         |
| Cloudflare Workers Limits   | https://developers.cloudflare.com/workers/platform/limits/#request-limits      |

## What's next?

We'd love to hear from you if you have any feedback on this tutorial, would like to know more about Xata, or if you'd like to contribute a community blog or tutorial. Reach out to us on [Discord](https://discord.com/invite/kvAcQKh7vm) or join us on [X | Twitter](https://twitter.com/xata). Happy building 🦋