Repository: garmeeh/next-seo
Pull request #90 (2019-09-02 13:19:06)

Added support for alternate link
I've added support for the rel="alternate" mobile attribute

Read more: https://www.contentkingapp.com/academy/mobile-attribute/#how-to-implement-the-mobile-attribute

pedro-pedrosa: Just wanted to ask if this can be generally refactored for arbitrary `link` tags the same way we have `additionalMetaTags` ?
bolonio: The thing is `additionalMetaTags` works with only two props (`property` and `name`). This is a very specific case for the alternate tag which needs two sets of props (`href` and `media`) or (`href` and `hrefLang`). That's why I couldn't add it via `additionalMetaTags`. Again, a very specific requirements for alternate tag.
pedro-pedrosa: I'm sure this works for your specific use case but doesn't work for generic `link` tags that don't match your use case.

Eg. oembed
```ts
<link 
  rel="alternate" 
  type="application/json+oembed" 
  href={ `https://example.com/services/oembed?url=${ encodeURIComponent(embedUrl) }` } 
  title={ embedTitle }
/>
```
bolonio: Ahh...sure, but I guess that's a matter of a different PR, right? I mean, it's a matter of extending `additionalMetaTags` to receive any prop. (which potentially would fix my issue)
pedro-pedrosa: It's a matter of identifying whether the next-seo package should support this specific use case or a more generic builder for `link` tags (or both).
bolonio: Good point... @garmeeh What do you think?? Should I make the change more generic? Or shall you support this use case and extend the link?
garmeeh: Hey @pedro-pedrosa, I have been thinking about how to handle when there is a need for the likes of what you mentioned. Other use cases I have seen for this tag include linking to the Android app page for example. 

The two use cases covered in this pull request are defiantly the most common IMO and the goal of the library is bring the most common use cases to the front and also try encourage good practices. 

I think extending the the current `additionalMetaTags ` to be able to handle more keys is probably the better way to handle the less used tags. But wouldn't be against having some key like `alternate` that excepts an array like `additionalMetaTags`.   At the moment `additionalMetaTags` is pretty weak as it only supports unique keys. 

I think for now adding these two as they are in the pull request makes sense, even if it only ends up being for the short term. 
garmeeh: Nice one @bolonio! I will merge and publish this later on today. 
garmeeh: @all-contributors please add @bolonio for code, doc, test
allcontributors[bot]: @garmeeh 

I've put up [a pull request](https://github.com/garmeeh/next-seo/pull/92) to add @bolonio! :tada:

Patch:
diff --git a/README.md b/README.md
index 5ec41e9..721a654 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ Version One docs can be found [here](https://github.com/garmeeh/next-seo/tree/su
     - [Twitter](#twitter)
     - [facebook](#facebook)
     - [Canonical URL](#canonical-url)
+    - [Alternate](#alternate)
     - [Additional Meta Tags](#additional-meta-tags)
 - [Open Graph](#open-graph)
   - [Open Graph Examples](#open-graph-examples)
@@ -206,6 +207,10 @@ From now on all of your pages will have the defaults above applied applied.
 | `noindex`                          | boolean (default false) | Sets whether page should be indexed or not [More Info](#no-index)                                                                                                                    |
 | `description`                      | string                  | Set the page meta description                                                                                                                                                        |
 | `canonical`                        | string                  | Set the page canonical url                                                                                                                                                           |
+| `mobileAlternate.media`            | string                  | Set what screen size the mobile website should be served from                                                                                                                        |
+| `mobileAlternate.href`             | string                  | Set the mobile page alternate url                                                                                                                                                    |
+| `languageAlternate.hrefLang`       | string                  | Set the language of the alternate url                                                                                                                                                |
+| `languageAlternate.href`           | string                  | Set the alternate language page url                                                                                                                                                  |
 | `additionalMetaTags`               | array                   | Allows you to add a meta tag that is not documented here. [More Info](#additional-meta-tags)                                                                                         |
 | `twitter.cardType`                 | string                  | The card type, which will be one of `summary`, `summary_large_image`, `app`, or `player`                                                                                             |
 | `twitter.site`                     | string                  | @username for the website used in the card footer                                                                                                                                    |
@@ -234,18 +239,6 @@ From now on all of your pages will have the defaults above applied applied.
 | `openGraph.article.section`        | string                  | A high-level section name. E.g. Technology                                                                                                                                           |
 | `openGraph.article.tags`           | string[]                | Tag words associated with this article.                                                                                                                                              |
 
-### Additional DefaultSEO Options
-
-Same as above along with the following.
-
-| Property                                | Type    | Description                                                                                                                                                                                                                                                                             |
-| --------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `dangerouslySetAllPagesToNoIndex`       | boolean | Please see [here for more info](#dangerouslySetAllPagesToNoIndex).                                                                                                                                                                                                                      |
-| `openGraph.defaultOpenGraphImageHeight` | number  | Set's the default height for all open graph images. You should only set this if all image heights are known. It can be overrode at a page level but you must know the height to do so. Otherwise the default height will be set for a dynamic image that you do not know the height of. |
-| `openGraph.defaultOpenGraphImageWidth`  | number  | Set's the default width for all open graph images. You should only set this if all image widths are known. It can be overrode at a page level but you must know the width to do so. Otherwise the default width will be set for a dynamic image that you do not know the width of.      |
-| `openGraph.defaultOpenGraphVideoHeight` | number  | Set's the default height for all open graph videos. You should only set this if all image heights are known. It can be overrode at a page level but you must know the height to do so. Otherwise the default height will be set for a dynamic image that you do not know the height of. |
-| `openGraph.defaultOpenGraphVideoWidth`  | number  | Set's the default width for all open graph videos. You should only set this if all image widths are known. It can be overrode at a page level but you must know the width to do so. Otherwise the default width will be set for a dynamic image that you do not know the width of.      |
-
 #### Title Template
 
 Replaces `%s` with your title string
@@ -314,6 +307,26 @@ Add this on page per page basis when you want to consolidate duplicate URLs.
 canonical = 'https://www.canonical.ie/';
 ```
 
+#### Alternate
+
+This link relation is used to indicate a relation between a desktop and a mobile website to search engines.
+
+Example:
+
+```js
+mobileAlternate: {
+  media: 'only screen and (max-width: 640px)',
+  href: 'https://m.canonical.ie',
+},
+```
+
+```js
+languageAlternate: {
+  hrefLang: 'de-AT',
+  href: 'https://www.canonical.ie/de',
+},
+```
+
 #### Additional Meta Tags
 
 This allows you to add any other meta tags that are not covered in the `config`.
diff --git a/src/meta/buildTags.tsx b/src/meta/buildTags.tsx
index 44976f4..5214607 100644
--- a/src/meta/buildTags.tsx
+++ b/src/meta/buildTags.tsx
@@ -66,6 +66,28 @@ const buildTags = (config: BuildTagsParams) => {
     );
   }
 
+  if (config.mobileAlternate) {
+    tagsToRender.push(
+      <link
+        rel="alternate"
+        key="mobileAlternate"
+        media={config.mobileAlternate.media}
+        href={config.mobileAlternate.href}
+      />,
+    );
+  }
+
+  if (config.languageAlternate) {
+    tagsToRender.push(
+      <link
+        rel="alternate"
+        key="languageAlternate"
+        hrefLang={config.languageAlternate.hrefLang}
+        href={config.languageAlternate.href}
+      />,
+    );
+  }
+
   if (config.twitter) {
     if (config.twitter.cardType) {
       tagsToRender.push(
diff --git a/src/meta/defaultSEO.tsx b/src/meta/defaultSEO.tsx
index 3273a57..c181cf9 100644
--- a/src/meta/defaultSEO.tsx
+++ b/src/meta/defaultSEO.tsx
@@ -20,6 +20,8 @@ export default class extends Component<DefaultSeoProps, {}> {
       defaultOpenGraphImageHeight,
       defaultOpenGraphVideoWidth,
       defaultOpenGraphVideoHeight,
+      mobileAlternate,
+      languageAlternate,
     } = this.props;
 
     return (
@@ -38,6 +40,8 @@ export default class extends Component<DefaultSeoProps, {}> {
           defaultOpenGraphImageHeight,
           defaultOpenGraphVideoWidth,
           defaultOpenGraphVideoHeight,
+          mobileAlternate,
+          languageAlternate,
         })}
       </Head>
     );
diff --git a/src/types.ts b/src/types.ts
index 1ede79d..644ac0e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -74,6 +74,16 @@ export interface Twitter {
   cardType?: string;
 }
 
+interface MobileAlternate {
+  media: string;
+  href: string;
+}
+
+interface LanguageAlternate {
+  hrefLang: string;
+  href: string;
+}
+
 export interface BaseMetaTag {
   content: string;
 }
@@ -96,6 +106,8 @@ export interface NextSeoProps {
   noindex?: boolean;
   description?: string;
   canonical?: string;
+  mobileAlternate?: MobileAlternate;
+  languageAlternate?: LanguageAlternate;
   openGraph?: OpenGraph;
   facebook?: { appId: string };
   twitter?: Twitter;


Test patch:
diff --git a/e2e/next-seo.config.ts b/e2e/next-seo.config.ts
index 7887311..efd8b4c 100644
--- a/e2e/next-seo.config.ts
+++ b/e2e/next-seo.config.ts
@@ -7,6 +7,14 @@ const APP_DEFAULT_SEO: DefaultSeoProps = {
   canonical: 'https://www.canonical.ie/a',
   defaultOpenGraphImageHeight: 1200,
   defaultOpenGraphImageWidth: 1200,
+  mobileAlternate: {
+    media: 'only screen and (max-width: 640px)',
+    href: 'https://m.canonical.ie',
+  },
+  languageAlternate: {
+    hrefLang: 'de-AT',
+    href: 'https://www.canonical.ie/de',
+  },
   openGraph: {
     type: 'website',
     locale: 'en_IE',
diff --git a/src/meta/__tests__/__snapshots__/buildTags.spec.tsx.snap b/src/meta/__tests__/__snapshots__/buildTags.spec.tsx.snap
index dd9e7a8..d3606e4 100644
--- a/src/meta/__tests__/__snapshots__/buildTags.spec.tsx.snap
+++ b/src/meta/__tests__/__snapshots__/buildTags.spec.tsx.snap
@@ -473,6 +473,16 @@ exports[`renders correctly 1`] = `
     content="This is a test description."
     name="description"
   />
+  <link
+    href="https://m.canonical.ie"
+    media="only screen and (max-width: 640px)"
+    rel="alternate"
+  />
+  <link
+    href="https://www.canonical.ie/de"
+    hreflang="de-AT"
+    rel="alternate"
+  />
   <meta
     content="summary_large_image"
     name="twitter:card"
diff --git a/src/meta/__tests__/buildTags.spec.tsx b/src/meta/__tests__/buildTags.spec.tsx
index a8e856a..5be9f92 100644
--- a/src/meta/__tests__/buildTags.spec.tsx
+++ b/src/meta/__tests__/buildTags.spec.tsx
@@ -12,6 +12,14 @@ const SEO: BuildTagsParams = {
   canonical: 'https://www.canonical.ie',
   defaultOpenGraphImageHeight: 1200,
   defaultOpenGraphImageWidth: 1200,
+  mobileAlternate: {
+    media: 'only screen and (max-width: 640px)',
+    href: 'https://m.canonical.ie',
+  },
+  languageAlternate: {
+    hrefLang: 'de-AT',
+    href: 'https://www.canonical.ie/de',
+  },
   openGraph: {
     type: 'website',
     locale: 'en_IE',
@@ -139,6 +147,34 @@ it('returns full array for default seo object', () => {
   const ogSiteNameTag = tags.filter(item => item.key === 'og:site_name');
   const canonicalTag = tags.filter(item => item.key === 'canonical');
 
+  const mobileAlternateTag = container.querySelectorAll(
+    'link[rel="alternate"][media]',
+  );
+  const mobileAlternateHref = container.querySelectorAll(
+    `link[href="${SEO.mobileAlternate.href}"]`,
+  );
+  const mobileAlternateMedia = container.querySelectorAll(
+    `link[media="${SEO.mobileAlternate.media}"]`,
+  );
+
+  const languageAlternateTag = container.querySelectorAll(
+    'link[rel="alternate"][hrefLang]',
+  );
+  const languageAlternateHref = container.querySelectorAll(
+    `link[href="${SEO.languageAlternate.href}"]`,
+  );
+  const languageAlternateHrefLang = container.querySelectorAll(
+    `link[hrefLang="${SEO.languageAlternate.hrefLang}"]`,
+  );
+
+  expect(Array.from(mobileAlternateTag).length).toBe(1);
+  expect(Array.from(mobileAlternateHref).length).toBe(1);
+  expect(Array.from(mobileAlternateMedia).length).toBe(1);
+
+  expect(Array.from(languageAlternateTag).length).toBe(1);
+  expect(Array.from(languageAlternateHref).length).toBe(1);
+  expect(Array.from(languageAlternateHrefLang).length).toBe(1);
+
   expect(title).toBeDefined();
   expect(Array.from(index).length).toBe(2);
   expect(Array.from(description).length).toBe(1);