diff --git a/packages/platform-browser/src/browser/link.ts b/packages/platform-browser/src/browser/link.ts
new file mode 100644
index 000000000000..ed8085d40110
--- /dev/null
+++ b/packages/platform-browser/src/browser/link.ts
@@ -0,0 +1,201 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {DOCUMENT} from '@angular/common';
+import {Inject, Injectable} from '@angular/core';
+
+/**
+ * Represents the attributes of an HTML `` element.
+ *
+ * @see [HTML link element](https://developer.mozilla.org/docs/Web/HTML/Element/link)
+ * @see {@link Link}
+ *
+ * @publicApi
+ */
+export type LinkDefinition = {
+ as?: string;
+ blocking?: string;
+ charset?: string;
+ crossorigin?: string;
+ disabled?: string;
+ fetchpriority?: string;
+ href?: string;
+ hreflang?: string;
+ imagesizes?: string;
+ imagesrcset?: string;
+ integrity?: string;
+ media?: string;
+ nonce?: string;
+ referrerpolicy?: string;
+ rel?: string;
+ rev?: string;
+ sizes?: string;
+ target?: string;
+ title?: string;
+ type?: string;
+} & {
+ [prop: string]: string | undefined;
+};
+
+/**
+ * A service for managing HTML `` tags.
+ *
+ * Properties of the `LinkDefinition` object match the attributes of the
+ * HTML `` tag. These tags are used for canonical URLs, stylesheets,
+ * preload and prefetch hints, Web App Manifest references, and more.
+ *
+ * To identify specific `` tags in a document, use an attribute selection
+ * string in the format `"tag_attribute='value string'"`.
+ * For example, an `attrSelector` value of `"rel='canonical'"` matches a tag
+ * whose `rel` attribute has the value `"canonical"`.
+ * Selectors are used with the `querySelector()` Document method,
+ * in the format `link[{attrSelector}]`.
+ *
+ * @see [HTML link element](https://developer.mozilla.org/docs/Web/HTML/Element/link)
+ * @see [Document.querySelector()](https://developer.mozilla.org/docs/Web/API/Document/querySelector)
+ *
+ * @publicApi
+ */
+@Injectable({providedIn: 'root'})
+export class Link {
+ constructor(@Inject(DOCUMENT) private _doc: any) {}
+
+ /**
+ * Retrieves or creates a specific `` tag element in the current HTML document.
+ */
+ addTag(tag: LinkDefinition, forceCreation: boolean = false): HTMLLinkElement | null {
+ if (!tag) return null;
+
+ return this._getOrCreateElement(tag, forceCreation);
+ }
+
+ /**
+ * Retrieves or creates a set of `` tag elements in the current HTML document.
+ */
+ addTags(tags: LinkDefinition[], forceCreation: boolean = false): HTMLLinkElement[] {
+ if (!tags) return [];
+
+ return tags.reduce((result: HTMLLinkElement[], tag: LinkDefinition) => {
+ if (tag) {
+ result.push(this._getOrCreateElement(tag, forceCreation));
+ }
+
+ return result;
+ }, []);
+ }
+
+ /**
+ * Retrieves a `` tag element in the current HTML document.
+ */
+ getTag(attrSelector: string): HTMLLinkElement | null {
+ if (!attrSelector) return null;
+
+ return this._doc.querySelector(`link[${attrSelector}]`) || null;
+ }
+
+ /**
+ * Retrieves a set of `` tag elements in the current HTML document.
+ */
+ getTags(attrSelector: string): HTMLLinkElement[] {
+ if (!attrSelector) return [];
+
+ const list = this._doc.querySelectorAll(`link[${attrSelector}]`);
+
+ return list ? [].slice.call(list) : [];
+ }
+
+ /**
+ * Modifies an existing `` tag element in the current HTML document.
+ */
+ updateTag(tag: LinkDefinition, selector?: string): HTMLLinkElement | null {
+ if (!tag) return null;
+
+ selector = selector || this._parseSelector(tag);
+
+ const link = this.getTag(selector);
+
+ if (link) {
+ return this._setLinkElementAttributes(tag, link);
+ }
+
+ return this._getOrCreateElement(tag, true);
+ }
+
+ /**
+ * Removes an existing `` tag element from the current HTML document.
+ */
+ removeTag(attrSelector: string): void {
+ this.removeTagElement(this.getTag(attrSelector)!);
+ }
+
+ /**
+ * Removes a specific `` tag element from the document.
+ */
+ removeTagElement(link: HTMLLinkElement): void {
+ if (link && link.parentNode) {
+ link.parentNode.removeChild(link);
+ }
+ }
+
+ private _getOrCreateElement(
+ link: LinkDefinition,
+ forceCreation: boolean = false,
+ ): HTMLLinkElement {
+ if (!forceCreation) {
+ const selector = this._parseSelector(link);
+
+ if (selector) {
+ const existing = this.getTags(selector).filter((elem) =>
+ this._containsAttributes(link, elem),
+ )[0];
+
+ if (existing !== undefined) {
+ return existing;
+ }
+ }
+ }
+
+ const element = this._doc.createElement('link') as HTMLLinkElement;
+
+ this._setLinkElementAttributes(link, element);
+
+ this._doc.head.appendChild(element);
+
+ return element;
+ }
+
+ private _setLinkElementAttributes(tag: LinkDefinition, el: HTMLLinkElement): HTMLLinkElement {
+ Object.keys(tag).forEach((prop: string) => {
+ const value = tag[prop];
+
+ if (value !== undefined) {
+ (el as unknown as Record)[prop] = value;
+ }
+ });
+
+ return el;
+ }
+
+ private _parseSelector(tag: LinkDefinition): string {
+ const selectors: string[] = [];
+
+ if (tag.rel) {
+ selectors.push(`rel="${tag.rel}"`);
+ }
+
+ if (tag.href) {
+ selectors.push(`href="${tag.href}"`);
+ }
+
+ return selectors.join('][');
+ }
+
+ private _containsAttributes(tag: LinkDefinition, elem: HTMLLinkElement): boolean {
+ return Object.keys(tag).every((key: string) => elem.getAttribute(key) === tag[key]);
+ }
+}
diff --git a/packages/platform-browser/src/platform-browser.ts b/packages/platform-browser/src/platform-browser.ts
index 8e3b27de3f51..b50b4e092880 100644
--- a/packages/platform-browser/src/platform-browser.ts
+++ b/packages/platform-browser/src/platform-browser.ts
@@ -14,6 +14,7 @@ export {
platformBrowser,
provideProtractorTestingSupport,
} from './browser';
+export {Link, LinkDefinition} from './browser/link';
export {Meta, MetaDefinition} from './browser/meta';
export {Title} from './browser/title';
export {disableDebugTools, enableDebugTools} from './browser/tools/tools';
diff --git a/packages/platform-browser/test/browser/link_spec.ts b/packages/platform-browser/test/browser/link_spec.ts
new file mode 100644
index 000000000000..cca15eafc128
--- /dev/null
+++ b/packages/platform-browser/test/browser/link_spec.ts
@@ -0,0 +1,244 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {ɵgetDOM as getDOM} from '@angular/common';
+import {Injectable} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {expect} from '@angular/private/testing/matchers';
+
+import {BrowserModule, Link} from '../../index';
+
+describe('Link service', () => {
+ let doc: Document;
+ let linkService: Link;
+ let defaultLink: HTMLLinkElement;
+
+ beforeEach(() => {
+ doc = getDOM().createHtmlDocument();
+
+ linkService = new Link(doc);
+
+ defaultLink = getDOM().createElement('link', doc) as HTMLLinkElement;
+ defaultLink.setAttribute('rel', 'canonical');
+ defaultLink.setAttribute('href', 'https://angular.dev');
+
+ doc.getElementsByTagName('head')[0].appendChild(defaultLink);
+ });
+
+ afterEach(() => {
+ if (defaultLink.parentNode) {
+ defaultLink.parentNode.removeChild(defaultLink);
+ }
+ });
+
+ it('should return link tag matching selector', () => {
+ const actual = linkService.getTag('rel="canonical"')!;
+
+ expect(actual).not.toBeNull();
+ expect(actual.getAttribute('href')).toEqual('https://angular.dev');
+ });
+
+ it('should return all link tags matching selector', () => {
+ const link1 = linkService.addTag({
+ rel: 'preload',
+ href: '/font1.woff2',
+ })!;
+
+ const link2 = linkService.addTag({
+ rel: 'preload',
+ href: '/font2.woff2',
+ })!;
+
+ const actual = linkService.getTags('rel="preload"');
+
+ expect(actual.length).toEqual(2);
+
+ expect(actual[0].getAttribute('href')).toEqual('/font1.woff2');
+ expect(actual[1].getAttribute('href')).toEqual('/font2.woff2');
+
+ linkService.removeTagElement(link1);
+ linkService.removeTagElement(link2);
+ });
+
+ it('should return null if link tag does not exist', () => {
+ const actual = linkService.getTag('rel="fake"');
+
+ expect(actual).toBeNull();
+ });
+
+ it('should remove link tag by the given selector', () => {
+ const selector = 'rel="manifest"';
+
+ expect(linkService.getTag(selector)).toBeNull();
+
+ linkService.addTag({
+ rel: 'manifest',
+ href: '/manifest.webmanifest',
+ });
+
+ expect(linkService.getTag(selector)).not.toBeNull();
+
+ linkService.removeTag(selector);
+
+ expect(linkService.getTag(selector)).toBeNull();
+ });
+
+ it('should remove link tag by the given element', () => {
+ const selector = 'rel="stylesheet"';
+
+ linkService.addTag({
+ rel: 'stylesheet',
+ href: '/styles.css',
+ });
+
+ const link = linkService.getTag(selector)!;
+
+ expect(link).not.toBeNull();
+
+ linkService.removeTagElement(link);
+
+ expect(linkService.getTag(selector)).toBeNull();
+ });
+
+ it('should update link tag matching the given selector', () => {
+ const selector = 'rel="canonical"';
+
+ linkService.updateTag(
+ {
+ rel: 'canonical',
+ href: 'https://next.angular.dev',
+ },
+ selector,
+ );
+
+ const actual = linkService.getTag(selector)!;
+
+ expect(actual).not.toBeNull();
+
+ expect(actual.getAttribute('href')).toEqual('https://next.angular.dev');
+ });
+
+ it('should create link tag if it does not exist', () => {
+ const selector = 'rel="manifest"';
+
+ linkService.updateTag(
+ {
+ rel: 'manifest',
+ href: '/manifest.webmanifest',
+ },
+ selector,
+ );
+
+ const actual = linkService.getTag(selector)!;
+
+ expect(actual).not.toBeNull();
+
+ expect(actual.getAttribute('href')).toEqual('/manifest.webmanifest');
+
+ linkService.removeTagElement(actual);
+ });
+
+ it('should add new link tag', () => {
+ const selector = 'rel="modulepreload"';
+
+ expect(linkService.getTag(selector)).toBeNull();
+
+ linkService.addTag({
+ rel: 'modulepreload',
+ href: '/main.js',
+ });
+
+ const actual = linkService.getTag(selector)!;
+
+ expect(actual).not.toBeNull();
+
+ expect(actual.getAttribute('href')).toEqual('/main.js');
+
+ linkService.removeTagElement(actual);
+ });
+
+ it('should add multiple new link tags', () => {
+ linkService.addTags([
+ {
+ rel: 'preconnect',
+ href: 'https://fonts.googleapis.com',
+ },
+ {
+ rel: 'dns-prefetch',
+ href: 'https://cdn.example.com',
+ },
+ ]);
+
+ expect(linkService.getTag('href="https://fonts.googleapis.com"')).not.toBeNull();
+
+ expect(linkService.getTag('href="https://cdn.example.com"')).not.toBeNull();
+ });
+
+ it('should not add link tag if it is already present on the page and has the same attributes', () => {
+ const selector = 'rel="canonical"';
+
+ expect(linkService.getTags(selector).length).toEqual(1);
+
+ linkService.addTag({
+ rel: 'canonical',
+ href: 'https://angular.dev',
+ });
+
+ expect(linkService.getTags(selector).length).toEqual(1);
+ });
+
+ it('should add link tag if it has different attributes', () => {
+ const selector = 'rel="canonical"';
+
+ expect(linkService.getTags(selector).length).toEqual(1);
+
+ const link = linkService.addTag({
+ rel: 'canonical',
+ href: 'https://next.angular.dev',
+ })!;
+
+ expect(linkService.getTags(selector).length).toEqual(2);
+
+ linkService.removeTagElement(link);
+ });
+
+ it('should add link tag if forceCreation is true', () => {
+ const selector = 'rel="canonical"';
+
+ expect(linkService.getTags(selector).length).toEqual(1);
+
+ const link = linkService.addTag(
+ {
+ rel: 'canonical',
+ href: 'https://angular.dev',
+ },
+ true,
+ )!;
+
+ expect(linkService.getTags(selector).length).toEqual(2);
+
+ linkService.removeTagElement(link);
+ });
+
+ describe('integration test', () => {
+ @Injectable()
+ class DependsOnLink {
+ constructor(public link: Link) {}
+ }
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [BrowserModule],
+ providers: [DependsOnLink],
+ });
+ });
+
+ it('should inject Link service when using BrowserModule', () =>
+ expect(TestBed.inject(DependsOnLink).link).toBeInstanceOf(Link));
+ });
+});