---
url: https://formhaus.dev/recipes/steps-in-url.md
description: >-
  Sync Formhaus steps with the URL: push history on step_viewed, open steps on
  Back, Forward and deep links with goToStep, and show a notice on a redirect.
---

# 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](/spec#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](/guide/endpoint#drafts) to restore the step too.
