diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md
index 20f5ae106ff..fb2e90d0025 100644
--- a/catalog/CHANGELOG.md
+++ b/catalog/CHANGELOG.md
@@ -17,6 +17,7 @@ where verb is one of
 
 ## Changes
 
+- [Changed] Admin: Move bucket settings to a separate page ([#4122](https://github.com/quiltdata/quilt/pull/4122))
 - [Changed] Athena: always show catalog name, simplify setting execution context ([#4123](https://github.com/quiltdata/quilt/pull/4123))
 - [Added] Support `ui.actions.downloadObject` and `ui.actions.downloadPackage` options for configuring visibility of download buttons under "Bucket" and "Packages" respectively ([#4111](https://github.com/quiltdata/quilt/pull/4111))
 - [Added] Bootstrap the change log ([#4112](https://github.com/quiltdata/quilt/pull/4112))
diff --git a/catalog/app/constants/routes.ts b/catalog/app/constants/routes.ts
index b0c413b723f..3117f87663e 100644
--- a/catalog/app/constants/routes.ts
+++ b/catalog/app/constants/routes.ts
@@ -262,11 +262,23 @@ export const admin: Route = {
 
 export const adminUsers = admin
 
-export type AdminBucketsArgs = [bucket: string]
+export type AdminBucketsArgs = [
+  options?: {
+    add?: boolean
+    bucket?: string /* @deprecated legacy param   */
+  },
+]
 
 export const adminBuckets: Route<AdminBucketsArgs> = {
   path: '/admin/buckets',
-  url: (bucket) => `/admin/buckets${mkSearch({ bucket })}`,
+  url: ({ add, bucket } = {}) => `/admin/buckets${mkSearch({ add, bucket })}`,
+}
+
+export type AdminBucketEditArgs = [bucket: string]
+
+export const adminBucketEdit: Route<AdminBucketEditArgs> = {
+  path: '/admin/buckets/:bucketName',
+  url: (bucketName) => `/admin/buckets/${bucketName}`,
 }
 
 export const adminSettings: Route = {
diff --git a/catalog/app/containers/Admin/Admin.tsx b/catalog/app/containers/Admin/Admin.tsx
index df74f6472bb..7611f3029a9 100644
--- a/catalog/app/containers/Admin/Admin.tsx
+++ b/catalog/app/containers/Admin/Admin.tsx
@@ -82,7 +82,7 @@ export default function Admin() {
 
   const sections = {
     users: { path: paths.adminUsers, exact: true },
-    buckets: { path: paths.adminBuckets, exact: true },
+    buckets: { path: paths.adminBuckets },
     sync: { path: paths.adminSync, exact: true },
     settings: { path: paths.adminSettings, exact: true },
     status: { path: paths.adminStatus, exact: true },
@@ -105,9 +105,6 @@ export default function Admin() {
           <RR.Route path={paths.adminUsers} exact strict>
             <UsersAndRoles />
           </RR.Route>
-          <RR.Route path={paths.adminBuckets} exact>
-            <Buckets />
-          </RR.Route>
           {cfg.desktop && (
             <RR.Route path={paths.adminSync} exact>
               <Sync />
@@ -119,6 +116,9 @@ export default function Admin() {
           <RR.Route path={paths.adminStatus} exact>
             <Status />
           </RR.Route>
+          <RR.Route path={paths.adminBuckets}>
+            <Buckets />
+          </RR.Route>
           <RR.Route>
             <ThrowNotFound />
           </RR.Route>
diff --git a/catalog/app/containers/Admin/Buckets/Buckets.tsx b/catalog/app/containers/Admin/Buckets/Buckets.tsx
index eb3cceb03eb..a21ef812b14 100644
--- a/catalog/app/containers/Admin/Buckets/Buckets.tsx
+++ b/catalog/app/containers/Admin/Buckets/Buckets.tsx
@@ -1,4 +1,4 @@
-import * as dateFns from 'date-fns'
+import cx from 'classnames'
 import * as FF from 'final-form'
 import * as FP from 'fp-ts'
 import * as IO from 'io-ts'
@@ -6,39 +6,276 @@ import * as R from 'ramda'
 import * as React from 'react'
 import * as RF from 'react-final-form'
 import * as RRDom from 'react-router-dom'
+import { useDebounce } from 'use-debounce'
+import useResizeObserver from 'use-resize-observer'
 import * as M from '@material-ui/core'
 import * as Lab from '@material-ui/lab'
 
-import BucketIcon from 'components/BucketIcon'
+import * as Buttons from 'components/Buttons'
 import * as Dialog from 'components/Dialog'
-import * as Pagination from 'components/Pagination'
+import JsonDisplay from 'components/JsonDisplay'
 import Skeleton from 'components/Skeleton'
 import * as Notifications from 'containers/Notifications'
 import type * as Model from 'model'
 import * as APIConnector from 'utils/APIConnector'
 import Delay from 'utils/Delay'
-import * as Dialogs from 'utils/Dialogs'
 import type FormSpec from 'utils/FormSpec'
 import * as GQL from 'utils/GraphQL'
 import MetaTitle from 'utils/MetaTitle'
 import * as NamedRoutes from 'utils/NamedRoutes'
+import StyledLink from 'utils/StyledLink'
 import StyledTooltip from 'utils/StyledTooltip'
 import assertNever from 'utils/assertNever'
 import parseSearch from 'utils/parseSearch'
+import { formatQuantity } from 'utils/string'
 import { useTracker } from 'utils/tracking'
 import * as Types from 'utils/types'
 import * as validators from 'utils/validators'
 
 import * as Form from '../Form'
-import * as Table from '../Table'
+
+import ListPage, { ListSkeleton as ListPageSkeleton } from './List'
 
 import BUCKET_CONFIGS_QUERY from './gql/BucketConfigs.generated'
 import ADD_MUTATION from './gql/BucketsAdd.generated'
 import UPDATE_MUTATION from './gql/BucketsUpdate.generated'
-import REMOVE_MUTATION from './gql/BucketsRemove.generated'
 import { BucketConfigSelectionFragment as BucketConfig } from './gql/BucketConfigSelection.generated'
 import CONTENT_INDEXING_SETTINGS_QUERY from './gql/ContentIndexingSettings.generated'
 
+// TODO: organize skeletons
+
+const noop = () => {}
+
+const bucketToFormValues = (bucket: BucketConfig) => ({
+  title: bucket.title,
+  iconUrl: bucket.iconUrl || '',
+  description: bucket.description || '',
+  relevanceScore: bucket.relevanceScore.toString(),
+  overviewUrl: bucket.overviewUrl || '',
+  tags: (bucket.tags || []).join(', '),
+  linkedData: bucket.linkedData ? JSON.stringify(bucket.linkedData) : '',
+  enableDeepIndexing:
+    !R.equals(bucket.fileExtensionsToIndex, []) && bucket.indexContentBytes !== 0,
+  fileExtensionsToIndex: (bucket.fileExtensionsToIndex || []).join(', '),
+  indexContentBytes: bucket.indexContentBytes,
+  scannerParallelShardsDepth: bucket.scannerParallelShardsDepth?.toString() || '',
+  snsNotificationArn:
+    bucket.snsNotificationArn === DO_NOT_SUBSCRIBE_STR
+      ? DO_NOT_SUBSCRIBE_SYM
+      : bucket.snsNotificationArn,
+  skipMetaDataIndexing: bucket.skipMetaDataIndexing ?? false,
+  browsable: bucket.browsable ?? false,
+})
+
+interface CardAvatarProps {
+  className?: string
+  src: string
+}
+
+function CardAvatar({ className, src }: CardAvatarProps) {
+  if (src.startsWith('http')) return <M.Avatar className={className} src={src} />
+  return <M.Icon className={className}>{src}</M.Icon>
+}
+
+const useCardStyles = M.makeStyles((t) => ({
+  avatar: {
+    display: 'block',
+  },
+  header: {
+    paddingBottom: t.spacing(1),
+  },
+  content: {
+    paddingTop: 0,
+    '& > * + *': {
+      marginTop: t.spacing(1),
+    },
+  },
+}))
+
+interface CardProps {
+  children?: React.ReactNode
+  className?: string
+  disabled?: boolean
+  icon?: string | null
+  onEdit?: () => void
+  subTitle?: string
+  title: React.ReactNode
+}
+
+function Card({
+  children,
+  className,
+  disabled,
+  icon,
+  onEdit,
+  subTitle,
+  title,
+}: CardProps) {
+  const classes = useCardStyles()
+  return (
+    <M.Card className={className}>
+      <M.CardHeader
+        action={
+          onEdit && (
+            <M.IconButton onClick={onEdit} disabled={disabled}>
+              <M.Icon>edit</M.Icon>
+            </M.IconButton>
+          )
+        }
+        avatar={icon && <CardAvatar className={classes.avatar} src={icon} />}
+        className={classes.header}
+        subheader={subTitle}
+        title={title}
+      />
+      {children && <M.CardContent className={classes.content}>{children}</M.CardContent>}
+    </M.Card>
+  )
+}
+
+const useFormActionsStyles = M.makeStyles((t) => ({
+  actions: {
+    animation: `$show 150ms ease-out`,
+    display: 'flex',
+    justifyContent: 'flex-end',
+    padding: t.spacing(2, 1),
+    '& > * + *': {
+      // Spacing between direct children
+      marginLeft: t.spacing(2),
+    },
+  },
+  placeholder: {
+    height: t.spacing(8),
+  },
+  sticky: {
+    animation: `$sticking 150ms ease-out`,
+    bottom: 0,
+    left: '50%',
+    position: 'fixed',
+    transform: `translateX(-50%)`,
+  },
+  '@keyframes show': {
+    '0%': {
+      opacity: 0.3,
+    },
+    '100%': {
+      opacity: '1',
+    },
+  },
+  '@keyframes sticking': {
+    '0%': {
+      transform: 'translate(-50%, 10%)',
+    },
+    '100%': {
+      transform: 'translate(-50%, 0)',
+    },
+  },
+}))
+
+interface FormActionsProps {
+  children: React.ReactNode
+  siblingRef: React.RefObject<HTMLElement>
+}
+
+// 1. Listen scroll and sibling element resize
+// 2. Get the bottom of `<FormActions />` and debounce the value
+// 3. If the bottom is below the viewport, make the element `position: "fixed"`
+function FormActions({ children, siblingRef }: FormActionsProps) {
+  const classes = useFormActionsStyles()
+
+  const [bottom, setBottom] = React.useState(0)
+  const ref = React.useRef<HTMLDivElement>(null)
+  const handleScroll = React.useCallback(() => {
+    const rect = ref.current?.getBoundingClientRect()
+    if (!rect || !rect.height) return
+    setBottom(rect.bottom)
+  }, [])
+  React.useEffect(() => {
+    window.addEventListener('scroll', handleScroll)
+    return () => window.removeEventListener('scroll', handleScroll)
+  }, [handleScroll])
+  const { height: siblingHeight } = useResizeObserver({ ref: siblingRef })
+  React.useEffect(() => handleScroll(), [handleScroll, siblingHeight])
+
+  const DEBOUNCE_TIMEOUT = 150
+  const [debouncedBottom] = useDebounce(bottom, DEBOUNCE_TIMEOUT)
+  const sticky = React.useMemo(
+    () =>
+      debouncedBottom >= (window.innerHeight || document.documentElement.clientHeight),
+    [debouncedBottom],
+  )
+
+  return (
+    <div ref={ref}>
+      {sticky ? (
+        <>
+          <M.Container className={classes.sticky} maxWidth="lg">
+            <M.Paper className={classes.actions} elevation={8}>
+              {children}
+            </M.Paper>
+          </M.Container>
+          <div className={classes.placeholder} />
+        </>
+      ) : (
+        <div className={classes.actions}>{children}</div>
+      )}
+    </div>
+  )
+}
+
+const useSubPageHeaderStyles = M.makeStyles({
+  root: {
+    display: 'flex',
+  },
+  back: {
+    marginLeft: 'auto',
+  },
+})
+
+interface SubPageHeaderProps {
+  back: () => void
+  children?: React.ReactNode
+  dirty?: boolean
+  disabled?: boolean
+  submit: () => void
+}
+
+function SubPageHeader({ disabled, back, children, dirty, submit }: SubPageHeaderProps) {
+  const classes = useSubPageHeaderStyles()
+  const handleConfirm = React.useCallback(
+    (confirmed: boolean) => (confirmed ? submit() : back()),
+    [back, submit],
+  )
+  const confirm = Dialog.useConfirm({
+    cancelTitle: 'Discard',
+    onSubmit: handleConfirm,
+    submitTitle: 'Save',
+    title: 'You have unsaved changes',
+  })
+  const handleBack = React.useCallback(
+    () => (dirty ? confirm.open() : back()),
+    [back, confirm, dirty],
+  )
+  return (
+    <div className={classes.root}>
+      {confirm.render(<></>)}
+      {children && (
+        <M.Typography variant="h6" color="textPrimary">
+          {children}
+        </M.Typography>
+      )}
+      <M.Button
+        className={classes.back}
+        disabled={disabled}
+        onClick={handleBack}
+        size="small"
+        startIcon={<M.Icon>arrow_back</M.Icon>}
+      >
+        Back to buckets
+      </M.Button>
+    </div>
+  )
+}
+
 const SNS_ARN_RE = /^arn:aws(-|\w)*:sns:(-|\w)*:\d*:\S+$/
 
 const DO_NOT_SUBSCRIBE_STR = 'DO_NOT_SUBSCRIBE'
@@ -87,6 +324,7 @@ const usePFSCheckboxStyles = M.makeStyles({
     marginTop: -9,
   },
 })
+
 function PFSCheckbox({ input, meta }: Form.CheckboxProps & M.CheckboxProps) {
   const classes = usePFSCheckboxStyles()
   const confirm = React.useCallback((checked) => input?.onChange(checked), [input])
@@ -334,442 +572,812 @@ function Hint({ children }: HintProps) {
   )
 }
 
-const useBucketFieldsStyles = M.makeStyles((t) => ({
-  group: {
-    '& > *:first-child': {
-      marginTop: 0,
-    },
-  },
-  panel: {
-    margin: '0 !important',
-    '&::before': {
-      content: 'none',
+const useInlineActionsStyles = M.makeStyles((t) => ({
+  actions: {
+    display: 'flex',
+    justifyContent: 'flex-end',
+    padding: t.spacing(2, 0, 0),
+    '& > * + *': {
+      // Spacing between direct children
+      marginLeft: t.spacing(2),
     },
   },
-  panelSummary: {
-    padding: 0,
-    minHeight: 'auto !important',
+  error: {
+    flexGrow: 1,
   },
-  panelSummaryContent: {
-    margin: `${t.spacing(1)}px 0 !important`,
+}))
+
+interface InlineActionsProps {
+  form: FF.FormApi
+  onCancel: () => void
+}
+
+function InlineActions({ form, onCancel }: InlineActionsProps) {
+  const classes = useInlineActionsStyles()
+  const state = form.getState()
+  const handleCancel = React.useCallback(() => {
+    form.reset()
+    onCancel()
+  }, [form, onCancel])
+  return (
+    <div className={classes.actions}>
+      {state.submitFailed && (
+        <Form.FormError
+          className={classes.error}
+          error={state.error || state.submitError}
+          errors={{
+            unexpected: 'Something went wrong',
+            notificationConfigurationError: 'Notification configuration error',
+            bucketNotFound: 'Bucket not found',
+          }}
+          margin="none"
+        />
+      )}
+      {state.submitting && (
+        <Delay>
+          {() => (
+            <M.Box flexGrow={1} display="flex" pl={2}>
+              <M.CircularProgress size={24} />
+            </M.Box>
+          )}
+        </Delay>
+      )}
+      <M.Button
+        onClick={() => form.reset()}
+        color="primary"
+        disabled={state.pristine || state.submitting}
+      >
+        Reset
+      </M.Button>
+      <M.Button onClick={handleCancel} color="primary" disabled={state.submitting}>
+        Cancel
+      </M.Button>
+      <M.Button
+        onClick={form.submit}
+        color="primary"
+        disabled={
+          state.pristine ||
+          state.submitting ||
+          (state.submitFailed && state.hasValidationErrors)
+        }
+        variant="contained"
+      >
+        Save
+      </M.Button>
+    </div>
+  )
+}
+
+const useInlineFormStyles = M.makeStyles((t) => ({
+  root: {
+    padding: t.spacing(2),
   },
-  warning: {
-    background: t.palette.warning.main,
+  title: {
     marginBottom: t.spacing(1),
-    marginTop: t.spacing(2),
-  },
-  warningIcon: {
-    color: t.palette.warning.dark,
   },
 }))
 
-interface BucketFieldsProps {
-  bucket?: BucketConfig
-  reindex?: () => void
+interface InlineFormProps {
+  className?: string
+  title?: string
+  children: React.ReactNode
 }
 
-function BucketFields({ bucket, reindex }: BucketFieldsProps) {
-  const classes = useBucketFieldsStyles()
+function InlineForm({ className, children, title }: InlineFormProps) {
+  const classes = useInlineFormStyles()
+  return (
+    <M.Paper className={cx(classes.root, className)}>
+      {title && (
+        <M.Typography className={classes.title} variant="h6">
+          {title}
+        </M.Typography>
+      )}
+      {children}
+    </M.Paper>
+  )
+}
 
-  const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY)
-  const settings = data.config.contentIndexingSettings
+interface PrimaryFormProps {
+  bucket?: BucketConfig
+  className?: string
+  children?: React.ReactNode
+}
 
+function PrimaryForm({ bucket, children, className }: PrimaryFormProps) {
   return (
-    <M.Box>
-      <M.Box className={classes.group} mt={-1} pb={2}>
-        {bucket ? (
-          <M.TextField
-            label="Name"
-            value={bucket.name}
-            fullWidth
-            margin="normal"
-            disabled
-          />
-        ) : (
-          <RF.Field
-            component={Form.Field}
-            name="name"
-            label="Name"
-            placeholder="Enter an S3 bucket name"
-            parse={R.pipe(
-              R.toLower,
-              R.replace(/[^a-z0-9-.]/g, ''),
-              R.take(63) as (s: string) => string,
-            )}
-            validate={validators.required as FF.FieldValidator<any>}
-            errors={{
-              required: 'Enter a bucket name',
-              conflict: 'Bucket already added',
-              noSuchBucket: 'No such bucket',
-            }}
-            fullWidth
-            margin="normal"
-          />
-        )}
+    <InlineForm className={className}>
+      {bucket ? (
+        <M.TextField
+          label="Name"
+          value={bucket.name}
+          fullWidth
+          margin="normal"
+          disabled
+        />
+      ) : (
         <RF.Field
           component={Form.Field}
-          name="title"
-          label="Title"
-          placeholder='e.g. "Production analytics data"'
-          parse={R.pipe(R.replace(/^\s+/g, ''), R.take(256) as (s: string) => string)}
+          name="name"
+          label="Name"
+          placeholder="Enter an S3 bucket name"
+          parse={R.pipe(
+            R.toLower,
+            R.replace(/[^a-z0-9-.]/g, ''),
+            R.take(63) as (s: string) => string,
+          )}
           validate={validators.required as FF.FieldValidator<any>}
           errors={{
-            required: 'Enter a bucket title',
+            required: 'Enter a bucket name',
+            conflict: 'Bucket already added',
+            noSuchBucket: 'No such bucket',
           }}
           fullWidth
           margin="normal"
         />
-        <RF.Field
-          component={Form.Field}
-          name="iconUrl"
-          label="Icon URL (optional)"
-          placeholder="e.g. https://some-cdn.com/icon.png"
-          helperText="Recommended size: 80x80px"
-          parse={R.pipe(R.trim, R.take(1024) as (s: string) => string)}
-          fullWidth
-          margin="normal"
+      )}
+      <RF.Field
+        component={Form.Field}
+        name="title"
+        label="Title"
+        placeholder='e.g. "Production analytics data"'
+        parse={R.pipe(R.replace(/^\s+/g, ''), R.take(256) as (s: string) => string)}
+        validate={validators.required as FF.FieldValidator<any>}
+        errors={{
+          required: 'Enter a bucket title',
+        }}
+        fullWidth
+        margin="normal"
+      />
+      <RF.Field
+        component={Form.Field}
+        name="iconUrl"
+        label="Icon URL (optional)"
+        placeholder="e.g. https://some-cdn.com/icon.png"
+        helperText="Recommended size: 80x80px"
+        parse={R.pipe(R.trim, R.take(1024) as (s: string) => string)}
+        fullWidth
+        margin="normal"
+      />
+      <RF.Field
+        component={Form.Field}
+        name="description"
+        label="Description (optional)"
+        placeholder="Enter description if required"
+        parse={R.pipe(R.replace(/^\s+/g, ''), R.take(1024) as (s: string) => string)}
+        multiline
+        rows={1}
+        rowsMax={3}
+        fullWidth
+        margin="normal"
+      />
+      {children}
+    </InlineForm>
+  )
+}
+
+interface PrimaryCardProps {
+  bucket: BucketConfig
+  className: string
+  form: FF.FormApi
+}
+
+function PrimaryCard({ className, bucket, form }: PrimaryCardProps) {
+  const [editing, setEditing] = React.useState(false)
+  if (editing) {
+    return (
+      <PrimaryForm className={className} bucket={bucket}>
+        <InlineActions form={form} onCancel={() => setEditing(false)} />
+      </PrimaryForm>
+    )
+  }
+  return (
+    <Card
+      className={className}
+      disabled={form.getState().submitting}
+      icon={bucket.iconUrl || undefined}
+      onEdit={() => setEditing(true)}
+      subTitle={`s3://${bucket.name}`}
+      title={bucket.title}
+    >
+      {bucket.description && (
+        <M.Typography variant="body2">{bucket.description}</M.Typography>
+      )}
+    </Card>
+  )
+}
+
+interface MetadataFormProps {
+  children?: React.ReactNode
+  className: string
+}
+
+function MetadataForm({ children, className }: MetadataFormProps) {
+  return (
+    <InlineForm className={className} title="Metadata">
+      <RF.Field
+        component={Form.Field}
+        name="relevanceScore"
+        label="Relevance score"
+        placeholder="Higher numbers appear first, -1 to hide"
+        parse={R.pipe(
+          R.replace(/[^0-9-]/g, ''),
+          R.replace(/(.+)-+$/g, '$1'),
+          R.take(16) as (s: string) => string,
+        )}
+        validate={validators.integer as FF.FieldValidator<any>}
+        errors={{
+          integer: 'Enter a valid integer',
+        }}
+        fullWidth
+        margin="normal"
+      />
+      <RF.Field
+        component={Form.Field}
+        name="tags"
+        label="Tags (comma-separated)"
+        placeholder='e.g. "geospatial", for bucket discovery'
+        fullWidth
+        margin="normal"
+        multiline
+        rows={1}
+        rowsMax={3}
+      />
+      <RF.Field
+        component={Form.Field}
+        name="overviewUrl"
+        label="Overview URL"
+        parse={R.trim}
+        fullWidth
+        margin="normal"
+      />
+      <RF.Field
+        component={Form.Field}
+        name="linkedData"
+        label="Structured data (JSON-LD)"
+        validate={validators.jsonObject as FF.FieldValidator<any>}
+        errors={{
+          jsonObject: 'Must be a valid JSON object',
+        }}
+        fullWidth
+        multiline
+        rows={1}
+        rowsMax={10}
+        margin="normal"
+      />
+      {children}
+    </InlineForm>
+  )
+}
+
+const useMetadataCardStyles = M.makeStyles((t) => ({
+  tagsList: {
+    marginBottoM: t.spacing(-1),
+  },
+  tag: {
+    marginBottom: t.spacing(1),
+    verticalAlign: 'baseline',
+    '& + &': {
+      marginLeft: t.spacing(0.5),
+    },
+  },
+}))
+
+interface MetadataCardProps {
+  bucket: BucketConfig
+  className: string
+  form: FF.FormApi
+}
+
+function MetadataCard({ bucket, className, form }: MetadataCardProps) {
+  const classes = useMetadataCardStyles()
+  const [editing, setEditing] = React.useState(false)
+  if (editing) {
+    return (
+      <MetadataForm className={className}>
+        <InlineActions form={form} onCancel={() => setEditing(false)} />
+      </MetadataForm>
+    )
+  }
+  return (
+    <Card
+      className={className}
+      disabled={form.getState().submitting}
+      icon="toc"
+      onEdit={() => setEditing(true)}
+      title="Metadata"
+    >
+      {bucket.description && (
+        <M.Typography variant="body2">{bucket.description}</M.Typography>
+      )}
+      <M.Typography variant="body2">
+        Relevance score: {bucket.relevanceScore.toString()}
+      </M.Typography>
+      {bucket.tags && (
+        <M.Typography variant="body2" className={classes.tagsList}>
+          Tags:{' '}
+          {bucket.tags.map((tag) => (
+            <M.Chip
+              className={classes.tag}
+              label={tag}
+              key={tag}
+              component="span"
+              size="small"
+            />
+          ))}
+        </M.Typography>
+      )}
+      {bucket.overviewUrl && (
+        <M.Typography variant="body2">
+          Overview URL:{' '}
+          <StyledLink href={bucket.overviewUrl} target="_blank">
+            {bucket.overviewUrl}
+          </StyledLink>
+        </M.Typography>
+      )}
+      {bucket.linkedData && (
+        // @ts-expect-error
+        <JsonDisplay
+          name="Structured data (JSON-LD)"
+          topLevel
+          value={bucket.linkedData}
         />
+      )}
+    </Card>
+  )
+}
+
+interface IndexingAndNotificationsFormProps {
+  bucket?: BucketConfig
+  children?: React.ReactNode
+  className: string
+  reindex?: () => void
+  settings: Model.GQLTypes.ContentIndexingSettings
+}
+
+function IndexingAndNotificationsForm({
+  bucket,
+  children,
+  className,
+  reindex,
+  settings,
+}: IndexingAndNotificationsFormProps) {
+  const classes = useIndexingAndNotificationsFormStyles()
+  return (
+    <InlineForm className={className} title="Indexing and notifications">
+      {!!reindex && (
+        <M.Box pb={2.5}>
+          <M.Button variant="outlined" fullWidth onClick={reindex}>
+            Re-index and repair
+          </M.Button>
+        </M.Box>
+      )}
+
+      <RF.Field
+        component={Form.Checkbox}
+        type="checkbox"
+        name="enableDeepIndexing"
+        label={
+          <>
+            Enable deep indexing
+            <Hint>
+              Deep indexing adds the <em>contents</em> of an object to your search index,
+              while shallow indexing only covers object metadata. Deep indexing may
+              require more disk in ElasticSearch. Enable deep indexing when you want your
+              users to find files by their contents.
+            </Hint>
+          </>
+        }
+      />
+
+      <RF.FormSpy subscription={{ modified: true, values: true }}>
+        {({ modified, values }) => {
+          // don't need this while adding a bucket
+          if (!bucket) return null
+          if (
+            !modified?.enableDeepIndexing &&
+            !modified?.fileExtensionsToIndex &&
+            !modified?.indexContentBytes
+          )
+            return null
+          try {
+            if (
+              R.equals(
+                bucket.fileExtensionsToIndex,
+                editFormSpec.fileExtensionsToIndex(values),
+              ) &&
+              R.equals(bucket.indexContentBytes, editFormSpec.indexContentBytes(values))
+            )
+              return null
+          } catch {
+            return null
+          }
+
+          return (
+            <Lab.Alert
+              className={classes.warning}
+              icon={
+                <M.Icon fontSize="inherit" className={classes.warningIcon}>
+                  error
+                </M.Icon>
+              }
+              severity="warning"
+            >
+              Changing these settings affects files that are indexed after the change. If
+              you wish to deep index existing files, click{' '}
+              <strong>&quot;Re-index and repair&quot;</strong>.
+            </Lab.Alert>
+          )
+        }}
+      </RF.FormSpy>
+
+      <RF.FormSpy subscription={{ values: true }}>
+        {({ values }) => {
+          if (!values.enableDeepIndexing) return null
+          return (
+            <>
+              <RF.Field
+                component={Form.Field}
+                name="fileExtensionsToIndex"
+                label={
+                  <>
+                    File extensions to deep index (comma-separated)
+                    <Hint>
+                      Default extensions:
+                      <ul>
+                        {settings.extensions.map((ext) => (
+                          <li key={ext}>{ext}</li>
+                        ))}
+                      </ul>
+                    </Hint>
+                  </>
+                }
+                placeholder='e.g. ".txt, .md", leave blank to use default settings'
+                validate={validateExtensions}
+                errors={{
+                  validExtensions: (
+                    <>
+                      Enter a comma-separated list of{' '}
+                      <abbr title="Must start with the dot and contain only alphanumeric characters thereafter">
+                        valid
+                      </abbr>{' '}
+                      file extensions
+                    </>
+                  ),
+                }}
+                fullWidth
+                margin="normal"
+                multiline
+                rows={1}
+                rowsMax={3}
+              />
+              <RF.Field
+                component={Form.Field}
+                name="indexContentBytes"
+                label={
+                  <>
+                    Content bytes to deep index
+                    <Hint>Defaults to {settings.bytesDefault}</Hint>
+                  </>
+                }
+                placeholder='e.g. "1024", leave blank to use default settings'
+                parse={R.replace(/[^0-9]/g, '')}
+                validate={integerInRange(settings.bytesMin, settings.bytesMax)}
+                errors={{
+                  integerInRange: (
+                    <>
+                      Enter an integer from {settings.bytesMin} to {settings.bytesMax}
+                    </>
+                  ),
+                }}
+                fullWidth
+                margin="normal"
+              />
+            </>
+          )
+        }}
+      </RF.FormSpy>
+      <RF.Field
+        component={Form.Field}
+        name="scannerParallelShardsDepth"
+        label="Scanner parallel shards depth"
+        placeholder="Leave blank to use default settings"
+        validate={validators.integer as FF.FieldValidator<any>}
+        errors={{
+          integer: 'Enter a valid integer',
+        }}
+        parse={R.pipe(R.replace(/[^0-9]/g, ''), R.take(16) as (s: string) => string)}
+        fullWidth
+        margin="normal"
+      />
+      <RF.Field component={SnsField} name="snsNotificationArn" validate={validateSns} />
+      <M.Box mt={2}>
         <RF.Field
-          component={Form.Field}
-          name="description"
-          label="Description (optional)"
-          placeholder="Enter description if required"
-          parse={R.pipe(R.replace(/^\s+/g, ''), R.take(1024) as (s: string) => string)}
-          multiline
-          rows={1}
-          rowsMax={3}
-          fullWidth
-          margin="normal"
+          component={Form.Checkbox}
+          type="checkbox"
+          name="skipMetaDataIndexing"
+          label="Skip metadata indexing"
         />
       </M.Box>
-      <M.Accordion elevation={0} className={classes.panel}>
-        <M.AccordionSummary
-          expandIcon={<M.Icon>expand_more</M.Icon>}
-          classes={{
-            root: classes.panelSummary,
-            content: classes.panelSummaryContent,
-          }}
-        >
-          <M.Typography variant="h6">Metadata</M.Typography>
-        </M.AccordionSummary>
-        <M.Box className={classes.group} pt={1} pb={2}>
-          <RF.Field
-            component={Form.Field}
-            name="relevanceScore"
-            label="Relevance score"
-            placeholder="Higher numbers appear first, -1 to hide"
-            parse={R.pipe(
-              R.replace(/[^0-9-]/g, ''),
-              R.replace(/(.+)-+$/g, '$1'),
-              R.take(16) as (s: string) => string,
-            )}
-            validate={validators.integer as FF.FieldValidator<any>}
-            errors={{
-              integer: 'Enter a valid integer',
-            }}
-            fullWidth
-            margin="normal"
-          />
+      {!bucket && (
+        <M.Box mt={1}>
           <RF.Field
-            component={Form.Field}
-            name="tags"
-            label="Tags (comma-separated)"
-            placeholder='e.g. "geospatial", for bucket discovery'
-            fullWidth
-            margin="normal"
-            multiline
-            rows={1}
-            rowsMax={3}
-          />
-          <RF.Field
-            component={Form.Field}
-            name="overviewUrl"
-            label="Overview URL"
-            parse={R.trim}
-            fullWidth
-            margin="normal"
-          />
-          <RF.Field
-            component={Form.Field}
-            name="linkedData"
-            label="Structured data (JSON-LD)"
-            validate={validators.jsonObject as FF.FieldValidator<any>}
-            errors={{
-              jsonObject: 'Must be a valid JSON object',
-            }}
-            fullWidth
-            multiline
-            rows={1}
-            rowsMax={10}
-            margin="normal"
+            component={Form.Checkbox}
+            type="checkbox"
+            name="delayScan"
+            label="Delay scan"
           />
         </M.Box>
-      </M.Accordion>
-      <M.Accordion elevation={0} className={classes.panel}>
-        <M.AccordionSummary
-          expandIcon={<M.Icon>expand_more</M.Icon>}
-          classes={{
-            root: classes.panelSummary,
-            content: classes.panelSummaryContent,
-          }}
+      )}
+      {children}
+    </InlineForm>
+  )
+}
+
+const useIndexingAndNotificationsFormStyles = M.makeStyles((t) => ({
+  warning: {
+    background: t.palette.warning.main,
+    marginBottom: t.spacing(1),
+    marginTop: t.spacing(2),
+  },
+  warningIcon: {
+    color: t.palette.warning.dark,
+  },
+}))
+
+interface IndexingAndNotificationsCardProps {
+  bucket: BucketConfig
+  className: string
+  form: FF.FormApi
+  reindex?: () => void
+}
+
+function IndexingAndNotificationsCard({
+  bucket,
+  className,
+  form,
+  reindex,
+}: IndexingAndNotificationsCardProps) {
+  const [editing, setEditing] = React.useState(false)
+
+  const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY)
+  const settings = data.config.contentIndexingSettings
+
+  if (editing) {
+    return (
+      <IndexingAndNotificationsForm
+        bucket={bucket}
+        className={className}
+        reindex={reindex}
+        settings={settings}
+      >
+        <InlineActions form={form} onCancel={() => setEditing(false)} />
+      </IndexingAndNotificationsForm>
+    )
+  }
+
+  const { enableDeepIndexing, snsNotificationArn } = bucketToFormValues(bucket)
+  return (
+    <Card
+      className={className}
+      disabled={form.getState().submitting}
+      onEdit={() => setEditing(true)}
+      icon="find_in_page"
+      title="Indexing and notifications"
+    >
+      {!!reindex && (
+        <M.Button
+          variant="outlined"
+          onClick={reindex}
+          size="small"
+          disabled={form.getState().submitting}
         >
-          <M.Typography variant="h6">Indexing and notifications</M.Typography>
-        </M.AccordionSummary>
-        <M.Box className={classes.group} pt={1}>
-          {!!reindex && (
-            <M.Box pb={2.5}>
-              <M.Button variant="outlined" fullWidth onClick={reindex}>
-                Re-index and repair
-              </M.Button>
-            </M.Box>
+          Re-index and repair
+        </M.Button>
+      )}
+      {enableDeepIndexing ? (
+        <>
+          {bucket.fileExtensionsToIndex ? (
+            <M.Typography variant="body2">
+              File extensions to deep index:
+              {bucket.fileExtensionsToIndex.join(', ')}
+            </M.Typography>
+          ) : (
+            <M.Typography variant="body2">
+              Default file extensions to deep index:
+              {settings.extensions.join(', ')}
+            </M.Typography>
+          )}
+          {bucket.indexContentBytes ? (
+            <M.Typography variant="body2">
+              Content bytes to deep index is {formatQuantity(bucket.indexContentBytes)}{' '}
+              bytes
+            </M.Typography>
+          ) : (
+            <M.Typography variant="body2">
+              Default content bytes to deep index is{' '}
+              {formatQuantity(settings.bytesDefault)} bytes
+            </M.Typography>
           )}
+        </>
+      ) : (
+        <M.Typography variant="body2">Deep indexing is disabled</M.Typography>
+      )}
+      {bucket.scannerParallelShardsDepth && (
+        <M.Typography variant="body2">
+          Scanner parallel shards depth: {bucket.scannerParallelShardsDepth}
+        </M.Typography>
+      )}
+      {bucket.skipMetaDataIndexing && (
+        <M.Typography variant="body2">Metadata indexing is disabled</M.Typography>
+      )}
+      {typeof snsNotificationArn === 'string' && (
+        <M.Typography variant="body2">
+          SNS Topic ARN:{' '}
+          <StyledLink
+            href={`https://console.aws.amazon.com/sns/v3/home?#/topic/${bucket.snsNotificationArn}`}
+            target="_blank"
+          >
+            {bucket.snsNotificationArn}
+          </StyledLink>
+        </M.Typography>
+      )}
+    </Card>
+  )
+}
 
-          <RF.Field
-            component={Form.Checkbox}
-            type="checkbox"
-            name="enableDeepIndexing"
-            label={
-              <>
-                Enable deep indexing
-                <Hint>
-                  Deep indexing adds the <em>contents</em> of an object to your search
-                  index, while shallow indexing only covers object metadata. Deep indexing
-                  may require more disk in ElasticSearch. Enable deep indexing when you
-                  want your users to find files by their contents.
-                </Hint>
-              </>
-            }
-          />
+interface PreviewFormProps {
+  children?: React.ReactNode
+  className: string
+}
 
-          <RF.FormSpy subscription={{ modified: true, values: true }}>
-            {({ modified, values }) => {
-              // don't need this while adding a bucket
-              if (!bucket) return null
-              if (
-                !modified?.enableDeepIndexing &&
-                !modified?.fileExtensionsToIndex &&
-                !modified?.indexContentBytes
-              )
-                return null
-              try {
-                if (
-                  R.equals(
-                    bucket.fileExtensionsToIndex,
-                    editFormSpec.fileExtensionsToIndex(values),
-                  ) &&
-                  R.equals(
-                    bucket.indexContentBytes,
-                    editFormSpec.indexContentBytes(values),
-                  )
-                )
-                  return null
-              } catch {
-                return null
-              }
+function PreviewForm({ children, className }: PreviewFormProps) {
+  return (
+    <InlineForm className={className}>
+      <RF.Field component={PFSCheckbox} name="browsable" type="checkbox" />
+      {children}
+    </InlineForm>
+  )
+}
 
-              return (
-                <Lab.Alert
-                  className={classes.warning}
-                  icon={
-                    <M.Icon fontSize="inherit" className={classes.warningIcon}>
-                      error
-                    </M.Icon>
-                  }
-                  severity="warning"
-                >
-                  Changing these settings affects files that are indexed after the change.
-                  If you wish to deep index existing files, click{' '}
-                  <strong>&quot;Re-index and repair&quot;</strong>.
-                </Lab.Alert>
-              )
-            }}
-          </RF.FormSpy>
-
-          <RF.FormSpy subscription={{ values: true }}>
-            {({ values }) => {
-              if (!values.enableDeepIndexing) return null
-              return (
-                <>
-                  <RF.Field
-                    component={Form.Field}
-                    name="fileExtensionsToIndex"
-                    label={
-                      <>
-                        File extensions to deep index (comma-separated)
-                        <Hint>
-                          Default extensions:
-                          <ul>
-                            {settings.extensions.map((ext) => (
-                              <li key={ext}>{ext}</li>
-                            ))}
-                          </ul>
-                        </Hint>
-                      </>
-                    }
-                    placeholder='e.g. ".txt, .md", leave blank to use default settings'
-                    validate={validateExtensions}
-                    errors={{
-                      validExtensions: (
-                        <>
-                          Enter a comma-separated list of{' '}
-                          <abbr title="Must start with the dot and contain only alphanumeric characters thereafter">
-                            valid
-                          </abbr>{' '}
-                          file extensions
-                        </>
-                      ),
-                    }}
-                    fullWidth
-                    margin="normal"
-                    multiline
-                    rows={1}
-                    rowsMax={3}
-                  />
-                  <RF.Field
-                    component={Form.Field}
-                    name="indexContentBytes"
-                    label={
-                      <>
-                        Content bytes to deep index
-                        <Hint>Defaults to {settings.bytesDefault}</Hint>
-                      </>
-                    }
-                    placeholder='e.g. "1024", leave blank to use default settings'
-                    parse={R.replace(/[^0-9]/g, '')}
-                    validate={integerInRange(settings.bytesMin, settings.bytesMax)}
-                    errors={{
-                      integerInRange: (
-                        <>
-                          Enter an integer from {settings.bytesMin} to {settings.bytesMax}
-                        </>
-                      ),
-                    }}
-                    fullWidth
-                    margin="normal"
-                  />
-                </>
-              )
-            }}
-          </RF.FormSpy>
-          <RF.Field
-            component={Form.Field}
-            name="scannerParallelShardsDepth"
-            label="Scanner parallel shards depth"
-            placeholder="Leave blank to use default settings"
-            validate={validators.integer as FF.FieldValidator<any>}
-            errors={{
-              integer: 'Enter a valid integer',
-            }}
-            parse={R.pipe(R.replace(/[^0-9]/g, ''), R.take(16) as (s: string) => string)}
-            fullWidth
-            margin="normal"
-          />
-          <RF.Field
-            component={SnsField}
-            name="snsNotificationArn"
-            validate={validateSns}
-          />
-          <M.Box mt={2}>
-            <RF.Field
-              component={Form.Checkbox}
-              type="checkbox"
-              name="skipMetaDataIndexing"
-              label="Skip metadata indexing"
-            />
-          </M.Box>
-          {!bucket && (
-            <M.Box mt={1}>
-              <RF.Field
-                component={Form.Checkbox}
-                type="checkbox"
-                name="delayScan"
-                label="Delay scan"
-              />
-            </M.Box>
-          )}
-        </M.Box>
-      </M.Accordion>
-      <M.Accordion elevation={0} className={classes.panel}>
-        <M.AccordionSummary
-          expandIcon={<M.Icon>expand_more</M.Icon>}
-          classes={{
-            root: classes.panelSummary,
-            content: classes.panelSummaryContent,
-          }}
-        >
-          <M.Typography variant="h6">File preview options</M.Typography>
-        </M.AccordionSummary>
-        <M.Box className={classes.group} pt={1}>
-          <RF.Field component={PFSCheckbox} name="browsable" type="checkbox" />
-        </M.Box>
-      </M.Accordion>
-    </M.Box>
+interface PreviewCardProps {
+  className: string
+  bucket: BucketConfig
+  form: FF.FormApi
+}
+
+function PreviewCard({ bucket, className, form }: PreviewCardProps) {
+  const [editing, setEditing] = React.useState(false)
+  if (editing) {
+    return (
+      <PreviewForm className={className}>
+        <InlineActions form={form} onCancel={() => setEditing(false)} />
+      </PreviewForm>
+    )
+  }
+  return (
+    <Card
+      className={className}
+      disabled={form.getState().submitting}
+      onEdit={() => setEditing(true)}
+      icon="code"
+      title={`Permissive HTML rendering is ${bucket.browsable ? 'enabled' : 'disabled'}`}
+    />
   )
 }
 
-function BucketFieldsPlaceholder() {
+const useStyles = M.makeStyles((t) => ({
+  card: {
+    '& + &': {
+      marginTop: t.spacing(2),
+    },
+  },
+  error: {
+    flexGrow: 1,
+  },
+  fields: {
+    marginTop: t.spacing(2),
+  },
+}))
+
+interface AddPageSkeletonProps {
+  back: () => void
+}
+
+function AddPageSkeleton({ back }: AddPageSkeletonProps) {
+  const classes = useStyles()
+  const formRef = React.useRef<HTMLDivElement>(null)
   return (
     <>
-      {R.times(
-        (i) => (
-          <Skeleton key={i} height={48} mt={i ? 3 : 0} />
-        ),
-        5,
-      )}
+      <SubPageHeader back={back} submit={noop}>
+        Add a bucket
+      </SubPageHeader>
+      <div className={classes.fields} ref={formRef}>
+        <InlineForm className={classes.card}>
+          <Skeleton height={54} />
+          <Skeleton height={54} mt={4} />
+          <Skeleton height={54} mt={4} />
+        </InlineForm>
+        <InlineForm className={classes.card}>
+          <Skeleton height={54} />
+          <Skeleton height={54} mt={4} />
+          <Skeleton height={54} mt={4} />
+        </InlineForm>
+        <InlineForm className={classes.card}>
+          <Skeleton height={54} />
+          <Skeleton height={54} mt={4} />
+          <Skeleton height={54} mt={4} />
+        </InlineForm>
+        <InlineForm className={classes.card}>
+          <Skeleton height={54} />
+        </InlineForm>
+        <FormActions siblingRef={formRef}>
+          <Buttons.Skeleton />
+          <Buttons.Skeleton />
+        </FormActions>
+      </div>
     </>
   )
 }
 
+function parseResponseError(
+  r:
+    | Exclude<Model.GQLTypes.BucketAddResult, Model.GQLTypes.BucketAddSuccess>
+    | Exclude<Model.GQLTypes.BucketUpdateResult, Model.GQLTypes.BucketUpdateSuccess>,
+): FF.SubmissionErrors | undefined {
+  switch (r.__typename) {
+    case 'BucketAlreadyAdded':
+      return { name: 'conflict' }
+    case 'BucketDoesNotExist':
+      return { name: 'noSuchBucket' }
+    case 'SnsInvalid':
+      // shouldnt happen since we're validating it
+      return { snsNotificationArn: 'invalidArn' }
+    case 'NotificationTopicNotFound':
+      return { snsNotificationArn: 'topicNotFound' }
+    case 'NotificationConfigurationError':
+      return {
+        snsNotificationArn: 'configurationError',
+        [FF.FORM_ERROR]: 'notificationConfigurationError',
+      }
+    case 'InsufficientPermissions':
+      return { [FF.FORM_ERROR]: 'insufficientPermissions' }
+    case 'SubscriptionInvalid':
+      return { [FF.FORM_ERROR]: 'subscriptionInvalid' }
+    case 'BucketIndexContentBytesInvalid':
+      // shouldnt happen since we valide input
+      return { indexContentBytes: 'integerInRange' }
+    case 'BucketFileExtensionsToIndexInvalid':
+      // shouldnt happen since we valide input
+      return { fileExtensionsToIndex: 'validExtensions' }
+    case 'BucketNotFound':
+      return { [FF.FORM_ERROR]: 'bucketNotFound' }
+    default:
+      return assertNever(r)
+  }
+}
+
 interface AddProps {
-  close: (reason?: string) => void
+  back: (reason?: string) => void
+  settings: Model.GQLTypes.ContentIndexingSettings
+  submit: (
+    input: Model.GQLTypes.BucketAddInput,
+  ) => Promise<
+    | Exclude<Model.GQLTypes.BucketAddResult, Model.GQLTypes.BucketAddSuccess>
+    | Error
+    | undefined
+  >
 }
 
-function Add({ close }: AddProps) {
-  const { push } = Notifications.use()
-  const t = useTracker()
-  const add = GQL.useMutation(ADD_MUTATION)
+function Add({ back, settings, submit }: AddProps) {
+  const classes = useStyles()
   const onSubmit = React.useCallback(
     async (values) => {
       try {
         const input = R.applySpec(addFormSpec)(values)
-        const { bucketAdd: r } = await add({ input })
-        switch (r.__typename) {
-          case 'BucketAddSuccess':
-            push(`Bucket "${r.bucketConfig.name}" added`)
-            t.track('WEB', {
-              type: 'admin',
-              action: 'bucket add',
-              bucket: r.bucketConfig.name,
-            })
-            close()
-            return undefined
-          case 'BucketAlreadyAdded':
-            return { name: 'conflict' }
-          case 'BucketDoesNotExist':
-            return { name: 'noSuchBucket' }
-          case 'SnsInvalid':
-            // shouldnt happen since we're validating it
-            return { snsNotificationArn: 'invalidArn' }
-          case 'NotificationTopicNotFound':
-            return { snsNotificationArn: 'topicNotFound' }
-          case 'NotificationConfigurationError':
-            return {
-              snsNotificationArn: 'configurationError',
-              [FF.FORM_ERROR]: 'notificationConfigurationError',
-            }
-          case 'InsufficientPermissions':
-            return { [FF.FORM_ERROR]: 'insufficientPermissions' }
-          case 'SubscriptionInvalid':
-            return { [FF.FORM_ERROR]: 'subscriptionInvalid' }
-          case 'BucketIndexContentBytesInvalid':
-            // shouldnt happen since we valide input
-            return { indexContentBytes: 'integerInRange' }
-          case 'BucketFileExtensionsToIndexInvalid':
-            // shouldnt happen since we valide input
-            return { fileExtensionsToIndex: 'validExtensions' }
-          default:
-            return assertNever(r)
-        }
+        const error = await submit(input)
+        if (!error) return
+        if (error instanceof Error) throw error
+        return parseResponseError(error)
       } catch (e) {
         // eslint-disable-next-line no-console
         console.error('Error adding bucket')
@@ -778,12 +1386,13 @@ function Add({ close }: AddProps) {
         return { [FF.FORM_ERROR]: 'unexpected' }
       }
     },
-    [add, push, close, t],
+    [submit],
   )
-
+  const formRef = React.useRef<HTMLFormElement>(null)
   return (
     <RF.Form onSubmit={onSubmit} initialValues={{ enableDeepIndexing: true }}>
       {({
+        dirty,
         handleSubmit,
         submitting,
         submitFailed,
@@ -792,27 +1401,35 @@ function Add({ close }: AddProps) {
         hasValidationErrors,
       }) => (
         <>
-          <M.DialogTitle>Add a bucket</M.DialogTitle>
-          <M.DialogContent>
-            <React.Suspense fallback={<BucketFieldsPlaceholder />}>
-              <form onSubmit={handleSubmit}>
-                <BucketFields />
-                {submitFailed && (
-                  <Form.FormError
-                    error={error || submitError}
-                    errors={{
-                      unexpected: 'Something went wrong',
-                      notificationConfigurationError: 'Notification configuration error',
-                      insufficientPermissions: 'Insufficient permissions',
-                      subscriptionInvalid: 'Subscription invalid',
-                    }}
-                  />
-                )}
-                <input type="submit" style={{ display: 'none' }} />
-              </form>
-            </React.Suspense>
-          </M.DialogContent>
-          <M.DialogActions>
+          <SubPageHeader
+            back={back}
+            dirty={dirty}
+            disabled={submitting}
+            submit={handleSubmit}
+          >
+            Add a bucket
+          </SubPageHeader>
+          <form onSubmit={handleSubmit} ref={formRef} className={classes.fields}>
+            <PrimaryForm className={classes.card} />
+            <MetadataForm className={classes.card} />
+            <IndexingAndNotificationsForm className={classes.card} settings={settings} />
+            <PreviewForm className={classes.card} />
+            <input type="submit" style={{ display: 'none' }} />
+          </form>
+          <FormActions siblingRef={formRef}>
+            {submitFailed && (
+              <Form.FormError
+                className={classes.error}
+                error={error || submitError}
+                errors={{
+                  unexpected: 'Something went wrong',
+                  notificationConfigurationError: 'Notification configuration error',
+                  insufficientPermissions: 'Insufficient permissions',
+                  subscriptionInvalid: 'Subscription invalid',
+                }}
+                margin="none"
+              />
+            )}
             {submitting && (
               <Delay>
                 {() => (
@@ -823,7 +1440,7 @@ function Add({ close }: AddProps) {
               </Delay>
             )}
             <M.Button
-              onClick={() => close('cancel')}
+              onClick={() => back('cancel')}
               color="primary"
               disabled={submitting}
             >
@@ -833,10 +1450,11 @@ function Add({ close }: AddProps) {
               onClick={handleSubmit}
               color="primary"
               disabled={submitting || (submitFailed && hasValidationErrors)}
+              variant="contained"
             >
               Add
             </M.Button>
-          </M.DialogActions>
+          </FormActions>
         </>
       )}
     </RF.Form>
@@ -973,48 +1591,72 @@ function Reindex({ bucket, open, close }: ReindexProps) {
   )
 }
 
+interface BucketFieldSkeletonProps {
+  className: string
+  width: number
+}
+
+function BucketFieldSkeleton({ className, width }: BucketFieldSkeletonProps) {
+  return <Card className={className} title={<Skeleton height={32} width={width} />} />
+}
+
+interface CardsPlaceholderProps {
+  className: string
+}
+
+function CardsPlaceholder({ className }: CardsPlaceholderProps) {
+  const classes = useStyles()
+  return (
+    <div className={className}>
+      <BucketFieldSkeleton className={classes.card} width={300} />
+      <BucketFieldSkeleton className={classes.card} width={100} />
+      <BucketFieldSkeleton className={classes.card} width={240} />
+      <BucketFieldSkeleton className={classes.card} width={180} />
+    </div>
+  )
+}
+
+interface EditPageSkeletonProps {
+  back: () => void
+}
+
+function EditPageSkeleton({ back }: EditPageSkeletonProps) {
+  const classes = useStyles()
+  return (
+    <>
+      <SubPageHeader back={back} submit={noop} />
+      <CardsPlaceholder className={classes.fields} />
+    </>
+  )
+}
+
 interface EditProps {
   bucket: BucketConfig
-  close: (reason?: string) => void
+  back: (reason?: string) => void
+  submit: (
+    input: Model.GQLTypes.BucketUpdateInput,
+  ) => Promise<
+    | Exclude<Model.GQLTypes.BucketUpdateResult, Model.GQLTypes.BucketUpdateSuccess>
+    | Error
+    | undefined
+  >
 }
 
-function Edit({ bucket, close }: EditProps) {
-  const update = GQL.useMutation(UPDATE_MUTATION)
-
+function Edit({ bucket, back, submit }: EditProps) {
   const [reindexOpen, setReindexOpen] = React.useState(false)
   const openReindex = React.useCallback(() => setReindexOpen(true), [])
   const closeReindex = React.useCallback(() => setReindexOpen(false), [])
 
+  const classes = useStyles()
+
   const onSubmit = React.useCallback(
     async (values) => {
       try {
         const input = R.applySpec(editFormSpec)(values)
-        const { bucketUpdate: r } = await update({ name: bucket.name, input })
-        switch (r.__typename) {
-          case 'BucketUpdateSuccess':
-            close()
-            return undefined
-          case 'SnsInvalid':
-            // shouldnt happen since we're validating it
-            return { snsNotificationArn: 'invalidArn' }
-          case 'NotificationTopicNotFound':
-            return { snsNotificationArn: 'topicNotFound' }
-          case 'NotificationConfigurationError':
-            return {
-              snsNotificationArn: 'configurationError',
-              [FF.FORM_ERROR]: 'notificationConfigurationError',
-            }
-          case 'BucketNotFound':
-            return { [FF.FORM_ERROR]: 'bucketNotFound' }
-          case 'BucketIndexContentBytesInvalid':
-            // shouldnt happen since we valide input
-            return { indexContentBytes: 'integerInRange' }
-          case 'BucketFileExtensionsToIndexInvalid':
-            // shouldnt happen since we valide input
-            return { fileExtensionsToIndex: 'validExtensions' }
-          default:
-            return assertNever(r)
-        }
+        const error = await submit(input)
+        if (!error) return
+        if (error instanceof Error) throw error
+        return parseResponseError(error)
       } catch (e) {
         // eslint-disable-next-line no-console
         console.error('Error updating bucket')
@@ -1023,29 +1665,12 @@ function Edit({ bucket, close }: EditProps) {
         return { [FF.FORM_ERROR]: 'unexpected' }
       }
     },
-    [update, close, bucket.name],
+    [submit],
   )
 
-  const initialValues = {
-    title: bucket.title,
-    iconUrl: bucket.iconUrl || '',
-    description: bucket.description || '',
-    relevanceScore: bucket.relevanceScore.toString(),
-    overviewUrl: bucket.overviewUrl || '',
-    tags: (bucket.tags || []).join(', '),
-    linkedData: bucket.linkedData ? JSON.stringify(bucket.linkedData) : '',
-    enableDeepIndexing:
-      !R.equals(bucket.fileExtensionsToIndex, []) && bucket.indexContentBytes !== 0,
-    fileExtensionsToIndex: (bucket.fileExtensionsToIndex || []).join(', '),
-    indexContentBytes: bucket.indexContentBytes,
-    scannerParallelShardsDepth: bucket.scannerParallelShardsDepth?.toString() || '',
-    snsNotificationArn:
-      bucket.snsNotificationArn === DO_NOT_SUBSCRIBE_STR
-        ? DO_NOT_SUBSCRIBE_SYM
-        : bucket.snsNotificationArn,
-    skipMetaDataIndexing: bucket.skipMetaDataIndexing ?? false,
-    browsable: bucket.browsable ?? false,
-  }
+  const initialValues = bucketToFormValues(bucket)
+
+  const formRef = React.useRef<HTMLFormElement>(null)
 
   return (
     <RF.Form onSubmit={onSubmit} initialValues={initialValues}>
@@ -1061,340 +1686,199 @@ function Edit({ bucket, close }: EditProps) {
       }) => (
         <>
           <Reindex bucket={bucket.name} open={reindexOpen} close={closeReindex} />
-          <M.DialogTitle>Edit the &quot;{bucket.name}&quot; bucket</M.DialogTitle>
-          <M.DialogContent>
-            <React.Suspense fallback={<BucketFieldsPlaceholder />}>
-              <form onSubmit={handleSubmit}>
-                <BucketFields bucket={bucket} reindex={openReindex} />
-                {submitFailed && (
-                  <Form.FormError
-                    error={error || submitError}
-                    errors={{
-                      unexpected: 'Something went wrong',
-                      notificationConfigurationError: 'Notification configuration error',
-                      bucketNotFound: 'Bucket not found',
-                    }}
-                  />
-                )}
-                <input type="submit" style={{ display: 'none' }} />
-              </form>
-            </React.Suspense>
-          </M.DialogContent>
-          <M.DialogActions>
-            {submitting && (
-              <Delay>
-                {() => (
-                  <M.Box flexGrow={1} display="flex" pl={2}>
-                    <M.CircularProgress size={24} />
-                  </M.Box>
-                )}
-              </Delay>
-            )}
-            <M.Button
-              onClick={() => form.reset()}
-              color="primary"
-              disabled={pristine || submitting}
-            >
-              Reset
-            </M.Button>
-            <M.Button
-              onClick={() => close('cancel')}
-              color="primary"
-              disabled={submitting}
-            >
-              Cancel
-            </M.Button>
-            <M.Button
-              onClick={handleSubmit}
-              color="primary"
-              disabled={pristine || submitting || (submitFailed && hasValidationErrors)}
-            >
-              Save
-            </M.Button>
-          </M.DialogActions>
+          <SubPageHeader
+            back={back}
+            dirty={!pristine}
+            disabled={submitting}
+            submit={handleSubmit}
+          />
+          <React.Suspense fallback={<CardsPlaceholder className={classes.fields} />}>
+            <form className={classes.fields} onSubmit={handleSubmit} ref={formRef}>
+              <PrimaryCard bucket={bucket} className={classes.card} form={form} />
+              <MetadataCard bucket={bucket} className={classes.card} form={form} />
+              <IndexingAndNotificationsCard
+                bucket={bucket}
+                className={classes.card}
+                form={form}
+                reindex={openReindex}
+              />
+              <PreviewCard bucket={bucket} className={classes.card} form={form} />
+              <input type="submit" style={{ display: 'none' }} />
+            </form>
+          </React.Suspense>
+          {!bucket && (
+            <FormActions siblingRef={formRef}>
+              {submitFailed && (
+                <Form.FormError
+                  className={classes.error}
+                  error={error || submitError}
+                  errors={{
+                    unexpected: 'Something went wrong',
+                    notificationConfigurationError: 'Notification configuration error',
+                    bucketNotFound: 'Bucket not found',
+                  }}
+                  margin="none"
+                />
+              )}
+              {submitting && (
+                <Delay>
+                  {() => (
+                    <M.Box flexGrow={1} display="flex" pl={2}>
+                      <M.CircularProgress size={24} />
+                    </M.Box>
+                  )}
+                </Delay>
+              )}
+              <M.Button
+                onClick={() => form.reset()}
+                color="primary"
+                disabled={pristine || submitting}
+              >
+                Reset
+              </M.Button>
+              <M.Button
+                onClick={() => back('cancel')}
+                color="primary"
+                disabled={submitting}
+              >
+                Cancel
+              </M.Button>
+              <M.Button
+                onClick={handleSubmit}
+                color="primary"
+                disabled={pristine || submitting || (submitFailed && hasValidationErrors)}
+                variant="contained"
+              >
+                Save
+              </M.Button>
+            </FormActions>
+          )}
         </>
       )}
     </RF.Form>
   )
 }
 
-interface DeleteProps {
-  bucket: BucketConfig
-  close: (reason?: string) => void
+interface EditRouteParams {
+  bucketName: string
 }
 
-function Delete({ bucket, close }: DeleteProps) {
-  const { push } = Notifications.use()
-  const t = useTracker()
-  const rm = GQL.useMutation(REMOVE_MUTATION)
-  const doDelete = React.useCallback(async () => {
-    close()
-    try {
-      const { bucketRemove: r } = await rm({ name: bucket.name })
-      switch (r.__typename) {
-        case 'BucketRemoveSuccess':
-          t.track('WEB', { type: 'admin', action: 'bucket delete', bucket: bucket.name })
-          return
-        case 'IndexingInProgress':
-          push(`Can't delete bucket "${bucket.name}" while it's being indexed`)
-          return
-        case 'BucketNotFound':
-          push(`Can't delete bucket "${bucket.name}": not found`)
-          return
-        default:
-          assertNever(r)
-      }
-    } catch (e) {
-      push(`Error deleting bucket "${bucket.name}"`)
-      // eslint-disable-next-line no-console
-      console.error('Error deleting bucket')
-      // eslint-disable-next-line no-console
-      console.error(e)
-    }
-  }, [bucket, close, rm, push, t])
+interface EditPageProps {
+  back: () => void
+}
 
-  return (
-    <>
-      <M.DialogTitle>Delete a bucket</M.DialogTitle>
-      <M.DialogContent>
-        You are about to disconnect &quot;{bucket.name}&quot; from Quilt. The search index
-        will be deleted. Bucket contents will remain unchanged.
-      </M.DialogContent>
-      <M.DialogActions>
-        <M.Button onClick={() => close('cancel')} color="primary">
-          Cancel
-        </M.Button>
-        <M.Button onClick={doDelete} color="primary">
-          Delete
-        </M.Button>
-      </M.DialogActions>
-    </>
+function EditPage({ back }: EditPageProps) {
+  const { bucketName } = RRDom.useParams<EditRouteParams>()
+  const { urls } = NamedRoutes.use()
+  const update = GQL.useMutation(UPDATE_MUTATION)
+  const { bucketConfigs: rows } = GQL.useQueryS(BUCKET_CONFIGS_QUERY)
+  const bucket = React.useMemo(
+    () => (bucketName ? rows.find(({ name }) => name === bucketName) : null),
+    [bucketName, rows],
+  )
+  const submit = React.useCallback(
+    async (input: Model.GQLTypes.BucketUpdateInput) => {
+      if (!bucket) return new Error('Submit form without bucket')
+      try {
+        const { bucketUpdate: r } = await update({ name: bucket.name, input })
+        if (r.__typename !== 'BucketUpdateSuccess') {
+          // TS infered shape but not the actual type
+          return r as Exclude<
+            Model.GQLTypes.BucketUpdateResult,
+            Model.GQLTypes.BucketUpdateSuccess
+          >
+        }
+        back()
+      } catch (e) {
+        return e instanceof Error ? e : new Error('Error updating bucket')
+      }
+    },
+    [back, bucket, update],
   )
+  if (!bucket) return <RRDom.Redirect to={urls.adminBuckets()} />
+  return <Edit bucket={bucket} back={back} submit={submit} />
 }
 
-const useCustomBucketIconStyles = M.makeStyles({
-  stub: {
-    opacity: 0.7,
-  },
-})
-
-interface CustomBucketIconProps {
-  src: string
+interface AddPageProps {
+  back: () => void
 }
 
-function CustomBucketIcon({ src }: CustomBucketIconProps) {
-  const classes = useCustomBucketIconStyles()
-
-  return <BucketIcon alt="" classes={classes} src={src} title="Default icon" />
+function AddPage({ back }: AddPageProps) {
+  const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY)
+  const settings = data.config.contentIndexingSettings
+  const add = GQL.useMutation(ADD_MUTATION)
+  const { push } = Notifications.use()
+  const { track } = useTracker()
+  const submit = React.useCallback(
+    async (input: Model.GQLTypes.BucketAddInput) => {
+      try {
+        const { bucketAdd: r } = await add({ input })
+        if (r.__typename !== 'BucketAddSuccess')
+          // TS infered shape but not the actual type
+          return r as Exclude<
+            Model.GQLTypes.BucketAddResult,
+            Model.GQLTypes.BucketAddSuccess
+          >
+        push(`Bucket "${r.bucketConfig.name}" added`)
+        track('WEB', {
+          type: 'admin',
+          action: 'bucket add',
+          bucket: r.bucketConfig.name,
+        })
+        back()
+      } catch (e) {
+        return e instanceof Error ? e : new Error('Error adding bucket')
+      }
+    },
+    [add, back, push, track],
+  )
+  return <Add settings={settings} back={back} submit={submit} />
 }
 
-const columns: Table.Column<BucketConfig>[] = [
-  {
-    id: 'name',
-    label: 'Name (relevance)',
-    getValue: R.prop('name'),
-    getDisplay: (v: string, b: BucketConfig) => (
-      <span>
-        <M.Box fontFamily="monospace.fontFamily" component="span">
-          {v}
-        </M.Box>{' '}
-        <M.Box color="text.secondary" component="span">
-          ({b.relevanceScore})
-        </M.Box>
-      </span>
-    ),
-  },
-  {
-    id: 'icon',
-    label: 'Icon',
-    sortable: false,
-    align: 'center',
-    getValue: R.prop('iconUrl'),
-    getDisplay: (v: string) => <CustomBucketIcon src={v} />,
-  },
-  {
-    id: 'title',
-    label: 'Title',
-    getValue: R.prop('title'),
-    getDisplay: (v: string) => (
-      <M.Box
-        component="span"
-        maxWidth={240}
-        textOverflow="ellipsis"
-        overflow="hidden"
-        whiteSpace="nowrap"
-        display="inline-block"
-      >
-        {v}
-      </M.Box>
-    ),
-  },
-  {
-    id: 'description',
-    label: 'Description',
-    getValue: R.prop('description'),
-    getDisplay: (v: string | undefined) =>
-      v ? (
-        <M.Box
-          component="span"
-          maxWidth={240}
-          textOverflow="ellipsis"
-          overflow="hidden"
-          whiteSpace="nowrap"
-          display="inline-block"
-        >
-          {v}
-        </M.Box>
-      ) : (
-        <M.Box color="text.secondary" component="span">
-          {'<Empty>'}
-        </M.Box>
-      ),
-  },
-  {
-    id: 'lastIndexed',
-    label: 'Last indexed',
-    getValue: R.prop('lastIndexed'),
-    getDisplay: (v: Date | undefined) =>
-      v ? (
-        <span title={v.toLocaleString()}>
-          {dateFns.formatDistanceToNow(v, { addSuffix: true })}
-        </span>
-      ) : (
-        <M.Box color="text.secondary" component="span">
-          {'<N/A>'}
-        </M.Box>
-      ),
-  },
-]
-
-interface CRUDProps {
-  bucketName?: string
+function useIsAddPage() {
+  const location = RRDom.useLocation()
+  const params = parseSearch(location.search)
+  return !!params.add
 }
 
-function CRUD({ bucketName }: CRUDProps) {
-  const { bucketConfigs: rows } = GQL.useQueryS(BUCKET_CONFIGS_QUERY)
-  const filtering = Table.useFiltering({
-    rows,
-    filterBy: ({ name, title }) => name + title,
-  })
-  const ordering = Table.useOrdering({
-    rows: filtering.filtered,
-    column: columns[0],
-  })
-  const pagination = Pagination.use(ordering.ordered, {
-    // @ts-expect-error
-    getItemId: R.prop('name'),
-  })
-  const { open: openDialog, render: renderDialogs } = Dialogs.use()
-
-  const { urls } = NamedRoutes.use()
-  const history = RRDom.useHistory()
-
-  const toolbarActions = [
-    {
-      title: 'Add bucket',
-      icon: <M.Icon>add</M.Icon>,
-      fn: React.useCallback(() => {
-        openDialog(({ close }) => <Add {...{ close }} />)
-      }, [openDialog]),
-    },
-  ]
-
-  const edit = (bucket: BucketConfig) => () => {
-    history.push(urls.adminBuckets(bucket.name))
-  }
-
-  const inlineActions = (bucket: BucketConfig) => [
-    {
-      title: 'Delete',
-      icon: <M.Icon>delete</M.Icon>,
-      fn: () => {
-        openDialog(({ close }) => <Delete {...{ bucket, close }} />)
-      },
-    },
-    {
-      title: 'Edit',
-      icon: <M.Icon>edit</M.Icon>,
-      fn: edit(bucket),
-    },
-  ]
-
-  const editingBucket = React.useMemo(
-    () => (bucketName ? rows.find(({ name }) => name === bucketName) : null),
-    [bucketName, rows],
-  )
-
-  const onBucketClose = React.useCallback(() => {
-    history.push(urls.adminBuckets())
-  }, [history, urls])
+interface BucketsProps {
+  back: () => void
+}
 
-  if (bucketName && !editingBucket) {
-    // Bucket name set in URL, but it was not found in buckets list
-    return <RRDom.Redirect to={urls.adminBuckets()} />
+function Buckets({ back }: BucketsProps) {
+  const isAddPage = useIsAddPage()
+  if (isAddPage) {
+    return (
+      <React.Suspense fallback={<AddPageSkeleton back={back} />}>
+        <AddPage back={back} />
+      </React.Suspense>
+    )
   }
-
   return (
-    <M.Paper>
-      {renderDialogs({ maxWidth: 'xs', fullWidth: true })}
-
-      <M.Dialog open={!!editingBucket} fullWidth maxWidth="xs">
-        {editingBucket && <Edit bucket={editingBucket} close={onBucketClose} />}
-      </M.Dialog>
-
-      <Table.Toolbar heading="Buckets" actions={toolbarActions}>
-        <Table.Filter {...filtering} />
-      </Table.Toolbar>
-      <Table.Wrapper>
-        <M.Table size="small">
-          <Table.Head columns={columns} ordering={ordering} withInlineActions />
-          <M.TableBody>
-            {pagination.paginated.map((i: BucketConfig) => (
-              <M.TableRow
-                hover
-                key={i.name}
-                onClick={edit(i)}
-                style={{ cursor: 'pointer' }}
-              >
-                {columns.map((col) => (
-                  <M.TableCell key={col.id} align={col.align} {...col.props}>
-                    {(col.getDisplay || R.identity)(col.getValue(i), i)}
-                  </M.TableCell>
-                ))}
-                <M.TableCell
-                  align="right"
-                  padding="none"
-                  onClick={(e) => e.stopPropagation()}
-                >
-                  <Table.InlineActions actions={inlineActions(i)} />
-                </M.TableCell>
-              </M.TableRow>
-            ))}
-          </M.TableBody>
-        </M.Table>
-      </Table.Wrapper>
-      <Table.Pagination pagination={pagination} />
-    </M.Paper>
+    <React.Suspense fallback={<ListPageSkeleton />}>
+      <ListPage />
+    </React.Suspense>
   )
 }
 
-export default function Buckets() {
-  const location = RRDom.useLocation()
-  const { bucket } = parseSearch(location.search)
-  const bucketName = Array.isArray(bucket) ? bucket[0] : bucket
+export default function BucketsRouter() {
+  const history = RRDom.useHistory()
+  const { paths, urls } = NamedRoutes.use()
+  const back = React.useCallback(() => history.push(urls.adminBuckets()), [history, urls])
+
   return (
     <M.Box mt={2} mb={2}>
       <MetaTitle>{['Buckets', 'Admin']}</MetaTitle>
-      <React.Suspense
-        fallback={
-          <M.Paper>
-            <Table.Toolbar heading="Buckets" />
-            <Table.Progress />
-          </M.Paper>
-        }
-      >
-        <CRUD bucketName={bucketName} />
-      </React.Suspense>
+      <RRDom.Switch>
+        <RRDom.Route path={paths.adminBucketEdit} exact strict>
+          <React.Suspense fallback={<EditPageSkeleton back={back} />}>
+            <EditPage back={back} />
+          </React.Suspense>
+        </RRDom.Route>
+        <RRDom.Route>
+          <Buckets back={back} />
+        </RRDom.Route>
+      </RRDom.Switch>
     </M.Box>
   )
 }
diff --git a/catalog/app/containers/Admin/Buckets/List.tsx b/catalog/app/containers/Admin/Buckets/List.tsx
new file mode 100644
index 00000000000..96016c676ea
--- /dev/null
+++ b/catalog/app/containers/Admin/Buckets/List.tsx
@@ -0,0 +1,279 @@
+import * as dateFns from 'date-fns'
+import * as R from 'ramda'
+import * as React from 'react'
+import * as RRDom from 'react-router-dom'
+import * as M from '@material-ui/core'
+
+import BucketIcon from 'components/BucketIcon'
+import * as Pagination from 'components/Pagination'
+import * as Notifications from 'containers/Notifications'
+import * as Dialogs from 'utils/Dialogs'
+import * as GQL from 'utils/GraphQL'
+import * as NamedRoutes from 'utils/NamedRoutes'
+import assertNever from 'utils/assertNever'
+import parseSearch from 'utils/parseSearch'
+import { useTracker } from 'utils/tracking'
+
+import * as Table from '../Table'
+
+import { BucketConfigSelectionFragment as BucketConfig } from './gql/BucketConfigSelection.generated'
+import BUCKET_CONFIGS_QUERY from './gql/BucketConfigs.generated'
+import REMOVE_MUTATION from './gql/BucketsRemove.generated'
+
+export function ListSkeleton() {
+  return (
+    <M.Paper>
+      <Table.Toolbar heading="Buckets" />
+      <Table.Progress />
+    </M.Paper>
+  )
+}
+
+interface DeleteProps {
+  bucket: BucketConfig
+  close: (reason?: string) => void
+}
+
+function Delete({ bucket, close }: DeleteProps) {
+  const { push } = Notifications.use()
+  const { track } = useTracker()
+  const rm = GQL.useMutation(REMOVE_MUTATION)
+  const doDelete = React.useCallback(async () => {
+    close()
+    try {
+      const { bucketRemove: r } = await rm({ name: bucket.name })
+      switch (r.__typename) {
+        case 'BucketRemoveSuccess':
+          track('WEB', { type: 'admin', action: 'bucket delete', bucket: bucket.name })
+          return
+        case 'IndexingInProgress':
+          push(`Can't delete bucket "${bucket.name}" while it's being indexed`)
+          return
+        case 'BucketNotFound':
+          push(`Can't delete bucket "${bucket.name}": not found`)
+          return
+        default:
+          assertNever(r)
+      }
+    } catch (e) {
+      push(`Error deleting bucket "${bucket.name}"`)
+      // eslint-disable-next-line no-console
+      console.error('Error deleting bucket')
+      // eslint-disable-next-line no-console
+      console.error(e)
+    }
+  }, [bucket, close, rm, push, track])
+
+  return (
+    <>
+      <M.DialogTitle>Delete a bucket</M.DialogTitle>
+      <M.DialogContent>
+        You are about to disconnect &quot;{bucket.name}&quot; from Quilt. The search index
+        will be deleted. Bucket contents will remain unchanged.
+      </M.DialogContent>
+      <M.DialogActions>
+        <M.Button onClick={() => close('cancel')} color="primary">
+          Cancel
+        </M.Button>
+        <M.Button onClick={doDelete} color="primary">
+          Delete
+        </M.Button>
+      </M.DialogActions>
+    </>
+  )
+}
+
+function useLegacyBucketNameParam() {
+  const location = RRDom.useLocation()
+  const params = parseSearch(location.search)
+  return Array.isArray(params.bucket) ? params.bucket[0] : params.bucket
+}
+
+const useCustomBucketIconStyles = M.makeStyles({
+  stub: {
+    opacity: 0.7,
+  },
+})
+
+interface CustomBucketIconProps {
+  src: string
+}
+
+function CustomBucketIcon({ src }: CustomBucketIconProps) {
+  const classes = useCustomBucketIconStyles()
+
+  return <BucketIcon alt="" classes={classes} src={src} title="Default icon" />
+}
+
+const columns: Table.Column<BucketConfig>[] = [
+  {
+    id: 'name',
+    label: 'Name (relevance)',
+    getValue: R.prop('name'),
+    getDisplay: (v: string, b: BucketConfig) => (
+      <span>
+        <M.Box fontFamily="monospace.fontFamily" component="span">
+          {v}
+        </M.Box>{' '}
+        <M.Box color="text.secondary" component="span">
+          ({b.relevanceScore})
+        </M.Box>
+      </span>
+    ),
+  },
+  {
+    id: 'icon',
+    label: 'Icon',
+    sortable: false,
+    align: 'center',
+    getValue: R.prop('iconUrl'),
+    getDisplay: (v: string) => <CustomBucketIcon src={v} />,
+  },
+  {
+    id: 'title',
+    label: 'Title',
+    getValue: R.prop('title'),
+    getDisplay: (v: string) => (
+      <M.Box
+        component="span"
+        maxWidth={240}
+        textOverflow="ellipsis"
+        overflow="hidden"
+        whiteSpace="nowrap"
+        display="inline-block"
+      >
+        {v}
+      </M.Box>
+    ),
+  },
+  {
+    id: 'description',
+    label: 'Description',
+    getValue: R.prop('description'),
+    getDisplay: (v: string | undefined) =>
+      v ? (
+        <M.Box
+          component="span"
+          maxWidth={240}
+          textOverflow="ellipsis"
+          overflow="hidden"
+          whiteSpace="nowrap"
+          display="inline-block"
+        >
+          {v}
+        </M.Box>
+      ) : (
+        <M.Box color="text.secondary" component="span">
+          {'<Empty>'}
+        </M.Box>
+      ),
+  },
+  {
+    id: 'lastIndexed',
+    label: 'Last indexed',
+    getValue: R.prop('lastIndexed'),
+    getDisplay: (v: Date | undefined) =>
+      v ? (
+        <span title={v.toLocaleString()}>
+          {dateFns.formatDistanceToNow(v, { addSuffix: true })}
+        </span>
+      ) : (
+        <M.Box color="text.secondary" component="span">
+          {'<N/A>'}
+        </M.Box>
+      ),
+  },
+]
+
+export default function List() {
+  const { bucketConfigs: rows } = GQL.useQueryS(BUCKET_CONFIGS_QUERY)
+  const filtering = Table.useFiltering({
+    rows,
+    filterBy: ({ name, title }) => name + title,
+  })
+  const ordering = Table.useOrdering({
+    rows: filtering.filtered,
+    column: columns[0],
+  })
+  const pagination = Pagination.use(ordering.ordered, {
+    // @ts-expect-error
+    getItemId: R.prop('name'),
+  })
+  const { open: openDialog, render: renderDialogs } = Dialogs.use()
+
+  const { urls } = NamedRoutes.use()
+  const history = RRDom.useHistory()
+
+  const toolbarActions = [
+    {
+      title: 'Add bucket',
+      icon: <M.Icon>add</M.Icon>,
+      fn: React.useCallback(() => {
+        history.push(urls.adminBuckets({ add: true }))
+      }, [history, urls]),
+    },
+  ]
+
+  const bucketName = useLegacyBucketNameParam()
+  if (bucketName) {
+    return <RRDom.Redirect to={urls.adminBucketEdit(bucketName)} />
+  }
+
+  const edit = (bucket: BucketConfig) => () => {
+    history.push(urls.adminBucketEdit(bucket.name))
+  }
+
+  const inlineActions = (bucket: BucketConfig) => [
+    {
+      title: 'Delete',
+      icon: <M.Icon>delete</M.Icon>,
+      fn: () => {
+        openDialog(({ close }) => <Delete {...{ bucket, close }} />)
+      },
+    },
+    {
+      title: 'Edit',
+      icon: <M.Icon>edit</M.Icon>,
+      fn: edit(bucket),
+    },
+  ]
+
+  return (
+    <M.Paper>
+      {renderDialogs({ maxWidth: 'xs', fullWidth: true })}
+
+      <Table.Toolbar heading="Buckets" actions={toolbarActions}>
+        <Table.Filter {...filtering} />
+      </Table.Toolbar>
+      <Table.Wrapper>
+        <M.Table size="small">
+          <Table.Head columns={columns} ordering={ordering} withInlineActions />
+          <M.TableBody>
+            {pagination.paginated.map((bucket: BucketConfig) => (
+              <M.TableRow
+                hover
+                key={bucket.name}
+                onClick={edit(bucket)}
+                style={{ cursor: 'pointer' }}
+              >
+                {columns.map((col) => (
+                  <M.TableCell key={col.id} align={col.align} {...col.props}>
+                    {(col.getDisplay || R.identity)(col.getValue(bucket), bucket)}
+                  </M.TableCell>
+                ))}
+                <M.TableCell
+                  align="right"
+                  padding="none"
+                  onClick={(e) => e.stopPropagation()}
+                >
+                  <Table.InlineActions actions={inlineActions(bucket)} />
+                </M.TableCell>
+              </M.TableRow>
+            ))}
+          </M.TableBody>
+        </M.Table>
+      </Table.Wrapper>
+      <Table.Pagination pagination={pagination} />
+    </M.Paper>
+  )
+}
diff --git a/catalog/app/containers/Admin/Form.tsx b/catalog/app/containers/Admin/Form.tsx
index be0edabb2c0..814d3624bcc 100644
--- a/catalog/app/containers/Admin/Form.tsx
+++ b/catalog/app/containers/Admin/Form.tsx
@@ -76,7 +76,8 @@ export function Checkbox({
 
 const useFormErrorStyles = M.makeStyles((t) => ({
   root: {
-    marginTop: t.spacing(3),
+    marginTop: ({ margin }: { margin: 'normal' | 'none' }) =>
+      t.spacing(margin === 'normal' ? 3 : 0),
 
     '& a': {
       textDecoration: 'underline',
@@ -87,10 +88,11 @@ const useFormErrorStyles = M.makeStyles((t) => ({
 interface FormErrorProps extends M.TypographyProps {
   error?: string
   errors: ErrorMessageMap
+  margin?: 'normal' | 'none'
 }
 
-export function FormError({ error, errors, ...rest }: FormErrorProps) {
-  const classes = useFormErrorStyles()
+export function FormError({ error, errors, margin = 'normal', ...rest }: FormErrorProps) {
+  const classes = useFormErrorStyles({ margin })
   if (!error) return null
   return (
     <M.Typography color="error" classes={classes} {...rest}>
diff --git a/catalog/app/containers/Bucket/Overview.js b/catalog/app/containers/Bucket/Overview.js
index 2670460238a..3acef4e2ccb 100644
--- a/catalog/app/containers/Bucket/Overview.js
+++ b/catalog/app/containers/Bucket/Overview.js
@@ -812,7 +812,7 @@ function Head({ s3, overviewUrl, bucket, description }) {
           />
         </M.Box>
         {isAdmin && (
-          <RRLink className={classes.settings} to={urls.adminBuckets(bucket)}>
+          <RRLink className={classes.settings} to={urls.adminBucketEdit(bucket)}>
             <M.IconButton color="inherit">
               <M.Icon>settings</M.Icon>
             </M.IconButton>
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 36727a8029d..c044596038a 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -11,8 +11,6 @@ Entries inside each section should be ordered by type:
 ## Python API
 
 ## CLI
-
-## Catalog, Lambdas
 !-->
 
 # 6.0.0 - 2024-08-19