Avoiding Route Overloading: Designing Reusable CRUD Forms for Complex SaaS Entities

How to orchestrate conditional form layouts, state toggles, and dynamic data loading under overloaded creation and edition route templates.

Akram Hafaiedh
Akram Hafaiedh
Full Stack Developer
2026-03-024 min read
// FRONTEND & UI RENDERING
PRODUCTION VERIFIED
TECHNICAL CASE STUDY

Avoiding Route Overloading: Designing Reusable CRUD Forms for Complex SaaS Entities

VUE 3NEXT.JS 15TAILWIND

Reusable CRUD Forms for Complex SaaS Entities

In SaaS development, managing entity pages (like creating, editing, and displaying company details) often leads to route overloading. Teams frequently build separate pages for each operation, duplication of HTML input markup, or overload a single route with confusing client-side state flags.

A clean solution is to create a unified, reusable Form Component that dynamically alters its behavior (View-Only, Create, or Edit) based on the route context. Let's look at how to implement this pattern.


The Challenge: Dynamic Context Detection

We need a single form layout to manage three contexts:

1. Create mode: Inputs are editable, submit button fires a POST request.

2. Edit mode: Inputs are loaded with existing data, submit button fires a PUT request.

3. View mode: Inputs are read-only, buttons are replaced with an "Edit Profile" link.


1. Designing the Reusable Form Component

Define explicit props for the form mode and data inputs to separate presentation markup from routing logic.

vue
<!-- components/CompanyForm.vue (Vue 3 / Composition API) -->
<template>
  <form @submit.prevent="handleSubmit" class="space-y-6">
    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
      <!-- Company Name Input -->
      <div>
        <label class="block text-xs font-bold uppercase tracking-wider mb-2 text-slate-500">Company Name</label>
        <input 
          v-model="form.name" 
          :disabled="isReadOnly" 
          type="text" 
          required
          class="w-full border rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 disabled:bg-slate-50"
        />
      </div>

      <!-- Company Tax ID -->
      <div>
        <label class="block text-xs font-bold uppercase tracking-wider mb-2 text-slate-500">Tax ID / VAT</label>
        <input 
          v-model="form.tax_id" 
          :disabled="isReadOnly" 
          type="text"
          class="w-full border rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 disabled:bg-slate-50"
        />
      </div>
    </div>

    <!-- Action Buttons -->
    <div class="flex justify-end gap-3 mt-6">
      <button 
        v-if="!isReadOnly" 
        type="submit" 
        class="bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-3 rounded-xl text-xs uppercase"
      >
        {{ mode === 'create' ? 'Create Company' : 'Save Changes' }}
      </button>
    </div>
  </form>
</template>

<script setup>
import { ref, computed } from 'vue';

const props = defineProps({
  initialData: {
    type: Object,
    default: () => ({ name: '', tax_id: '' })
  },
  mode: {
    type: String, class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// 'create', 'edit', 'view'
    required: true
  }
});

const emit = defineEmits(['submit']);
const form = ref({ ...props.initialData });

const isReadOnly = computed(() => props.mode === 'view');

const handleSubmit = () => {
  emit('submit', form.value);
};
</script>

2. Orchestrating Route Parameter State

By wrapping the component in a route handler, you load data dynamically and toggle the form mode based on the current URL path pattern (e.g. /companies/new, /companies/:id, /companies/:id/edit).

ts
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// router/index.js (Route Definition Examples)
{
    path: '/companies/new',
    component: CompanyPageWrapper,
    props: { mode: 'create' }
},
{
    path: '/companies/:id',
    component: CompanyPageWrapper,
    props: { mode: 'view' }
},
{
    path: '/companies/:id/edit',
    component: CompanyPageWrapper,
    props: { mode: 'edit' }
}

Inside CompanyPageWrapper.vue, parse the ID parameter, fetch data if in edit or view mode, and render the form:

vue
<!-- pages/CompanyPageWrapper.vue -->
<template>
  <div class="max-w-3xl mx-auto py-12 px-4">
    <div class="flex justify-between items-center mb-8">
      <h1 class="text-3xl font-black">{{ pageTitle }}</h1>
      <router-link 
        v-if="mode === 'view'" 
        :to="class="text-emerald-500 dark:text-emerald-400 font-medium">`/companies/${companyId}/edit`" 
        class="text-xs font-bold uppercase tracking-wider text-blue-600 hover:underline"
      >
        Edit Company
      </router-link>
    </div>

    <CompanyForm 
      v-if="!loading" 
      :initial-data="companyData" 
      :mode="mode" 
      @submit="handleFormSubmit" 
    />
    <div v-else class="text-center py-12">Loading data...</div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import CompanyForm from '../components/CompanyForm.vue';
import axios from 'axios';

const props = defineProps({
  mode: String
});

const route = useRoute();
const loading = ref(props.mode !== 'create');
const companyData = ref({ name: '', tax_id: '' });
const companyId = computed(() => route.params.id);

const pageTitle = computed(() => {
  if (props.mode === 'create') return 'Create Company';
  if (props.mode === 'view') return 'Company Information';
  return 'Edit Company Details';
});

const loadCompany = async () => {
  try {
    const response = await axios.get(class="text-emerald-500 dark:text-emerald-400 font-medium">`/api/companies/${companyId.value}`);
    companyData.value = response.data;
  } catch (error) {
    console.error('Failed to load company details', error);
  } finally {
    loading.value = false;
  }
};

const handleFormSubmit = async (formData) => {
  const url = props.mode === 'create' ? '/api/companies' : class="text-emerald-500 dark:text-emerald-400 font-medium">`/api/companies/${companyId.value}`;
  const method = props.mode === 'create' ? 'post' : 'put';
  
  await axios[method](url, formData);
  alert('Company record saved successfully!');
};

onMounted(() => {
  if (props.mode !== 'create') {
    loadCompany();
  }
});
</script>

By binding form attributes to route parameters and abstracting inputs, you maintain a single reusable form layout while cleanly supporting view-only, edit, and create workflows.

Engineering Collaboration

Building a High-Scale SaaS or Complex System?

Let’s discuss your architecture, full-stack development, or system modernization needs.

Related Articles

// BACKEND & PIPELINE ENGINEERING
PRODUCTION VERIFIED
TECHNICAL CASE STUDY

Eliminating N+1 Queries & Indexing Multi-Tenant Tables: Optimizing Slow Database Queries under Heavy SaaS Load

PHP 8.3QUEUESPARALLEL WORKERS
Backend
2026-03-30•Backend

Eliminating N+1 Queries & Indexing Multi-Tenant Tables: Optimizing Slow Database Queries under Heavy SaaS Load

Read Article
// BACKEND & PIPELINE ENGINEERING
PRODUCTION VERIFIED
TECHNICAL CASE STUDY

Streaming Large Financial Exports: Preventing Memory Overflows in Laravel & Vue 3

PHP 8.3QUEUESPARALLEL WORKERS
Backend
2026-03-25•Backend

Streaming Large Financial Exports: Preventing Memory Overflows in Laravel & Vue 3

Read Article