In Nextjs/App Router, How to pass httponly cookies in fetch request to API using server actions

1.7k Views Asked by At

I have a Nextjs 13 frontend using the App Router setup and a Rails 7 API-only backend using Doorkeeper for authentication.

I have a new product form that makes use of a Nextjs "server action" to send the POST request to the products#create route on the Rails backend. I want to use a server action so that I can call nextjs revalidatePath() once the Product is created to clear the cache and refresh the UI with the updated list of products.

However, since I am using Doorkeeper configured to authenticate via HttpOnly cookies and the products#create method is a protected route, I can't seem to figure out how to pass along the required access_token cookie.

It seems either:

  1. I use a client-side fetch call and have the authentication cookie included with the request automatically by the browser, but the nextjs cache doesn't clear or update with the new product info,

OR,

  1. I use a server action that can call revalidatePath(), but I need to refactor my Doorkeeper configuration to not be HttpOnly cookie-based, a security measure I want to maintain.

I tried to find a nextjs middleware solution, but even though I could see the access_token included when console logging the request.headers and it appears to be in the network request in devTools, the cookie is not getting through to Doorkeeper on the backend. The cookie does get through when I remove revalidatePath() from the server action and remove the 'use server' flag (so it's no longer a server action).

Is this a catch-22 scenario, or am I missing something in the Nextjs documentation? I'm guessing the Nextjs server does not have access to the HttpOnly cookies in the browser and therefore probably can't include them server-side when using a server action. But that confuses me to why the cookie shows up in the network tab in devTools.

I'm hoping someone out there might have worked with a similar setup and has found a best practice or workaround that I might be able to implement in this situation.

Many thanks!

Here's the server action sending the fetch request to products#create:

// actions.ts
'use server'
export const createProduct = async (data: FormData) => {
  const url = `${ baseApiUrl() }/v1/products`;

  const response = await fetch(url, {
    credentials: 'include',
    method: 'POST',
    headers: {
      'Authorization': `Basic ${ doorkeeperCredentials() }`,
    },
    body: configureData(data)
  });

  revalidatePath('/products');

  return response.json();
};

Here's the ProductForm component:

// product-form.component.tsx
'use client'

// library
import { useState, useEffect } from "react";

// api
import { createProduct } from "../../api/products-api";
import { Category } from "@/app/categories/page";
import { getAllCategories } from "@/app/api/categories-api";

const ProductForm = () => {
  // state
  const [ loading, setLoading ] = useState(true);
  const [ categories, setCategories ] = useState<Category[] | null>(null)

  useEffect(() => {
    const getCategories = async () => {
      const response = await getAllCategories();
      const categories: Category[] = await response.json();
      setCategories(categories);
    };

    categories ? setLoading(false) : getCategories();
  }, [ categories ])

  if (loading) return <p>Loading...</p>;

  return (
    <form 
      id="product"
      className="product-form"
      action={ formData => createProduct(formData) }
    >
      {/* product name */}
      <label 
        className="product-form__label"
        htmlFor="name"
      >
        Name
      </label>
      <input
        id="name" 
        className="product-form__input"
        type="text"
        autoComplete="false"
        name="product[name]"
      />

      {/* product description */}
      <label
        className="product-form__label" 
        htmlFor="short-description"
      >
        Description
      </label>
      <textarea
        id="short-description"
        className="product-form__textarea" 
        name="product[short_description]"
      />

      {/* product images */}
      <label 
        className="product-form__label"
        htmlFor="product-images"
      >
        Images
      </label>
      <input 
        id="product-images"
        className="product-form__attach-button"
        type="file"
        name="product[product_images][]"
        multiple
      />

      <div className="category-select">
        { categories && categories.map((category) => 
          <div key={ category.id }>
            <input 
              id={ `category-${ category.name }` }
              type='checkbox'
              value={ category.id }
              name={ `product[category_ids][]`}
            />
            <label htmlFor={  `category-${ category.name }` }>
              { category.name }
            </label>
          </div>
        )}
      </div>

      {/* submit button */}
      <button 
        className="product-form__button"
        type='submit'
      >
        Submit
      </button>
    </form>
  )
};

export default ProductForm;

Here's my nextConfig:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true
  },
  images: {
    domains: [
      'localhost'
    ]
  }
}

module.exports = nextConfig
1

There are 1 best solutions below

0
baconsocrispy On

I found a workaround to solve this issue. It feels a little clunky so if there is a cleaner solution or better practice, I'd love to know it.

In a separate server-actions.ts file, I exported an async function that simply calls revalidatePath(). This obviously feels redundant, but it follows the Nextjs protocol for using a server action inside of a client component: either export the action from a file with 'use server' at the top or pass an async function with the 'use server' directly inside of it from a server component into a client component.

Since I want to reuse this function in a few places I elected to export from a separate file.

I call my createProduct(formData) function the same way in my ProductForm component:

    <form 
      id="product"
      className="product-form"
      action={ formData => createProduct(formData) }
    >
      {/* product name */}
      <label 
        className="product-form__label"
        htmlFor="name"
      >
        Name
      </label>
      <input
        id="name" 
        className="product-form__input"
        type="text"
        autoComplete="false"
        name="product[name]"
      />

    ...

      {/* submit button */}
      <button 
        className="product-form__button"
        type='submit'
      >
        Submit
      </button>
    </form>

Then in my products-api.ts file I import the revalidate function I defined in server-actions.ts and call it inside the createProduct() function. This way createProduct still runs as a client-side function, passing the HttpOnly cookie to Doorkeeper on the Rails backend and then forcing a server-side revalidation upon successful product creation.

export const createProduct = async (data: FormData) => {
  const url = `${ baseApiUrl() }/v1/products`;

  const response = await fetch(url, {
    credentials: 'include',
    method: 'POST',
    headers: {
      'Authorization': `Basic ${ doorkeeperCredentials() }`,
    },
    body: configureData(data)
  });

  revalidate('/');

  return response.json();
};

I'm not sure that combining client and server actions like this follows Nextjs best practices, but for my needs, I have everything working now as I hoped it would.