Skip to content
Open Cloud

How do I put the current step in the URL? ​

Write the step id to the URL on step_viewed. On browser Back, Forward and on page load, pass the id from the URL to goToStep(). If the step can't be opened, the form opens the previous available step and reports it: goToStep() returns { reached: false, reason } and the engine reports step_redirected. A visitor can't skip validation or branching through the URL.

React and Next.js App Router ​

The component keeps the step in the URL hash with history.pushState. The App Router supports pushState and replaceState since Next.js 14.1, so no router import is needed. Save as steps-in-url.tsx:

tsx
'use client';

import type { FormDefinition, FormEvent } from '@formhaus/core';
import { FormRenderer, type FormRendererHandle } from '@formhaus/react';
import { useEffect, useRef, useState } from 'react';

const NOT_AVAILABLE = 'That step is not available yet. Finish this one first.';
const stepInUrl = () => decodeURIComponent(window.location.hash.slice(1));
const writeStep = (stepId: string, replace = false) =>
  window.history[replace ? 'replaceState' : 'pushState'](null, '', `#${encodeURIComponent(stepId)}`);

interface Props {
  definition: FormDefinition;
  onSubmit: (values: Record<string, unknown>) => Promise<void>;
}

export function StepsInUrl({ definition, onSubmit }: Props) {
  const form = useRef<FormRendererHandle>(null);
  const syncing = useRef(true);
  const [notice, setNotice] = useState('');

  useEffect(() => {
    const open = () => {
      const requested = stepInUrl() || definition.steps?.[0]?.id;
      if (!form.current || !requested) return;
      syncing.current = true;
      const result = form.current.goToStep(requested);
      syncing.current = false;
      setNotice(result.reached ? '' : NOT_AVAILABLE);
      if (!result.reached && result.stepId) writeStep(result.stepId, true);
    };
    open();
    syncing.current = false;
    window.addEventListener('popstate', open);
    return () => window.removeEventListener('popstate', open);
  }, [definition]);

  const onFormEvent = (event: FormEvent) => {
    if (event.type !== 'step_viewed' || syncing.current || stepInUrl() === event.stepId) return;
    setNotice('');
    writeStep(event.stepId);
  };

  return (
    <>
      {notice && <p role="status">{notice}</p>}
      <FormRenderer ref={form} definition={definition} onSubmit={onSubmit} onFormEvent={onFormEvent} />
    </>
  );
}

Render it from a page:

tsx
import type { FormDefinition } from '@formhaus/core';
import { StepsInUrl } from './steps-in-url';

const definition: FormDefinition = {
  id: 'signup',
  title: 'Sign up',
  submit: { label: 'Create account' },
  steps: [
    { id: 'account', title: 'Account', fields: [{ key: 'email', type: 'email', label: 'Email', validation: { required: true } }] },
    { id: 'profile', title: 'Profile', fields: [{ key: 'name', type: 'text', label: 'Name' }] },
  ],
};

async function save(values: Record<string, unknown>) {
  'use server';
  console.log(values);
}

export default function Page() {
  return <StepsInUrl definition={definition} onSubmit={save} />;
}

Vue Router ​

The component keeps the step in the step query parameter:

vue
<script setup lang="ts">
import type { FormDefinition, FormEvent, FormRendererHandle } from '@formhaus/core';
import { FormRenderer } from '@formhaus/vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

const props = defineProps<{ definition: FormDefinition; send: (values: Record<string, unknown>) => Promise<void> }>();
const route = useRoute();
const router = useRouter();
const form = ref<FormRendererHandle | null>(null);
const notice = ref('');
let syncing = true;

const stepInUrl = () => (typeof route.query.step === 'string' ? route.query.step : '');

function open() {
  const requested = stepInUrl() || props.definition.steps?.[0]?.id;
  if (!form.value || !requested) return;
  syncing = true;
  const result = form.value.goToStep(requested);
  syncing = false;
  notice.value = result.reached ? '' : 'That step is not available yet. Finish this one first.';
  if (!result.reached && result.stepId) void router.replace({ query: { ...route.query, step: result.stepId } });
}

function onFormEvent(event: FormEvent) {
  if (event.type !== 'step_viewed' || syncing || stepInUrl() === event.stepId) return;
  notice.value = '';
  void router.push({ query: { ...route.query, step: event.stepId } });
}

onMounted(() => {
  open();
  syncing = false;
});
watch(() => route.query.step, open);
</script>

<template>
  <p v-if="notice" role="status">{{ notice }}</p>
  <FormRenderer ref="form" :definition="props.definition" :on-submit="props.send" @form-event="onFormEvent" />
</template>

Notes ​

  • goToStep() returns reason: 'blocked' for a step behind an invalid step, 'not-on-path' for a step that a branch leaves out and 'unknown-step' for an id the form does not have. See Opening a step by id.
  • The listeners skip step_viewed while goToStep() runs, so Back and Forward do not add history entries.
  • A reload starts with empty answers, so a deep link to a later step opens the first step. Keep answers with initialValues or the Cloud draft to restore the step too.