Skip to content

RESOURCES / BLOG

Build a Contact Form in Headless WordPress Using Next.js and Ninja Forms (Part 2)

Why It Matters

    

  • Get best practices for handling image uploads securely on the server-side and delivering them optimally on the client-side.
  • Effectively use specialized NPM packages for server-side asset management and optimized client-side rendering to streamline development and improve user experience.
  • Lock down WordPress admin pages for Ninja Forms to maintain consistency between the frontend and backend.

In Part 1, we set up a headless contact form by installing Ninja Forms with WPGraphQL, creating a secured Next.js API route (/api/contact/route.ts) for users to submit their name, email, and message via GraphQL, and built a server action (actions.ts) to bridge our client-side form. We also enhanced the form component (contact-form.tsx) so that when a user selects an image, it uploads to Cloudinary and passes the resulting secure_url into our submission payload. 

In this second installment, we’ll dive into the details of that Cloudinary integration: configuring environment variables, implementing the lib/cloudinary.ts helper and /api/upload endpoint, and using the cloudinary and next-cloudinary npm packages to streamline server-side uploads and client-side image delivery.

In order for our API’s to consume what we need from each environment, the next step is to add our environment variables. In the .env.local file at your project’s root, your environment variables should be as follows:

NEXT_PUBLIC_GRAPHQL_ENDPOINT="https://your-wpsite.com/graphql"
WP_AUTH_TOKEN="your-auth-token"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"Code language: JavaScript (javascript)
  • NEXT_PUBLIC_GRAPHQL_ENDPOINT. Your WPGraphQL endpoint URL.
  • WP_AUTH_TOKEN. A randomly generated token (you can use openssl rand -base64 32).
  • NEXT_PUBLIC_SITE_URL. For local testing (e.g., http://localhost:3000).

To make Cloudinary uploads work on the server side, we added a lightweight helper in lib/cloudinary.ts:

```
import { v2 as cloudinary } from "cloudinary";

cloudinary.config({

  cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,

  api_key: process.env.CLOUDINARY_API_KEY,

  api_secret: process.env.CLOUDINARY_API_SECRET,

});

export { cloudinary };

```Code language: JavaScript (javascript)

This file reads your Cloudinary credentials from environment variables so that no sensitive data is checked into source control. Next, we need a Next.js API route that accepts a multipart/form-data POST containing the user’s chosen image and sends it to Cloudinary. Create a new file at app/api/upload/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { cloudinary } from "../../../lib/cloudinary";

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get("file") as Blob | null;
  if (!file) {
    return NextResponse.json({ error: "No file provided" }, { status: 400 });
  }

  try {
    // Convert Blob to a Buffer and upload to Cloudinary:
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const result = await cloudinary.uploader.upload_stream(
      {
        resource_type: "image",
        folder: "ninja-forms-uploads",
        use_filename: true,
        unique_filename: false,
      },
      (error, result) => {
        if (error || !result) {
          throw new Error(error?.message || "Cloudinary upload failed");
        }
        return result;
      }
    );

    // Since upload_stream uses a callback, we need to wrap in a Promise:
    const uploadPromise: Promise<any> = new Promise((resolve, reject) => {
      const stream = cloudinary.uploader.upload_stream(
        {
          resource_type: "image",
          folder: "ninja-forms-uploads",
          use_filename: true,
          unique_filename: false,
        },
        (error, result) => {
          if (error || !result) {
            reject(error?.message || "Upload failed");
          } else {
            resolve(result);
          }
        }
      );
      stream.end(buffer);
    });

    const uploadResult = await uploadPromise;
    return NextResponse.json(uploadResult, { status: 200 });
  } catch (err) {
    return NextResponse.json(
      { error: (err as Error).message || "Upload error" },
      { status: 500 }
    );
  }
}
Code language: JavaScript (javascript)

This POST handler:

  1. Reads the uploaded file blob from request.formData().
  2. Converts it to a buffer and passes it to Cloudinary’s upload_stream API.
  3. Returns JSON containing secure_url, public_id, and other metadata.
  4. On error, it returns a 500 response with an error message.

With that in place, the client-side handleImageUpload can call fetch("/api/upload", { method: "POST", body: formData }) and receive a JSON payload like:

```
{
  "public_id": "ninja-forms-uploads/abc-def",
  "version": 1620000000,
  "secure_url": "https://res.cloudinary.com/your-cloud-name/image/upload/v1620000000/abc-def.jpg",
  // …other metadata…
}

```Code language: PHP (php)

To integrate Cloudinary into our Next.js and WPGraphQL for Ninja Forms project, we rely on two npm packages: cloudinary and next-cloudinary. The Cloudinary package is the official Node.js SDK for Cloudinary, enabling secure configuration, uploads, and asset management. Install it with:

`npm install cloudinary`Code language: JavaScript (javascript)

The Node SDK supports Node@6+ (1.x) and Node@9+ (2.x) and includes functions for transformations, optimization, and media management. In our server-side API route (/api/upload), we import cloudinary.v2 and configure it with environment variables:

```
import { v2 as cloudinary } from "cloudinary";

cloudinary.config({
  cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

```Code language: JavaScript (javascript)

By reading credentials from process.env, we avoid committing sensitive data. Once configured, we use cloudinary.uploader.upload_stream in /api/upload to convert the uploaded file (as a Buffer) and send it to Cloudinary. The SDK returns metadata such as secure_url and public_id, which we forward to our Next.js form component. The Node SDK also supports chunked uploads, upload presets, and callbacks for error handling, making it ideal for headless environments.

In addition, we install next-cloudinary:

npm install next-cloudinary

Next Cloudinary is a community-maintained library supported by Cloudinary’s Developer Experience team. It provides React components for Next.js that automatically optimize images, generate responsive srcsets, apply transformations, and create Open Graph cards. After uploading an image, we store the returned secure_url in React state (imageUrl) and pass it into CldImage:

import { CldImage } from "next-cloudinary";

return (
  <CldImage width="600" height="400" src={imageUrl} alt="Uploaded image preview" />
);Code language: JavaScript (javascript)

CldImage uses the NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME to request an optimized version. It automatically selects modern formats (WebP, AVIF) when supported and serves images via Cloudinary’s CDN. For generating Open Graph cards, we import CldOgImage:

```
import { CldOgImage } from "next-cloudinary";

<CldOgImage src={imageUrl} text="Submission Preview" />

```Code language: HTML, XML (xml)

CldOgImage applies a 2:1 aspect ratio and overlays text dynamically, producing social media-ready images.

The cloudinary Node SDK allows our /api/upload route to accept multipart/form-data, convert it into a Buffer, and upload the file to Cloudinary securely. Credentials (cloud name, API key, secret) are loaded from environment variables. Using upload_stream avoids temporary disk storage. The Node SDK supports legacy Node versions and includes TypeScript definitions, so our server code enjoys type safety.

After the file is uploaded, we receive a secure_url back. Instead of rendering a plain <img> tag, we use CldImage from next-cloudinary, which leverages automatic transformations and optimizations at request time. For instance, CldImage requests WebP where supported, generates responsive srcset attributes, and lazy-loads. Developers can pass additional props (quality, placeholder, transformations) without writing custom code.

To set up both packages, add these variables to .env.local:

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="your-cloud-name"

CLOUDINARY_API_KEY="your-api-key"

CLOUDINARY_API_SECRET="your-api-secret"Code language: JavaScript (javascript)
Note:

If you need to know how to obtain your Cloudinary account credentials, please refer to the documentation here.

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME is safe on the client. The API key and secret remain private on the server.

Installation is straightforward:

`npm install cloudinary next-cloudinary`Code language: JavaScript (javascript)

In lib/cloudinary.ts, configure cloudinary. In components, import CldImage and CldOgImage. Both packages include TypeScript types, ensuring props and methods are validated at compile time. 

As headless applications evolve, advanced transformations like AI cropping or background removal may be required. With both packages installed, we can adopt new Cloudinary features immediately. For example, applying background removal during upload uses an extra parameter in cloudinary.uploader.upload. On the client, passing transformation props to CldImage can overlay text or apply filters. Both SDKs support streaming and chunked uploads, so our project can handle large files without hitting default WordPress limits.

By installing and configuring both the cloudinary Node SDK and the next-cloudinary package, our Next.js application achieves a modern image upload and delivery workflow. The cloudinary package powers server-side uploads and asset management, while next-cloudinary ensures images are delivered in optimized formats on the client. This combination enhances performance, scalability, and developer productivity, all while securely managing environment credentials and integrating directly with WPGraphQL for Ninja Forms.

With everything set up, navigate to your contact form route in the browser. You should see the contact form with Name, Email, Message, and an Image (Optional) file input. When you select a file, you’ll see an Uploading image… indicator and, once complete, a preview of the uploaded image via its Cloudinary secure_url. After filling out Name, Email, and Message (and optionally uploading an image), click Send Message.

The form will send all data (plus imageUrl) to your Next.js API route, which in turn calls the WPGraphQL mutation. If successful, you’ll get a confirmation message; otherwise, an error will display.

If you’re using a static form component in your frontend, any changes made to the form structure in the WordPress admin can break the submission logic. To prevent this, restrict access to Ninja Forms editing screens by creating a custom plugin that overrides its default capabilities.

```
<?php
/**
 * Plugin Name: Ninja Forms Admin Lockdown
 * Description: Restricts access to all Ninja Forms admin screens so that only users with the 'edit_themes' capability (typically Administrators) can view or modify forms.
 * Version:     1.0.0
 * Author:      Your Name
 * License:     GPLv2 or later
 * Text Domain: ninja-forms-admin-lockdown
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Restrict access to “All Forms” and the main Ninja Forms menu.
 *
 * By default, Ninja Forms uses 'edit_posts' to gate access.
 * Returning 'edit_themes' here ensures only users with that capability can see or edit.
 */
function nf_allforms_capabilities( $cap ) {
    return 'edit_themes';
}
add_filter( 'ninja_forms_admin_parent_menu_capabilities', 'nf_allforms_capabilities' );
add_filter( 'ninja_forms_admin_all_forms_capabilities',   'nf_allforms_capabilities' );

/**
 * Restrict access to “Add New Form” submenu.
 */
function nf_newforms_capabilities( $cap ) {
    return 'edit_themes';
}
add_filter( 'ninja_forms_admin_parent_menu_capabilities', 'nf_newforms_capabilities' );
add_filter( 'ninja_forms_admin_all_forms_capabilities',   'nf_newforms_capabilities' );
add_filter( 'ninja_forms_admin_add_new_capabilities',     'nf_newforms_capabilities' );

```Code language: HTML, XML (xml)

In order to do this, you need to hook into Ninja Forms capability filters and return a higher-level capability. Install and activate this plugin to restrict form editing to only site Administrators (those who have the edit_themes capability).

Please refer to the WordPress plugin docs for proper plugin creation here.

Integrating a contact form on a headless WordPress site doesn’t have to be complex. By combining Next.js, WPGraphQL, Ninja Forms, and Cloudinary for optional image uploads, you can build a secure, modern contact form that connects your frontend and backend seamlessly. Sign up for a free Cloudinary account today to get started.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free