Reactで、文章の中のURLを自動でリンクにする方法
文章の中の URL をリンクにするには、文章を「ふつうの文字」と「URL」の部分に分けた配列を作り、URL の部分だけを <a> にして描画します。文字列のまま React に渡すので、HTML の文字列を組み立てて dangerouslySetInnerHTML に入れる必要はありません。入力された文章に <img> などのタグが書かれていても、タグとしては働かず、そのまま文字として表示されます。
ここでは、文章を分ける関数 splitLinks と、それを使って表示する AutoLinkText という部品を作りました。対象にするのは http:// と https:// で始まる URL です。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で操作しました。www. で始まる URL やメールアドレスのリンク化、Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げは試していません。

文章を、文字と URL の部分に分ける
分ける処理は、linkify.ts にまとめました。
// 文章を「ふつうの文字」と「URL」の部分に分ける関数
export type Part =
| { type: 'text'; text: string }
| { type: 'link'; text: string; href: string };
// http:// か https:// で始まり、URL に使える ASCII の文字が続く部分
const URL_PATTERN = /https?:\/\/[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+/g;
// 文の終わりの「.」や、URL を囲んだ「)」を URL から外す
function trimEnd(url: string): string {
let end = url.length;
while (end > 0) {
const last = url[end - 1];
if ('.,!?;:\''.includes(last)) {
end--;
} else if (last === ')') {
const head = url.slice(0, end);
const open = head.split('(').length - 1;
const close = head.split(')').length - 1;
if (close <= open) break; // 対になる「(」があれば URL の一部
end--;
} else {
break;
}
}
return url.slice(0, end);
}
export function splitLinks(text: string): Part[] {
const parts: Part[] = [];
let last = 0;
for (const match of text.matchAll(URL_PATTERN)) {
const url = trimEnd(match[0]);
if (!URL.canParse(url)) continue; // 「https://」だけ、などは文字のまま
const start = match.index;
if (start > last) parts.push({ type: 'text', text: text.slice(last, start) });
parts.push({ type: 'link', text: url, href: url });
last = start + url.length;
}
if (last < text.length) parts.push({ type: 'text', text: text.slice(last) });
return parts;
}
matchAll() は、正規表現に合った部分を順に返します。1つ1つの結果には、合った文字列(match[0])と、文章の先頭から数えて、それが始まる位置(0 から始まる match.index)が入っています。前に見つけた URL の終わり(last)から次の URL の始まりまでを文字の部分、URL そのものをリンクの部分として、配列に順に入れていきます。
正規表現の [...] の中には、URL にそのまま書ける ASCII の文字だけを並べました。こうしておくと、日本語の文章で URL のすぐ後に文字が続いても、URL の終わりが分かります。検証環境で試したところ、「https://example.comを見てください」では
https://example.com だけがリンクになりました。全角のかっこで囲んだ「(https://example.com/faq)」も、かっこを含まずにリンクになります。
URL.canParse() は、MDN の説明(英語)にあるとおり、new URL() で作れる文字列なら true、作れずに例外になる文字列なら false を返します。「https:// だけ」のように、正規表現には合っても URL として成り立たないものは、リンクにせず文字のまま残します。
文の終わりの記号と、半角のかっこ
英語の文や、半角で書いた文では、URL のすぐ後に「.」や「)」が付くことがあります。これらは URL に使える文字でもあるので、正規表現だけでは URL の一部として拾われてしまいます。そこで trimEnd で、URL の最後に付いた .
,
!
?
;
:
' を外しています。
) は少し扱いが違います。https://en.wikipedia.org/wiki/Cat_(disambiguation) のように、URL の中に ( と ) が対になって入っていることがあるからです。URL の中の ( の数より ) の数が多いときだけ、最後の ) を外すようにしました。
検証環境で splitLinks に通した結果は次のとおりです(文字の部分は省き、リンクになった部分だけを書いています)。
| 入力した文章 | リンクになった部分 |
|---|---|
URLは https://example.com/path. です |
https://example.com/path |
(see https://en.wikipedia.org/wiki/Cat_(disambiguation)) |
https://en.wikipedia.org/wiki/Cat_(disambiguation) |
https://example.com/?q=a&b=c, http://example.com/! |
https://example.com/?q=a&b=c と http://example.com/ |
javascript:alert(1) と ftp://example.com |
なし |
https://ja.wikipedia.org/wiki/猫 |
https://ja.wikipedia.org/wiki/ |
最後の行のように、パスに日本語がそのまま入った URL は、日本語の手前でリンクが切れます。この記事では、URL のすぐ後に日本語が続いても URL の終わりが分かるように、ASCII の文字だけを URL とみなす書き方にしました。同じ URL でも、https://ja.wikipedia.org/wiki/%E7%8C%AB のように %記法で書かれていれば、最後までリンクになります。
javascript: や ftp:// で始まる文字列は、正規表現が https?:// で始まるものしか探さないので、リンクになりません。
配列のまま描画して、タグを文字として表示する
表示する部品が AutoLinkText.tsx です。
import { splitLinks } from './linkify';
type Props = {
text: string;
};
// 文章の中の URL だけを <a> にして表示する
export function AutoLinkText({ text }: Props) {
return (
<p className="autolink">
{splitLinks(text).map((part, i) =>
part.type === 'link' ? (
<a key={i} href={part.href} target="_blank" rel="noopener noreferrer">
{part.text}
</a>
) : (
part.text
),
)}
</p>
);
}
文字の部分は文字列のまま、URL の部分は <a> 要素にして、1つの配列として <p> の中に置きます。配列の中の文字列は、React がテキストとして DOM に入れます。
検証環境で、入力欄に <b>太字</b><img src=x onerror=alert(1)> https://example.com/ と入れたところ、表示の欄の innerHTML は次のようになりました。
<b>太字</b><img src=x onerror=alert(1)> <a href="https://example.com/" target="_blank" rel="noopener noreferrer">https://example.com/</a>
<b> と <img> は < に置き換えられた文字になり、要素としては1つもできませんでした。リンクになったのは URL の部分だけです。
同じことを、URL を <a href="..."> に置き換えた HTML の文字列を作って dangerouslySetInnerHTML に渡す方法でやると、文章の中のタグもそのまま HTML として扱われます。React の公式リファレンスにある dangerouslySetInnerHTML の説明では、ユーザーが入力したような信頼できない HTML を渡すと XSS 脆弱性の原因になり、信頼できるサニタイズ済みのデータにだけ使うよう書かれています。文字と <a> の配列にする方法なら、HTML を組み立てる部分がありません。
key には配列のインデックス(i)を使いました。この配列は text から毎回作り直すもので、<a> の中に state もありません。文字列の部分には key を付けていませんが、検証環境の開発用のビルドでは、key についての警告はコンソールに出ませんでした。
新しいタブで開くときの rel
リンクには target="_blank" と rel="noopener noreferrer" を付けて、新しいタブで開くようにしました。MDN の rel="noopener" の説明では、noopener を付けると、開いたページの window.opener が null になり、元のページを操作できなくなるとされています。rel="noreferrer" を付けると、リンク先に Referer ヘッダーを送りません。
検証環境で、リンクをクリックして開いたページで window.opener と document.referrer を調べたところ、{"opener":null,"referrer":""} でした。元のページはそのまま残っていました。
なお、MDN の <a> 要素の説明によると、新しい版のブラウザーでは target="_blank" だけでも rel="noopener" と同じ動きになります。この記事では、利用者が書いた、行き先の分からない URL を開くので、noreferrer も付けて明示しました。
改行と長い URL を、枠の中に収める
入力欄の文章をそのまま表示に渡しているので、改行は \n の文字として残ります。表示の側では CSS の white-space: pre-wrap で改行を表示しました。
.autolink {
margin: 0;
padding: 8px;
background: #eefafd;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.autolink a {
color: #067a98;
}
URL には空白がないので、長い URL は1つの長い単語として扱われ、そのままでは途中で折り返されません。overflow-wrap: anywhere を付けると、ほかに折り返せる場所がないときに、単語の途中でも折り返します。
検証環境で幅 400px の画面に表示したところ、overflow-wrap: anywhere があるときの表示の欄の幅は 360px で、中身もその幅に収まりました。normal に変えると、長い URL に合わせて欄が 534px まで広がり、画面の幅を超えました。
入力欄とつないで、打つたびにリンクにする
最初の画像の画面は、次の CommentPreview という部品で表示しています。入力欄の文章を state に持ち、それを AutoLinkText に渡しただけのものです。
import { useState } from 'react';
import { AutoLinkText } from './AutoLinkText';
type Props = {
defaultValue?: string;
};
// 入力欄に書いた文章を、URL をリンクにして下に表示する
export function CommentPreview({ defaultValue = '' }: Props) {
const [text, setText] = useState(defaultValue);
return (
<div className="preview">
<label>
文章
<textarea rows={7} value={text} onChange={(e) => setText(e.target.value)} />
</label>
<p className="preview-title">表示</p>
<AutoLinkText text={text} />
</div>
);
}
リンクにした結果は state に入れず、描画のたびに splitLinks で作ります。入力した文章さえあれば決まるものなので、別の state に入れて、入力と同期させる必要がありません。
空の入力欄に https://example.com/a と1文字ずつ打ったところ、https:// まで打った時点ではリンクにならず、https://exa まで打った時点ではリンクになっていました。そのあとも、確かめた時点(https://exam、https://example.com、https://example.com/)では、どれもそれまでに打った部分がリンクになっていました。最後は https://example.com/a 全体がリンクになりました。打っている途中の文字列も、その時点で URL として成り立てばリンクとして表示されます。





