Main query
Our querying method aims to keep the page as modular as possible, that’s why we don’t query each page separately, but we do a general query that detects which modules and heros a page has before retrieving the content itself.
The query
Section titled “The query”We use a function called the_query where we store all our querying logic. This file gets information from separate query files, which are done for each module, hero and single page (those without heros or modules) we need.
export const the_query = async (payload) => { const { type, slug, language, isDraft = false, isMainPage = false, extraTypes = [], langParam } = payload; const typesWithSingles = [ "webinars", "blogs", "news", "webinar_series", "people", "subproducts", "guides", "research", "publications", "customer_stories", "podcasts", "events", "compliance", "cybersecurity_frameworks", "attack_catalogue", "architectural_concepts", "security_concepts", ];
...}In this first snippet, we get our payload, which will indicate the type of the document we will be searching, its slug and the language, and can also optionally indicate if we need to retrieve a draft, if we are in the home page (because this one is retrieved differently to speed up the process) and any extra types we need to comb through when searching the slug we get.
We also determine which types have single pages instead of modular pages to skip the heros and modules check and go straight for the single query.
let typesQuery = "";if (extraTypes) { extraTypes.map((type) => { typesQuery += `|| _type == '${type}'`; });}
var filter = `!(_id in path('drafts.**'))`;if (isDraft) { filter = `(_id in path('drafts.**'))`;}These are our two checks for optional filtering before continuing with the main query.
const firstQuery = ` *[(_type == '${type}' ${typesQuery ?? ""}) && attributes.slug == '${slug}' && ${filter}][0]{ 'heros': heros[]{ _type }, 'modules': modules[]{ _type }, _type } `;
let documentData;if (isDraft) { documentData = await sanityClientPreview.fetch(firstQuery);} else { documentData = await sanityClient.fetch(firstQuery);}
if (!documentData) return null;Here we make a first pre-query to determine which modules and heros we need to retrieve, to shorten the size of the query that we will be making later.
We execute the preQuery either with our draft client or our regular client, and if we do not get any results, we get out of our querying function alltogether and return null to our page.
const baseQuery = `*[(_type == '${type}' ${typesQuery ?? ""}) && attributes.slug == '${slug}' && ${filter}][0]`;We start our query with our base, getting our types and determining our slug. This is the filtering part of the query, that gets also only the first document that matches.
GROQ always returns an array, so we need to do this here to get only one object into our Astro components. In case you need them, here are some GROQ docs on how to query using this language.
Since our query needs to be a full string, we then interpolate to create our final query, which we will call mergedQuery.
We’ll go step by step here on what everything in this query is:
const mergedQuery = ` ${baseQuery}{ "title": title.${lang}, "displayLangs": attributes.languages_display, "id": _id, _type,
"general": { 'product_code': coalesce(attributes.product_code->title.en, attributes.product_code->title), "attributes": { ${settings({ language: lang })} }, ${ type == "demos" || type == "downloads" || type == "demo" ? `"global_forms": *[_type == 'global_demo_download'][0]{ 'demo_title' : demo_title.${lang} , 'demo_subtitle' : demo_subtitle.${lang} , 'demo_form' : demo_form->text, 'download_title' : download_title.${lang} , 'download_subtitle' : download_subtitle.${lang} , 'download_form' : download_form->text, 'bg_image': {${asset({ localized: false, language })}}, },` : "" } ...`;In here we have our base query, which does the filtering, and we start with our projections.
Here is where we indicate to the query exactly which data we want and with which keys.
First, and general for all our queries independently of them being for a modular or a single page, we need their title, the languages they want the page to be displayed in, the id of the document, and the type.
Then we have a general tab where we will get our product code (from our associated product code island in the backend), since that can be available in every document, and we will go into our attributes.
In these we see our first interpolated function, settings(), which corresponds to another file that will return another piece of our query.
This is the key to how we add pieces to our query, since we need a really long query and we do not want to crowd a single file, we use these functions that return pieces of the query to add them to our long interpolated string.
import { asset } from "@utilities/groq/index.js";
export function settings({ language = "en" }) { return `"settings": { "type" : _type, "metaTitle": coalesce(attributes.title.${language}, attributes.title.en), "metaDescription": attributes.subtitle.${language}, "noTitle": title.${language}, "no_follow": attributes.no_follow, "no_index": attributes.boolean, "og_image": attributes{${asset({ name: "og_image", language, localized: false })}}, "date": attributes.date, "updatedAt": _updatedAt, "simplified_header": attributes.simplified_header, "simplified_footer": attributes.simplified_footer, } `;}This is how our settings query piece looks like. It just returns another piece of string to add to our general query.
`${ !isMainPage ? ` "heros": ${heros({ heros: documentData?.heros, language: lang })}, "modules": ${modules({ modules: documentData?.modules, language: lang })}, ${ (!documentData?.heros && !documentData?.modules) || typesWithSingles.includes(documentData?._type) ? `"single": ${singles({ type: documentData?._type, langParam, language: lang })}` : "" } ` : ` "heros": heros[]{ ${main_hero({ language: lang })} }, "modules": modules[]{ ${logo_marquee({ language: lang })}, ${media_text({ language: lang })}, ${media_text_loc_asset({ language: lang })}, ${solution_list({ language: lang })}, ${heading_with_options({ language: lang })}, ${cards_bullets_in_a_row({ language: lang })}, ${buttons_in_a_row({ language: lang })}, ${grid_divider({ language: lang })}, ${checklist({ language: lang })}, ${soft_grid_cta({ language: lang })}, ${text_column_of_cards({ language: lang })}, ${customer_testimonial_slider({ language: lang })}, ${big_gradient_cta({ language: lang })}, ${featured_resources({ language: lang })} } `This is the next piece of our query. Here we directly tell the query which heros and modules it needs to query to build the home page so it is retrieved faster. If it is not our main page, we retrieve our heros and modules from our heros and modules functions, which we will look at in our heros and modules section. If our page does not have heros or modules and belongs to a type that has a single page, we then retrieve our single from there. We’ll look at it in more depth in our singles section.
let data;if (isDraft) { data = await sanityClientPreview.fetch(mergedQuery);} else { data = await sanityClient.fetch(mergedQuery);}The last step is to retrieve our data from either of our clients.
if ( lang !== "en" && ((data?.displayLangs && data.displayLangs[lang] == false) || (!data?.title && !typesWithNoTitle.includes(data._type)))) { const fallbackQuery = ` ${baseQuery}{ ... } `; const fallbackData = await sanityClient.fetch(fallbackQuery); data = { title: data.title, displayLangs: data.displayLangs, id: data.id, general: { attributes: data.general.attributes, heros: fallbackData.general.heros, modules: fallbackData.general.modules, single: fallbackData.general.single ?? {}, }, };}We do have a fallback into English language that activates in two cases:
- If the user deactivates display languages using the set of booleans available in the attributes section of each document
- If the page has no title in that language and does not belong to our
typesWithNoTitlearray.
We have these two options because:
- Even though having no title indicates the user has not translated the page and so no content is available in that language, many times users duplicate pages from ones that are indeed translated, so we need the option for them to disable languages alltogether
- They could also want to disable some languages while translation QA is being run, so titles would be present even though the page is not ready
- We need our types with no title array because there are some post types in the backend that do not need a translated title because they do not ever display it, like downloads or demos
export const layout_query = async ({ lang }) => { const siteSettingsQuery = ` *[][0]{ "siteSettings": { ${seo({ language: lang })}, ${header({ language: lang })}, ${footer({ language: lang })}, ${error({ language: lang })}, } } `;
let siteSettingsData = await sanityClient.fetch(siteSettingsQuery);
return siteSettingsData;};After all of this, we have our “Layout query”. This query, unlike the previous one, is only executed once when the page first loads, but not in page change. Since this query contains our header, footer and SEO scripts information, we can get away with doing it only once because all of that remains in place when we change pages. The only thing changing is inside our <main> tag, which will be controlled by the big query before this one.
This way, we save a little bit of querying time for each of our subsequent page visits.
How information gets into our pages
Section titled “How information gets into our pages”---/** * USED FOR INDEXES * FOR URLS LIKE www.netwrix.com/en/solutions * */import Layout from "@layouts/Layout.astro";import HerosContainer from "@flexible/hero/Heros.astro"import ModulesContainer from "@flexible/modules/Modules.astro"import { the_query } from "@service/query";import { validatePreviewToken } from "@utilities/validatePreviewToken";
const { lang, slug } = Astro.params;const searchParams = Astro.url.searchParams;const isDraft = validatePreviewToken(searchParams.get('preview'));
const content = await the_query({ type:'pages', extraTypes: ['landing_pages'], language: lang, slug: `${slug}`, isDraft });if(!content){ return Astro.redirect(`/404`)}if(slug == 'home'){ return Astro.redirect(`/${lang}`);}---<Layout payload={{contentSeo : content?.general?.attributes?.settings, seo : content?.general?.siteSettings, language: lang, productSolutionName: content?.general?.product_code, pageTitle: content?.title}}> {content?.general?.heros?.[0] && <HerosContainer hero={content.general.heros[0]} language={lang} />} {content?.general?.modules && <ModulesContainer modules={content.general.modules} language={lang}/>}</Layout>This is how one of our page templates looks in Astro. Our content arrives through executing our query and then is passed on to our Layout and our heros’ and modules’ containers. This is further explained in our pages section and our heros and modules section
There are some safety precautions we include in all pages that need them.
Preview
Section titled “Preview”For our previews, we request a preview token to be included in the URL and validate it using a helper function:
const isDraft = validatePreviewToken(searchParams.get("preview"));export function validatePreviewToken(token) { const envToken = import.meta.env.PREVIEW_TOKEN;
if (!envToken) { throw new Error("PREVIEW_TOKEN is not defined in environment variables"); } const isValid = token === envToken;
return isValid;}Demos, downloads and thank you pages
Section titled “Demos, downloads and thank you pages”All of these types of pages should not be accessible just using the link, so we validate by sending a UUID from the form that redirects to them and checking that it matches UUID format before loading the page:
const registeredParam = searchParams.get("valid");var showDemoForm = false;if (!registeredParam || !checkIsUUID(registeredParam)) { showDemoForm = true;}If the valid param fails this UUID regex test or is not present, we show the form again on top of the page, barring access to the content.