Internal Linking at Scale on Shopify Without an App
Metaobject-driven related products, shop-the-look internal links, and blog-to-PDP linking. A no-app approach that does not add JavaScript or payment scripts. Includes the Liquid snippets we use.
Most Shopify merchants solve internal linking by installing an app. A related-products app, a shop-the-look app, a bundle app, sometimes a fourth widget to tie the previous three together. Each drops a script tag onto the page, each ships its own CSS, and a surprising number of them inject payment banners or upsell modals you never asked for. The result is a product detail page with six third-party scripts racing for main-thread time and a set of internal links that Googlebot cannot find in the initial HTML because they load after hydration.
You do not need any of that. Shopify's platform has matured to the point where a well-built theme can handle related products, shop-the-look modules, and blog-to-product linking with nothing but Liquid and metaobjects. The links render server-side, they appear in the first byte of HTML, and they cost you zero additional JavaScript, zero monthly fees, and zero vendor lock-in. This is the approach we use across the 250-plus Shopify storefronts we maintain, and it is the single highest-leverage SEO change most catalogues can make.
Why the native recommendations API is not enough
Shopify exposes a recommendations endpoint at /recommendations/products.json and most Dawn-derived themes consume it via a section called product-recommendations that fetches asynchronously after the PDP loads. The fetch uses the Section Rendering API and returns HTML that the theme injects into a placeholder. It works, and for long-tail SKUs it is a reasonable fallback.
The problem is twofold. First, the recommendations algorithm is a black box. It leans on co-purchase signals and collection membership, which means a new product with no order history gets noisy or empty results, and a flagship product gets recommendations that drift month to month as buying patterns shift. You have no control over which products Shopify chooses to link to from your highest-revenue PDP. Second, because the recommendations load via fetch, the anchors are not in the server-rendered HTML. Google will execute JavaScript and will often render the block during indexing, but crawl budget and link equity flow are both best served by links that are present at the first byte. Asynchronous recommendations are a weaker internal link signal than server-rendered ones.
Manual curation beats the algorithm on your top twenty percent of SKUs, which by the usual distribution drive roughly eighty percent of your revenue. For those products you want to choose the companions yourself. For the long tail you can fall back to a Liquid loop over the collection, which is still server-rendered and still better than the API fetch for SEO purposes.
The metaobject-to-products pattern for curated related items
Metaobjects in Shopify support a field type called list.product_reference, which stores a list of products handpicked in the admin. This is the foundation of curated internal linking without an app.
Create a metaobject definition called related_products with two fields. The first is a products field of type list of product references, limited to eight entries. The second is an optional heading field of type single-line text, defaulting to something like "You might also like" but overrideable per entry so you can write "Complete the setup" or "Wear it with" when the context calls for it.
Then add a metafield to the Product resource of type metaobject reference pointing at the related_products definition. Namespace it custom and key it related. Now a merchandiser can open any product in the admin, create or select a related_products metaobject entry, and handpick up to eight complementary SKUs with a bespoke heading.
Render it in sections/product-related.liquid with pure Liquid. The snippet we use looks like this:
{%- liquid
assign related = product.metafields.custom.related.value
assign heading = related.heading | default: 'You might also like'
assign picks = related.products.value
-%}
{%- if picks and picks.size > 0 -%}
<section class="wc-related" aria-label="{{ heading | escape }}">
<h2 class="wc-related__title">{{ heading }}</h2>
<ul class="wc-related__grid">
{%- for item in picks limit: 8 -%}
<li class="wc-related__card">
<a href="{{ item.url }}" class="wc-related__link">
{%- if item.featured_image -%}
<img
src="{{ item.featured_image | image_url: width: 400 }}"
srcset="{{ item.featured_image | image_url: width: 400 }} 1x, {{ item.featured_image | image_url: width: 800 }} 2x"
width="400"
height="{{ 400 | divided_by: item.featured_image.aspect_ratio | round }}"
alt="{{ item.featured_image.alt | default: item.title | escape }}"
loading="lazy"
decoding="async">
{%- endif -%}
<span class="wc-related__name">{{ item.title }}</span>
<span class="wc-related__price">{{ item.price | money }}</span>
</a>
</li>
{%- endfor -%}
</ul>
</section>
{%- endif -%}
Three details make this work. The anchor wraps the whole card so the product title is the visible link text, which is clean anchor-text hygiene. The image has explicit width and height plus eager dimensions so there is no cumulative layout shift. And the entire block only renders when a related entry exists, so there is no empty placeholder on products that have not been curated yet.
Include the section in your product.json template after the main product form. That is all. You now have curated, server-rendered internal links from every flagship PDP to the SKUs you actually want it to pass equity to.
Falling back to a collection loop for the long tail
Manual curation on ten thousand SKUs is not realistic. For products that do not have a curated related entry, we fall through to the product's primary collection and surface siblings. Still pure Liquid, still server-rendered.
{%- liquid
assign related = product.metafields.custom.related.value
-%}
{%- if related == blank -%}
{%- assign home_collection = product.collections | first -%}
{%- if home_collection -%}
<section class="wc-related" aria-label="More from {{ home_collection.title | escape }}">
<h2 class="wc-related__title">More from {{ home_collection.title }}</h2>
<ul class="wc-related__grid">
{%- assign shown = 0 -%}
{%- for item in home_collection.products -%}
{%- if item.id == product.id -%}{%- continue -%}{%- endif -%}
{%- if shown >= 6 -%}{%- break -%}{%- endif -%}
<li class="wc-related__card">
<a href="{{ item.url }}">
<span>{{ item.title }}</span>
<span>{{ item.price | money }}</span>
</a>
</li>
{%- assign shown = shown | plus: 1 -%}
{%- endfor -%}
</ul>
</section>
{%- endif -%}
{%- endif -%}
Two things to note. We limit to six rather than eight on the fallback, so curated blocks visually read as richer. And we exclude the current product with a continue, because Shopify's collection loop includes the product itself.
Shop-the-look as a metaobject collection of product handles
Shop-the-look widgets are usually the second app a fashion or home-goods merchant installs. The typical pattern is a lifestyle image with numbered hotspots that reveal product cards. You can build the same thing with a metaobject and a Liquid section, and in our experience the non-app version converts as well or better because it loads instantly and does not fight with the PDP gallery for main-thread time.
Define a metaobject called look with three fields. A title single-line text field. A hero file reference for the lifestyle image. A products list of product references, limited to six entries. If you want numbered hotspots you can extend with a second metaobject called hotspot that has x and y coordinate fields plus a product reference, then make look.spots a list of metaobject references. For most catalogues the simple grid-beside-image layout outperforms hotspots on conversion, so start simple.
Attach looks to products via a product metafield custom.looks of type list of metaobject references. A single product can appear in several looks, and a single look references several products. Then render on the PDP:
{%- liquid
assign looks = product.metafields.custom.looks.value
-%}
{%- if looks and looks.size > 0 -%}
{%- for look in looks limit: 2 -%}
<section class="wc-look">
<figure class="wc-look__image">
<img
src="{{ look.hero | image_url: width: 900 }}"
width="900"
height="{{ 900 | divided_by: look.hero.image.aspect_ratio | round }}"
alt="{{ look.title | escape }}"
loading="lazy">
</figure>
<div class="wc-look__items">
<h2>Shop the look: {{ look.title }}</h2>
<ul>
{%- for item in look.products.value -%}
<li>
<a href="{{ item.url }}">
<span>{{ item.title }}</span>
<span>{{ item.price | money }}</span>
</a>
</li>
{%- endfor -%}
</ul>
</div>
</section>
{%- endfor -%}
{%- endif -%}
Every product card is a real anchor to /products/{handle}. Googlebot sees the links, buyers see a real shop-the-look module, and your PDP stays under the CLS and INP thresholds that rankings and ad quality scores both reward.
Blog-to-PDP linking with an article metafield
Blog articles are the most wasted internal linking asset in Shopify. A guide on "how to layer wool sweaters" that ranks for an informational query and does not link to the actual sweater PDPs is failing twice, once for SEO and once for revenue. Fix it with an article metafield.
Add a metafield to the Article resource of type list of product references, namespace custom, key featured_products. Editorial staff pick three to five products per post in the article editor. Render in sections/main-article.liquid or a dedicated partial:
{%- liquid
assign featured = article.metafields.custom.featured_products.value
-%}
{%- if featured and featured.size > 0 -%}
<aside class="wc-article-picks" aria-label="Featured in this article">
<h2>Featured in this guide</h2>
<ul>
{%- for item in featured limit: 5 -%}
<li>
<a href="{{ item.url }}" rel="noopener">
{{ item.title }}
<span class="wc-article-picks__price">{{ item.price | money }}</span>
</a>
</li>
{%- endfor -%}
</ul>
</aside>
{%- endif -%}
Place this block twice on long articles. Once near the top, as a sticky-ish sidebar or inline callout after the introduction, and once at the end above the author bio. Two placements roughly double click-through compared to a single end-of-post block. Use the product title as the primary anchor text, not a generic "shop now" phrase, so the anchor signals relevance to both Google and the reader.
For the in-body prose links, encourage writers to mention products by name and hyperlink on first mention. Three to five in-body links per thousand words plus the two sidebar blocks gives an article eight to twelve outbound internal links to product pages, which is the density that actually moves internal PageRank in our measurement.
Anchor-text discipline across the store
Internal linking at scale fails when every link says the same thing. If every PDP's related block says "View product" and every article's sidebar says "Shop now", Google discounts the anchor text as boilerplate and you lose the topical signal. A few rules hold across our 250-plus sites.
Use the product title as the primary anchor whenever the product is the destination. Titles carry keywords naturally because they describe the item. Supplement with benefit-led secondary anchors in prose, like "our softest merino crewneck" linking to the same PDP, so the anchor text varies across the site while still pointing at the right page.
Avoid identical anchor text on every related card. If your theme renders "Shop {{ product.title }}" on every card, the "Shop" prefix is noise that Google strips. Drop it. Let the product title stand alone as the anchor, with price as an adjacent span that is not part of the link text.
In breadcrumbs, use the collection name and product name exactly. Breadcrumb anchors are parsed by Google as hierarchy signals and they benefit from being consistent and descriptive, not abbreviated to "Back" or "Up a level".
Finally, do not link the same two words to two different destinations from the same page. If "wool sweater" links to the PDP in paragraph two, do not link it to the collection in paragraph five. Pick the more relevant destination and use a different anchor for the other link.
Tying it together: the audit that finds the gaps
Once the three patterns are in place, run a quarterly audit. Crawl your own site with a tool like Screaming Frog configured to render Liquid-rendered HTML (the default crawl works because these links are server-side). Export the internal link count per URL. You want to see your top twenty revenue-generating PDPs receiving the highest inbound internal link counts, and your collections receiving the second-highest. If a flagship PDP has fewer inbound links than a random blog post, the metaobject curation is incomplete.
Then export all article URLs and cross-reference against the featured_products metafield. Any article without the metafield populated is a missed linking opportunity. Fixing those backfills is usually a two-week project for one editor and reliably lifts blog-sourced revenue within a single quarter.
The larger point is that Shopify in 2026 gives you every primitive you need to build a linked, server-rendered, crawl-friendly store without installing a single app. Metaobjects with product references, article metafields, and a few well-written Liquid sections replace three or four subscription widgets and give you a faster site with cleaner HTML. That is the internal linking system we ship on every WitsCode Shopify engagement, and it is the reason our clients stop paying for the app subscriptions they thought they needed.
WitsCode internal linking + content engineering ->
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Shopify Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss ecom for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.

