As a front-end developer working on GitLab’s Digital Experience(web marketing) team, I recently completed work on a new pricing page implementation. Our team is in the process of migrating from a Nuxt 2 to Nuxt 3 webstack, so this project provided a key opporunity to migrate this page over. My responsibilities include migrating the exiting page, and updating it for our upcoming GitLab 18 annual major release. This project involved taking GitLab’s existing Vue 2 page, and migrating it to using the Composition API. Let me walk through the key aspects of this process!
The goals:
useState
CompostableOne of the key features of our pricing page is the ability to switch between different deployment models (SaaS vs. self-managed).
// Tooltip.vue
<script lang="ts" setup>
// if exists, set application state based on query parameters on page load
// for example, `/pricing/?deployment=saas`
const activeDeployment = useState<string>('active-deployment', () => {
return (route.query.deployment as string) || 'saas';
});
function onChange(index: number, id: string): void {
activeDeployment.value = id;
router.push({
query: {
...route.query,
deployment: id,
},
});
watch(
() => route.query.deployment,
(newDeployment) => {
if (newDeployment) {
activeDeployment.value = newDeployment as string;
}
},
{ immediate: true },
);
}
</script>
<template>
<div class="pricing-toggle">
<div class="pricing-toggle__toggle">
<div
v-for="(tier, index) in props.tooltips"
:key="tier.config.id"
:class="{
'pricing-toggle__button': true,
'pricing-toggle__button--active': activeTypeIndex === index,
}"
@click="onChange(index, tier.config.id)"
>
<SlpTypography variant="body2">{{ tier.label }}</SlpTypography>
</div>
</div>
</div>
</template>
This page has complex business logic that is hard to keep track of when managing a 1500 line YML file. This helps catch errors in incorrect YML formatting, failing CI builds if the improper YML is supplied.
// components/pricing/types.ts
export interface PricingTier {
header: string;
description: string;
price: PricingPrice | string;
buttons?: BaseLink[];
features: PricingList;
config: {
id: string;
promo: string;
};
}
export interface PricingDeployment {
tooltip: PricingTooltip;
tiers: PricingTier[];
email?: {
placeholder: string;
primaryCTA: CTALink;
secondaryCTA: CTALink;
};
}
The mobile experience needed special handling. I created a dedicated layout component that adjusts the display of certain elements:
// layouts/hide-mobile-free-trial.vue
<script setup lang="ts">
provide('HideMobileFreeTrial', true);
</script>
<template>
<div class="grid-wrapper">
<!-- Hides mobile CTA for pages that inherit this layout -->
<NavigationMainNavigation v-if="navigationData" :navigation-data="navigationData?.data" />
<NavigationBanner v-if="bannerData" v-bind="bannerData" class="banner" />
<slot />
<NavigationFooter :footer-data="footerData?.data" />
</div>
</template>
I improved the SEO for the pricing page by adding schema.org markup for product information on the pricing page, while providing defaults for other pages:
let productSchema = {
name: 'GitLab DevSecOps Platform',
description:
'The most comprehensive AI-powered DevSecOps Platform that enables organizations to develop, secure, and operate software in a single application.',
image:
'https://images.ctfassets.net/xz1dnu24egyd/1hnQd13UBU7n5V0RsJcbP3/769692e40a6d528e334b84f079c1f577/gitlab-logo-100.png',
brand: {
'@type': 'Corporation',
name: 'GitLab',
logo: 'https://images.ctfassets.net/xz1dnu24egyd/KpJoqcRLUFBL1AqKt9x0R/4fd439c21cecca4881106dd0069aa39c/gitlab-logo-extra-whitespace.png',
},
};
if (seoConfig?.config?.schema) {
productSchema = {
...productSchema,
...seoConfig?.config?.schema,
};
}
const schemaOrgs: Array<object> = [defineProduct(productSchema)];
useSchemaOrg(schemaOrgs);
Since we expect this page to get updated frequently, and price offerings to shift, allowing state to be controlled by YAML using a key called config.deployments
make it easy to know which components to display based on the current activeDeployment
.
componentName: PricingCard
componentContent:
config:
deployments: ['self-managed'] # will only display this component when activeDeployment exists within this list
The same could be done in Vue templates if necessary:
<section v-if="activeDeployment === 'saas'">
Display SaaS content :fire:
</section>
<SlpButton v-else>
When not on SaaS, this will be shown instead
</SlpButton>
We had a whole team of engineers making updates to various different pages to align with the release. With some changes being confidential, we needed to create a private fork to manage this requirement.
This was exasperated as newly created Vue components needed to be shared between engineers in a forked repository and public repository. While creating duplicate components was not ideal, it allowed us to implement a similar component multiple times, which would minimize merge conflicts and allow us to meet our due dates. Additionally, this informs what functionality should be shared among a single component, or when composition of smaller components made more sense.
At the start, I briefly considered using presentational components where a smart container would write and resolve changes in state, where presentational components read that data passed down. While I didn’t apply this concept originally due to time constraints and fearing overengineering, this could be applied to a future iteration.
The page is now live! If you have any feedback, respond in the issue.