Custom component: secure way to update nested array items without token

8 replies
Last updated: Jan 25, 2022
I have created a custom component, which takes complex array as a value, schema is defined like this:
    {
      name: 'sections',
      title: 'Sections',
      type: 'array',
      inputComponent: SectionItemList,
      of: [
        {
          type: DocumentTypes.workoutgroup,
          weak: true,
        },
      ],
    },
And type workoutgroup schema is:

export const workoutGroup = createDocumentSchema({
  name: DocumentTypes.workoutgroup,
  type: 'object',

  fields: [
    {
      name: 'label',
      title: 'Workout group name',
      type: 'string',
      validation: (Rule: any) => Rule.required(),
    },
    {
      name: 'startTime',
      title: 'Start time',
      type: 'string',
    },
    {
      name: 'exercises',
      title: 'Excercises',
      type: 'array',
      of: [
        {
          type: DocumentTypes.workoutgroupexercise,
          weak: true,
        },
      ],
    },
  ],
});
From within the custom component which is client side js code, I need to make queries to update
label
,
startTime
create new
DocumentTypes.workoutgroup
update
exercises
. I used
sanityClient
and made queries like this:
      await client
        .patch(document._id)
        .set({ [`sections[_key=="${key}"].label`]: newValue.toString() })
        .commit();
However when client is initialised it requires auth token, which has write permissions (admin) and I shouldn’t be keeping the auth token in my JS bundle for security reasons.

I have tried using PatchEvent to make these queries, but it doesn’t seem to work.

My current idea is to move client queries to separate node proxy server and do it this way: FE -> node proxy service (which has auth token set, privately) -> Sanity

Does that feel like the best approach?
AI Update

You're absolutely right to be concerned about exposing auth tokens in client-side code! The good news is you don't need a proxy server for this use case. Your custom input component runs inside the authenticated Sanity Studio environment, which already handles authentication through the user's login session.

The Core Issue

You shouldn't be using a separate sanityClient with auth tokens inside custom input components. The Studio already has authentication, and custom components communicate changes through the onChange prop that's passed to your component.

The Correct Approach for Studio v3

Your custom input component receives onChange and value props from Sanity. To make updates, you use the onChange callback with patch operations like set() and unset().

Import the Required Functions

import {set, unset} from 'sanity'

Note: In Studio v3, you import set and unset directly from 'sanity', not from @sanity/form-builder (that was Studio v2). There's no separate PatchEvent class in v3 - you just pass the patch operations directly to onChange.

Basic Pattern for Updates

export function SectionItemList(props) {
  const {onChange, value = []} = props
  
  // In v3, onChange accepts patch operations directly
  const handleChange = (newValue) => {
    onChange(newValue ? set(newValue) : unset())
  }
}

Updating Label or StartTime

To update fields within a specific array item, you work with the entire value array:

const updateLabel = (key, newLabel) => {
  const updatedSections = value.map(section => 
    section._key === key 
      ? {...section, label: newLabel}
      : section
  )
  onChange(set(updatedSections))
}

const updateStartTime = (key, newTime) => {
  const updatedSections = value.map(section => 
    section._key === key 
      ? {...section, startTime: newTime}
      : section
  )
  onChange(set(updatedSections))
}

Creating New Workout Groups

To add a new item, generate a unique _key for it:

import {randomKey} from '@sanity/util/content'

const addNewWorkoutGroup = () => {
  const newGroup = {
    _type: 'workoutgroup',
    _key: randomKey(12),
    label: 'New Workout',
    startTime: '',
    exercises: []
  }
  
  const updatedSections = [...value, newGroup]
  onChange(set(updatedSections))
}

Updating the Exercises Array

For nested arrays, you update the parent array with the modified nested structure:

// Update entire exercises array for a specific workout group
const updateExercises = (workoutGroupKey, newExercises) => {
  const updatedSections = value.map(section => 
    section._key === workoutGroupKey 
      ? {...section, exercises: newExercises}
      : section
  )
  onChange(set(updatedSections))
}

// Add a single exercise to a workout group
const addExercise = (workoutGroupKey, newExercise) => {
  const exerciseWithKey = {
    ...newExercise,
    _key: randomKey(12),
    _type: 'workoutgroupexercise'
  }
  
  const updatedSections = value.map(section => 
    section._key === workoutGroupKey 
      ? {
          ...section, 
          exercises: [...(section.exercises || []), exerciseWithKey]
        }
      : section
  )
  
  onChange(set(updatedSections))
}

Removing Items

const removeWorkoutGroup = (key) => {
  const updatedSections = value.filter(section => section._key !== key)
  onChange(
    updatedSections.length > 0 
      ? set(updatedSections) 
      : unset()
  )
}

Schema Configuration

Your schema should use the components.input property for Studio v3:

{
  name: 'sections',
  title: 'Sections',
  type: 'array',
  components: {
    input: SectionItemList  // Studio v3 syntax
  },
  of: [
    {
      type: 'workoutgroup',
      weak: true,
    },
  ],
}

Note: If you were using inputComponent (camelCase), that was Studio v2 syntax. Studio v3 uses the components object.

Why This Works Securely

  • No exposed tokens: Changes go through the Studio's internal APIs using the logged-in user's session
  • Real-time collaboration: This approach maintains Studio's real-time features
  • Proper validation: Updates go through Studio's validation system
  • User permissions: Changes respect the current user's permission level

Why NOT a Proxy Server

Your proxy server idea would technically work, but it's unnecessary complexity that would:

  • Break real-time collaboration features
  • Add latency with an extra network hop
  • Require maintaining additional infrastructure
  • Lose Studio features like undo/redo and presence indicators

The onChange pattern is the standard approach for custom input components and keeps everything secure within the authenticated Studio environment. Your custom component has full access to make changes through onChange without needing any additional authentication.

Show original thread
8 replies
since this is within the studio, you can use the studio's client instead:

import sanityClient from 'part:@sanity/base/client'

const client = sanityClient.withConfig({apiVersion: '2022-01-26'})
You won't have to expose your key this way
My custom component is a huge component, with many sub components nested, but the core looks like this:
/* eslint-disable no-console */
import { TrashIcon, CopyIcon } from '@sanity/icons';
import {
  Stack, Label, Card, Flex, Inline, Button, useToast, Tooltip, Box, Text,
} from '@sanity/ui';
import debounce from 'lodash.debounce';
import { nanoid } from 'nanoid';
import { withDocument } from 'part:@sanity/form-builder';
import React, {
  FC, useRef, useCallback, useState,
} from 'react';

import { IWorkoutSectionItem, IDocument } from '../../interfaces/common';
import { DocumentTypes } from '../../schemas/common/baseDocument';
import { client } from '../../utils/browserClient';
import DeleteConfirmationModal from './DeleteConfirmationModal';
import WorkoutSectionExercises from './ExerciseItemList';
import WorkoutSectionLabelInput from './LabelInput';
import WorkoutSectionsTimeInput from './StartTimeInput';

interface IWorkoutSectionItemProps extends IWorkoutSectionItem {
  document: IDocument;
}

const DEBOUNCE_TIME = 500;

const WorkoutSectionItem: FC<IWorkoutSectionItemProps> = ({
  label,
  startTime,
  exercises,
  _key: key,
  document,
}) => {
  const [ confirmationIsOpen, setConfirmationIsOpen ] = useState(false);
  const onClose = useCallback(() => setConfirmationIsOpen(false), []);
  const onOpen = useCallback(() => setConfirmationIsOpen(true), []);

  const startDateRef = useRef();
  const labelRef = useRef();
  const toast = useToast();

  const onLabelChange = useCallback(debounce(async (newValue) => {
    try {
      await client
        .patch(document._id)
        .set({ [`sections[_key=="${key}"].label`]: newValue.toString() })
        .commit();

      toast.push({
        status: 'success',
        title: 'Successfully updated workout group title',
      });
    } catch (e) {
      toast.push({
        status: 'error',
        title: 'Failed to update workout group title',
      });
      console.log(e);
    }
  }, DEBOUNCE_TIME), [ key ]);

  const onStartTimeChange = useCallback(debounce(async (newValue) => {
    try {
      await client
        .patch(document._id)
        .set({ [`sections[_key=="${key}"].startTime`]: newValue.toString() })
        .commit();

      toast.push({
        status: 'success',
        title: 'Successfully updated workout group start time',
      });
    } catch (e) {
      toast.push({
        status: 'error',
        title: 'Failed to update workout group start time',
      });
      console.log(e);
    }
  }, DEBOUNCE_TIME), [ key ]);

  const deleteWorkoutGroup = useCallback(async () => {
    try {
      await client
        .patch(document._id)
        .unset([ `sections[_key=="${key}"]` ])
        .commit();

      toast.push({
        status: 'success',
        title: `Successfully removed "${label}"`,
      });
    } catch (e) {
      toast.push({
        status: 'error',
        title: 'Failed to remove workout group',
      });
      console.log(e);
    }

    onClose();
  }, [ key, label, onClose ]);

  const cloneWorkoutGroup = useCallback(async () => {
    const clonedWorkoutGroup = {
      _type: DocumentTypes.workoutgroup,
      _key: nanoid(),
      label: `${label} (copy)`,
      startTime,
      exercises,
    };

    try {
      await client
        .patch(document._id)
        .prepend('sections', [ clonedWorkoutGroup ])
        .commit();

      toast.push({
        status: 'success',
        title: `Successfully cloned "${label}"`,
      });
    } catch (e) {
      toast.push({
        status: 'error',
        title: 'Failed to clone workout group',
      });
      console.log(e);
    }
  }, [ exercises, label, startTime ]);

  return (
    <React.Fragment>
      <Card padding={[ 0, 0, 5, 5 ]} style={{ borderBottom: '1px dashed black' }} shadow={0}>
        <Flex>
          <Card flex={1} marginRight={4}>
            <WorkoutSectionLabelInput
              ref={labelRef.current}
              value={label}
              label="Workout group title"
              onChange={onLabelChange}
            />
          </Card>
          <Card marginRight={4}>
            <WorkoutSectionsTimeInput
              ref={startDateRef.current}
              value={startTime}
              onChange={onStartTimeChange}
              label="Start time"
            />
          </Card>
          <Card>
            <Stack space={2}>
              <Label>Actions</Label>
              <Inline space={[ 0, 0, 2 ]}>
                <Tooltip
                  content={(
                    <Box padding={2}>
                      <Text muted size={1}>
                        Delete workout group
                      </Text>
                    </Box>
              )}
                  fallbackPlacements={[ 'right', 'left' ]}
                  placement="top"
                  portal
                >
                  <Button
                    fontSize={0}
                    icon={TrashIcon}
                    tone="primary"
                    onClick={onOpen}
                    style={{ cursor: 'pointer' }}
                  />
                </Tooltip>
                <Tooltip
                  content={(
                    <Box padding={2}>
                      <Text muted size={1}>
                        Clone workout group
                      </Text>
                    </Box>
              )}
                  fallbackPlacements={[ 'right', 'left' ]}
                  placement="top"
                  portal
                >
                  <Button
                    fontSize={0}
                    icon={CopyIcon}
                    tone="secondary"
                    onClick={cloneWorkoutGroup}
                    style={{ cursor: 'pointer' }}
                  />
                </Tooltip>
              </Inline>
            </Stack>
          </Card>
        </Flex>
        <WorkoutSectionExercises exercises={exercises} workoutGroupKey={key} />
      </Card>
      <DeleteConfirmationModal
        isOpen={confirmationIsOpen}
        onClose={onClose}
        title={`Do you really want to delete workout group "${label}"?`}
        deleteCallback={deleteWorkoutGroup}
      />
    </React.Fragment>
  );
};

export default withDocument(WorkoutSectionItem);
Looks like
import sanityClient from 'part:@sanity/base/client'
Does the job!
Doing some further tests.
Just to confirm it uses sanity studio users loggin token and then it all depends on users permissions?
Correct.
Cool. Thank you guys!
Glad you got it sorted out!

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?