- {/* Same Animated Hero Flow as Landing Page */}
diff --git a/dashboard/app/landing.css b/dashboard/app/landing.css
new file mode 100644
index 0000000..db2dec1
--- /dev/null
+++ b/dashboard/app/landing.css
@@ -0,0 +1,1308 @@
+/* Landing page design — scoped so dashboard styles stay untouched */
+html:has(.landing) {
+ scroll-padding-top: calc(env(safe-area-inset-top, 0px) + 76px);
+ scroll-behavior: smooth;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html:has(.landing) {
+ scroll-behavior: auto;
+ }
+}
+
+.landing {
+ --bg: #f6f7f6;
+ --panel: #fff;
+ --ink: #0c0f0d;
+ --mute: #202923;
+ --line: #dfe3e0;
+ --green: #2fd272;
+ --green-ink: #0b7a3a;
+ --on-green: #04140b;
+ --tint: rgba(47, 210, 114, 0.12);
+ --no: #c0364b;
+ --warn: #8a5f00;
+ --sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ --mono: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --t-xs: 13px;
+ --t-sm: 15px;
+ --t-md: 17px;
+ --t-lg: 20px;
+ --t-xl: clamp(28px, 3.6vw, 42px);
+ --t-2xl: clamp(36px, 5vw, 56px);
+ --s1: 8px;
+ --s2: 16px;
+ --s3: 24px;
+ --s4: 32px;
+ --s5: 48px;
+ --s6: 64px;
+ --sec: clamp(64px, 9vw, 104px);
+ --r-s: 8px;
+ --r-m: 12px;
+ --r-l: 18px;
+ color-scheme: light;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: var(--t-md);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ min-height: 100vh;
+ padding-top: env(safe-area-inset-top, 0px);
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+html.dark .landing {
+ --bg: #0a0c0b;
+ --panel: #111412;
+ --ink: #e9eeea;
+ --mute: #8f9b94;
+ --line: #222825;
+ --green-ink: #3ddc84;
+ --tint: rgba(47, 210, 114, 0.1);
+ --no: #ef6b7e;
+ --warn: #e0b341;
+ color-scheme: dark;
+}
+
+.landing *,
+.landing *::before,
+.landing *::after {
+ box-sizing: border-box;
+}
+
+.landing a {
+ color: inherit;
+}
+
+.landing :focus-visible {
+ outline: 2px solid var(--green-ink);
+ outline-offset: 3px;
+ border-radius: 4px;
+}
+
+.landing .wrap {
+ max-width: 1160px;
+ margin: 0 auto;
+ padding: 0 28px;
+}
+
+.landing code,
+.landing pre {
+ font-family: var(--mono);
+}
+
+.landing code.i {
+ font-size: 0.85em;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ padding: 2px 6px;
+ border-radius: 6px;
+}
+
+.landing .band code.i {
+ background: var(--bg);
+}
+
+.landing h1,
+.landing h2,
+.landing h3 {
+ margin: 0;
+ font-weight: 600;
+ text-wrap: balance;
+}
+
+.landing p {
+ text-wrap: pretty;
+}
+
+.landing h1 {
+ font-size: var(--t-2xl);
+ line-height: 1.05;
+ letter-spacing: -0.04em;
+}
+
+.landing h2 {
+ font-size: var(--t-xl);
+ line-height: 1.12;
+ letter-spacing: -0.03em;
+}
+
+.landing h3 {
+ font-size: var(--t-md);
+ line-height: 1.35;
+ letter-spacing: -0.01em;
+}
+
+.landing .sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+.landing .sec {
+ padding: var(--sec) 0;
+}
+
+.landing .band {
+ background: var(--panel);
+ border-block: 1px solid var(--line);
+}
+
+.landing .head {
+ max-width: 36em;
+ margin-bottom: var(--s5);
+}
+
+.landing .head p {
+ margin: var(--s2) 0 0;
+ color: var(--mute);
+}
+
+/* buttons */
+.landing .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 11px 18px;
+ border-radius: var(--r-s);
+ font: 500 var(--t-sm) / 1.2 var(--sans);
+ text-decoration: none;
+ border: 1px solid var(--line);
+ background: var(--panel);
+ color: var(--ink);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.landing .btn:hover {
+ border-color: var(--mute);
+}
+
+.landing .btn.primary {
+ background: var(--green);
+ border-color: var(--green);
+ color: var(--on-green);
+}
+
+.landing .btn.primary:hover {
+ background: #43dc83;
+ border-color: #43dc83;
+}
+
+.landing .btn.sm {
+ padding: 8px 14px;
+}
+
+.landing .band .btn {
+ background: var(--bg);
+}
+
+.landing .actions {
+ display: flex;
+ gap: var(--s1);
+ flex-wrap: wrap;
+}
+
+.landing .icon-btn {
+ display: inline-grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ border-radius: var(--r-s);
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--ink);
+ cursor: pointer;
+}
+
+.landing .icon-btn:hover {
+ border-color: var(--line);
+ background: var(--panel);
+}
+
+.landing .icon-btn svg {
+ width: 18px;
+ height: 18px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.landing .i-sun {
+ display: none;
+}
+
+html.dark .landing .i-sun {
+ display: block;
+}
+
+html.dark .landing .i-moon {
+ display: none;
+}
+
+/* nav */
+.landing .top {
+ position: sticky;
+ top: env(safe-area-inset-top, 0px);
+ z-index: 10;
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--line);
+}
+
+.landing .bar {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ height: 64px;
+}
+
+.landing .logo {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-family: var(--mono);
+ font-weight: 600;
+ font-size: var(--t-lg);
+ text-decoration: none;
+ letter-spacing: -0.02em;
+}
+
+.landing .logo .p {
+ color: var(--green-ink);
+}
+
+.landing .logo .blk {
+ width: 0.55em;
+ height: 1.05em;
+ background: var(--green);
+ display: inline-block;
+ margin-left: 2px;
+}
+
+.landing .links {
+ display: flex;
+ gap: var(--s3);
+ margin-left: auto;
+ font-size: var(--t-sm);
+}
+
+.landing .links a {
+ text-decoration: none;
+ color: var(--mute);
+}
+
+.landing .links a:hover {
+ color: var(--ink);
+}
+
+.landing .links .only-m {
+ display: none;
+}
+
+.landing .nav-right {
+ display: flex;
+ align-items: center;
+ gap: var(--s1);
+}
+
+.landing .login {
+ font-size: var(--t-sm);
+ font-weight: 500;
+ text-decoration: none;
+ padding: 0 var(--s1);
+}
+
+.landing .menu-btn {
+ display: none;
+}
+
+/* hero */
+.landing .hero {
+ padding: clamp(40px, 6vw, 72px) 0 var(--sec);
+}
+
+.landing .hero-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
+ gap: var(--s6);
+ align-items: center;
+}
+
+.landing .lead {
+ font-size: var(--t-lg);
+ line-height: 1.5;
+ color: var(--mute);
+ margin: var(--s3) 0 var(--s4);
+ max-width: 30em;
+}
+
+.landing .cmd {
+ display: flex;
+ align-items: center;
+ gap: var(--s2);
+ background: #0c0c0c;
+ color: #ccc;
+ border-radius: var(--r-m);
+ padding: 10px 10px 10px var(--s3);
+ font-family: var(--mono);
+}
+
+.landing .cmd .tg {
+ flex: none;
+}
+
+.landing .cmd code {
+ flex: 1;
+ min-width: 0;
+ overflow-x: auto;
+ white-space: nowrap;
+ font-size: clamp(14px, 1.4vw, 16px);
+ user-select: all;
+ scrollbar-width: none;
+}
+
+.landing .cmd code::-webkit-scrollbar {
+ display: none;
+}
+
+.landing .copy {
+ flex: none;
+ min-width: 84px;
+ padding: 10px 16px;
+ border: 0;
+ border-radius: var(--r-s);
+ background: var(--green);
+ color: var(--on-green);
+ font: 600 var(--t-sm) / 1 var(--sans);
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+.landing .copy:hover {
+ background: #43dc83;
+}
+
+.landing .hero-meta {
+ margin: var(--s2) 0 var(--s3);
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+/* terminal / snip colors (shared with hero terminal demo) */
+.landing .tg {
+ color: #16c60c;
+}
+.landing .ty {
+ color: #f9f1a5;
+}
+.landing .tr {
+ color: #e74856;
+}
+.landing .tc {
+ color: #61d6d6;
+}
+.landing .td {
+ color: #8c8c8c;
+}
+.landing .tx {
+ color: #f2f2f2;
+}
+.landing .tl {
+ color: #5b8dff;
+ text-decoration: underline;
+}
+
+/* how it works */
+.landing .flow {
+ display: grid;
+ grid-template-columns: 1fr 112px 1fr 112px 1fr;
+ align-items: stretch;
+}
+
+.landing .node {
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--r-m);
+ padding: var(--s3);
+}
+
+.landing .node h3 {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: var(--s1);
+}
+
+.landing .num {
+ display: inline-grid;
+ place-items: center;
+ flex: none;
+ width: 24px;
+ height: 24px;
+ border: 1px solid var(--ink);
+ border-radius: 50%;
+ font: 500 var(--t-xs) / 1 var(--mono);
+}
+
+.landing .node code {
+ display: block;
+ font-size: var(--t-xs);
+ color: var(--green-ink);
+ margin: 0 0 var(--s1);
+ word-break: break-all;
+}
+
+.landing .node p {
+ margin: 0;
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .link {
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: var(--s1);
+}
+
+.landing .link .lab {
+ font: var(--t-xs) / 1 var(--mono);
+ color: var(--mute);
+}
+
+.landing .ln {
+ position: relative;
+ width: 100%;
+ height: 2px;
+ background: color-mix(in srgb, var(--mute) 40%, transparent);
+}
+
+.landing .dot {
+ position: absolute;
+ display: none;
+ top: 50%;
+ left: calc(100% - 10px);
+ width: 10px;
+ height: 10px;
+ margin-top: -5px;
+ border-radius: 50%;
+ background: var(--green);
+}
+
+.landing .flow.in .dot {
+ display: block;
+ animation: landing-flowx 1.6s ease-in-out infinite alternate;
+}
+
+.landing .flow.in .link.b .dot {
+ animation-delay: 1.6s;
+}
+
+@keyframes landing-flowx {
+ from {
+ left: calc(100% - 10px);
+ }
+ to {
+ left: 0;
+ }
+}
+
+@keyframes landing-flowy {
+ from {
+ top: calc(100% - 10px);
+ }
+ to {
+ top: 0;
+ }
+}
+
+/* zero install */
+.landing .zero-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: var(--s6);
+ align-items: start;
+}
+
+.landing .zero-grid .head {
+ margin: 0;
+}
+
+.landing .lists {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s4);
+}
+
+.landing .lists h3 {
+ margin-bottom: var(--s1);
+ font-size: var(--t-sm);
+ color: var(--mute);
+ font-weight: 500;
+}
+
+.landing .lists ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.landing .lists li {
+ display: flex;
+ gap: 12px;
+ padding: 11px 0;
+ border-top: 1px solid var(--line);
+ font-size: var(--t-sm);
+}
+
+.landing .lists li::before {
+ flex: none;
+ width: 16px;
+ font-family: var(--mono);
+ font-weight: 600;
+}
+
+.landing .no li {
+ color: var(--mute);
+}
+
+.landing .no li::before {
+ content: "\00d7";
+ color: var(--no);
+}
+
+.landing .yes li::before {
+ content: "\2713";
+ color: var(--green-ink);
+}
+
+/* use cases */
+.landing .bento {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--s2);
+}
+
+.landing .tile {
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--r-m);
+ padding: var(--s3);
+}
+
+.landing .tile h3 {
+ margin-bottom: 6px;
+}
+
+.landing .tile p {
+ margin: 0;
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .tile.big {
+ grid-column: span 2;
+ grid-row: span 2;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: var(--s4);
+ padding: var(--s4);
+}
+
+.landing .tile.big h3 {
+ font-size: var(--t-lg);
+}
+
+.landing .snip {
+ background: #0c0c0c;
+ color: #ccc;
+ border-radius: var(--r-s);
+ padding: var(--s2) var(--s3);
+ font-size: var(--t-xs);
+ line-height: 1.8;
+ overflow-x: auto;
+}
+
+.landing .snip p {
+ margin: 0;
+ color: inherit;
+ font-size: inherit;
+ white-space: nowrap;
+}
+
+/* capabilities */
+.landing .insp {
+ margin: 0 0 var(--s5);
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--r-m);
+ overflow: hidden;
+}
+
+.landing .insp-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--s2);
+ padding: 10px var(--s2);
+ border-bottom: 1px solid var(--line);
+ font: var(--t-xs) / 1 var(--mono);
+ color: var(--mute);
+}
+
+.landing .dots {
+ display: flex;
+ gap: 6px;
+}
+
+.landing .dots i {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--line);
+}
+
+.landing .insp-body {
+ display: grid;
+ grid-template-columns: minmax(260px, 0.85fr) 1.4fr;
+}
+
+.landing .reqs {
+ list-style: none;
+ margin: 0;
+ padding: var(--s1);
+ border-right: 1px solid var(--line);
+ font: var(--t-xs) / 1.4 var(--mono);
+}
+
+.landing .reqs li {
+ display: grid;
+ grid-template-columns: 5ch minmax(0, 1fr) 3.5ch 6ch;
+ gap: 1ch;
+ align-items: baseline;
+ padding: 8px 10px;
+ border-radius: 6px;
+}
+
+.landing .reqs li.sel {
+ background: var(--tint);
+}
+
+.landing .reqs b {
+ font-weight: 600;
+}
+
+.landing .reqs span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.landing .reqs em {
+ font-style: normal;
+ color: var(--green-ink);
+}
+
+.landing .reqs em.w {
+ color: var(--warn);
+}
+
+.landing .reqs small {
+ color: var(--mute);
+ text-align: right;
+ font-size: inherit;
+}
+
+.landing .detail {
+ padding: var(--s3);
+ min-width: 0;
+}
+
+.landing .dh {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 10px;
+ font: var(--t-sm) / 1.4 var(--mono);
+}
+
+.landing .dh em {
+ font-style: normal;
+ color: var(--green-ink);
+}
+
+.landing .dh small {
+ color: var(--mute);
+ font-size: var(--t-xs);
+}
+
+.landing .tabs {
+ display: flex;
+ gap: var(--s3);
+ margin: var(--s2) 0;
+ border-bottom: 1px solid var(--line);
+ font-size: var(--t-sm);
+ color: var(--mute);
+}
+
+.landing .tabs span {
+ padding-bottom: 8px;
+ margin-bottom: -1px;
+ border-bottom: 2px solid transparent;
+}
+
+.landing .tabs .on {
+ color: var(--ink);
+ border-color: var(--green);
+}
+
+.landing .hdrs {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 6px var(--s2);
+ margin: 0 0 var(--s2);
+ font: var(--t-xs) / 1.5 var(--mono);
+}
+
+.landing .hdrs dt {
+ color: var(--mute);
+}
+
+.landing .hdrs dd {
+ margin: 0;
+ word-break: break-all;
+}
+
+.landing .detail pre {
+ margin: 0;
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--r-s);
+ padding: 12px var(--s2);
+ font-size: var(--t-xs);
+ line-height: 1.6;
+ overflow-x: auto;
+}
+
+.landing .insp-cap {
+ padding: 12px var(--s2);
+ border-top: 1px solid var(--line);
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .group + .group {
+ margin-top: var(--s5);
+}
+
+.landing .glabel {
+ font-size: var(--t-sm);
+ font-weight: 600;
+ color: var(--green-ink);
+ margin: 0 0 var(--s2);
+}
+
+.landing .cols4 {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0 var(--s4);
+}
+
+.landing .item {
+ border-top: 1px solid var(--line);
+ padding: 22px 0 var(--s4);
+}
+
+.landing .item h3 {
+ margin-bottom: 6px;
+}
+
+.landing .item p {
+ margin: 0;
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .soon {
+ margin: var(--s5) 0 0;
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .soon b {
+ color: var(--ink);
+ font-weight: 600;
+}
+
+/* compare */
+.landing .tw {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--r-m);
+ background: var(--bg);
+}
+
+.landing table {
+ border-collapse: collapse;
+ width: 100%;
+ min-width: 760px;
+ font-size: var(--t-sm);
+}
+
+.landing th,
+.landing td {
+ padding: 14px 20px;
+ text-align: left;
+ border-bottom: 1px solid var(--line);
+ vertical-align: top;
+}
+
+.landing tr:last-child td {
+ border-bottom: 0;
+}
+
+.landing thead th {
+ font-size: var(--t-xs);
+ color: var(--mute);
+ font-weight: 500;
+}
+
+.landing thead th.me {
+ color: var(--ink);
+ font-weight: 600;
+}
+
+.landing .me {
+ background: var(--tint);
+}
+
+.landing td.me {
+ font-weight: 500;
+}
+
+.landing td small {
+ display: block;
+ color: var(--mute);
+ font-size: var(--t-xs);
+ margin-top: 2px;
+ font-weight: 400;
+}
+
+.landing .v {
+ display: inline-flex;
+ gap: 8px;
+ align-items: baseline;
+}
+
+.landing .v.y::before {
+ content: "\2713";
+ color: var(--green-ink);
+ font-weight: 600;
+}
+
+.landing .v.n {
+ color: var(--mute);
+}
+
+.landing .v.n::before {
+ content: "\00d7";
+ color: var(--no);
+ font-weight: 600;
+}
+
+.landing .v.t {
+ color: var(--mute);
+}
+
+.landing td.me .v.t {
+ color: var(--ink);
+}
+
+.landing .fn {
+ margin: var(--s2) 0 0;
+ color: var(--mute);
+ font-size: var(--t-xs);
+ max-width: 60em;
+}
+
+/* pricing */
+.landing .plans {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s3);
+}
+
+.landing .plan {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--r-m);
+ padding: var(--s4);
+ display: flex;
+ flex-direction: column;
+}
+
+.landing .plan.hl {
+ border: 2px solid var(--green);
+}
+
+.landing .plan .tag {
+ color: var(--mute);
+ font-size: var(--t-sm);
+ margin: 2px 0 0;
+}
+
+.landing .plan .amt {
+ font-size: var(--t-2xl);
+ font-weight: 600;
+ letter-spacing: -0.05em;
+ line-height: 1;
+ margin: var(--s3) 0 var(--s1);
+}
+
+.landing .plan .amt span {
+ font-size: var(--t-sm);
+ letter-spacing: 0;
+ font-weight: 400;
+ color: var(--mute);
+}
+
+.landing .plan > p {
+ margin: 0;
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .plan ul {
+ list-style: none;
+ margin: 22px 0 var(--s3);
+ padding: 0;
+ flex: 1;
+}
+
+.landing .plan li {
+ padding: 11px 0;
+ border-top: 1px solid var(--line);
+ font-size: var(--t-sm);
+}
+
+.landing .plan .btn {
+ align-self: flex-start;
+}
+
+/* faq */
+.landing .faq-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.8fr;
+ gap: var(--s6);
+ align-items: start;
+}
+
+.landing .faq-grid h2 {
+ position: sticky;
+ top: calc(env(safe-area-inset-top, 0px) + 100px);
+}
+
+.landing details {
+ border-top: 1px solid var(--line);
+ padding: 20px 0;
+}
+
+.landing details:last-child {
+ border-bottom: 1px solid var(--line);
+}
+
+.landing summary {
+ cursor: pointer;
+ font-weight: 500;
+ font-size: var(--t-md);
+ list-style: none;
+ display: flex;
+ justify-content: space-between;
+ gap: var(--s2);
+}
+
+.landing summary::-webkit-details-marker {
+ display: none;
+}
+
+.landing summary::after {
+ content: "+";
+ font: var(--t-lg) / 1.3 var(--mono);
+ color: var(--mute);
+}
+
+.landing details[open] summary::after {
+ content: "\2212";
+}
+
+.landing details p {
+ color: var(--mute);
+ margin: 12px 0 0;
+ max-width: 40em;
+ font-size: var(--t-sm);
+}
+
+.landing details::details-content {
+ block-size: 0;
+ overflow: hidden;
+ transition:
+ block-size 0.25s ease,
+ content-visibility 0.25s allow-discrete;
+}
+
+.landing details[open]::details-content {
+ block-size: auto;
+}
+
+/* closing cta */
+.landing .box {
+ background: #0c0c0c;
+ color: #f2f2f2;
+ border-radius: var(--r-l);
+ padding: var(--s6);
+ display: grid;
+ grid-template-columns: 1.1fr 1fr;
+ gap: var(--s5);
+ align-items: center;
+}
+
+.landing .box h2 {
+ margin-bottom: var(--s1);
+}
+
+.landing .box p {
+ margin: 0;
+ color: #9a9a9a;
+}
+
+.landing .box .cmd {
+ background: #161616;
+ border: 1px solid #2a2a2a;
+ margin-bottom: var(--s2);
+}
+
+.landing .box .btn {
+ background: transparent;
+ border-color: #3a3a3a;
+ color: #f2f2f2;
+}
+
+.landing .box .btn:hover {
+ border-color: #8a8a8a;
+}
+
+/* footer */
+.landing footer {
+ border-top: 1px solid var(--line);
+ padding: var(--s5) 0 var(--s6);
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+.landing .foot {
+ display: grid;
+ grid-template-columns: 1.6fr 1fr 1fr;
+ gap: var(--s5);
+}
+
+.landing footer h4 {
+ margin: 0 0 12px;
+ font-size: var(--t-sm);
+ color: var(--ink);
+ font-weight: 600;
+}
+
+.landing footer ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: var(--s1);
+}
+
+.landing footer a {
+ text-decoration: none;
+}
+
+.landing footer a:hover {
+ color: var(--ink);
+}
+
+.landing footer p {
+ margin: 10px 0 0;
+ max-width: 22em;
+}
+
+.landing .legal {
+ margin-top: var(--s5);
+ padding-top: var(--s3);
+ border-top: 1px solid var(--line);
+ font-size: var(--t-xs);
+}
+
+/* responsive */
+@media (max-width: 1100px) {
+ .landing .cols4 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 960px) {
+ .landing .hero-grid,
+ .landing .zero-grid,
+ .landing .faq-grid,
+ .landing .plans,
+ .landing .box,
+ .landing .foot,
+ .landing .lists {
+ grid-template-columns: 1fr;
+ }
+
+ .landing .hero-grid {
+ gap: var(--s5);
+ }
+
+ .landing .zero-grid,
+ .landing .faq-grid {
+ gap: var(--s4);
+ }
+
+ .landing .faq-grid h2 {
+ position: static;
+ }
+
+ .landing .box {
+ padding: var(--s4) var(--s3);
+ }
+
+ .landing .bento {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .landing .tile.big {
+ grid-column: span 2;
+ grid-row: auto;
+ }
+
+ .landing .menu-btn {
+ display: inline-grid;
+ }
+
+ .landing .links {
+ display: none;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+ margin: 0;
+ flex-direction: column;
+ gap: 0;
+ background: var(--bg);
+ border-bottom: 1px solid var(--line);
+ padding: var(--s1) 28px var(--s2);
+ }
+
+ .landing .top.open .links {
+ display: flex;
+ }
+
+ .landing .links a {
+ padding: 12px 0;
+ font-size: var(--t-md);
+ border-top: 1px solid var(--line);
+ }
+
+ .landing .links a:first-child {
+ border-top: 0;
+ }
+
+ .landing .nav-right {
+ margin-left: auto;
+ }
+
+ .landing .flow {
+ grid-template-columns: 1fr;
+ }
+
+ .landing .link {
+ grid-auto-flow: column;
+ justify-content: center;
+ align-items: center;
+ gap: var(--s2);
+ height: 76px;
+ }
+
+ .landing .ln {
+ width: 2px;
+ height: 48px;
+ }
+
+ .landing .dot {
+ left: 50%;
+ top: calc(100% - 10px);
+ margin: 0 0 0 -5px;
+ }
+
+ .landing .flow.in .dot {
+ animation-name: landing-flowy;
+ }
+
+ .landing .insp-body {
+ grid-template-columns: 1fr;
+ }
+
+ .landing .reqs {
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+}
+
+@media (max-width: 640px) {
+ .landing .wrap {
+ padding: 0 var(--s2);
+ }
+
+ .landing .links {
+ padding-inline: var(--s2);
+ }
+
+ .landing .bento,
+ .landing .cols4 {
+ grid-template-columns: 1fr;
+ }
+
+ .landing .tile.big {
+ grid-column: auto;
+ }
+
+ .landing .login,
+ .landing .nav-right .btn {
+ display: none;
+ }
+
+ .landing .links .only-m {
+ display: block;
+ }
+
+ .landing .cmd {
+ padding-left: var(--s2);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .landing *,
+ .landing *::before,
+ .landing *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx
index c0ad384..18b43b4 100644
--- a/dashboard/app/layout.tsx
+++ b/dashboard/app/layout.tsx
@@ -1,9 +1,17 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Geist, JetBrains_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { Providers } from "@/components/providers";
import { cn } from "@/lib/utils";
+import {
+ SEO_DESCRIPTION,
+ SEO_KEYWORDS,
+ SEO_TITLE,
+ SITE_NAME,
+ SITE_URL,
+ buildJsonLdGraph,
+} from "@/lib/seo";
import "./globals.css";
const geistSans = Geist({
@@ -16,43 +24,42 @@ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
});
+export const viewport: Viewport = {
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#f6f7f6" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0c0b" },
+ ],
+ colorScheme: "light dark",
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover",
+};
+
export const metadata: Metadata = {
- metadataBase: new URL("https://tunl.online"),
+ metadataBase: new URL(SITE_URL),
title: {
- default: "tunl — Free SSH Reverse Tunnel | Expose Localhost Online (tunl.online)",
+ default: SEO_TITLE,
template: "%s | tunl.online",
},
- description:
- "tunl (tunl.online) is a zero-install OpenSSH reverse tunneling platform. Expose localhost web servers, APIs, WebSockets, and webhooks to the public internet using a single SSH command.",
- keywords: [
- "tunl",
- "tunl online",
- "ssh tunl",
- "ssh tunnel",
- "ssh reverse tunnel",
- "expose localhost online",
- "ngrok alternative openssh",
- "free ssh reverse proxy",
- "localhost https tunnel",
- "webhook testing",
- "dev tunnel",
- "custom subdomains",
- "localtunnel alternative",
- "bore alternative",
- "cloudflare tunnel alternative",
- ],
+ description: SEO_DESCRIPTION,
+ keywords: SEO_KEYWORDS,
alternates: {
canonical: "/",
},
authors: [
- { name: "tunl team", url: "https://tunl.online" },
+ { name: "tunl", url: SITE_URL },
{ name: "Pankaj Thakur", url: "https://thakur.dev" },
],
- creator: "tunl",
- publisher: "tunl",
+ creator: "Pankaj Thakur",
+ publisher: SITE_NAME,
category: "Developer Tools",
- applicationName: "tunl",
+ applicationName: SITE_NAME,
referrer: "origin-when-cross-origin",
+ formatDetection: {
+ email: false,
+ address: false,
+ telephone: false,
+ },
robots: {
index: true,
follow: true,
@@ -68,25 +75,24 @@ export const metadata: Metadata = {
openGraph: {
type: "website",
locale: "en_US",
- url: "https://tunl.online",
+ url: SITE_URL,
siteName: "tunl.online",
- title: "tunl — Free SSH Reverse Tunnel | Expose Localhost Online",
- description:
- "Zero-install OpenSSH reverse tunneling. Expose localhost to the internet instantly with one command: ssh -R 80:localhost:3000 -p 2222 tunl.online",
+ title: "tunl — Make localhost public with one SSH command",
+ description: SEO_DESCRIPTION,
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
- alt: "tunl — Free SSH Reverse Tunnel Platform",
+ alt: "tunl — Free OpenSSH reverse tunnel to expose localhost online",
},
],
},
twitter: {
card: "summary_large_image",
- title: "tunl — Free SSH Reverse Tunnel | Expose Localhost Online (tunl.online)",
+ title: "tunl — Make localhost public with one SSH command",
description:
- "Expose localhost web servers, APIs, and webhooks to the public internet via native OpenSSH. No CLI download required.",
+ "Zero-install OpenSSH reverse tunnel. Expose localhost, APIs, WebSockets, and webhooks with one ssh command. Free ngrok alternative.",
creator: "@thakurdotdev",
site: "@thakurdotdev",
images: ["/opengraph-image"],
@@ -95,139 +101,12 @@ export const metadata: Metadata = {
icon: [{ url: "/favicon.ico" }, { url: "/tunl.svg", type: "image/svg+xml" }],
apple: [{ url: "/tunl.svg" }],
},
+ other: {
+ "color-scheme": "light dark",
+ },
};
-const jsonLdGraph = {
- "@context": "https://schema.org",
- "@graph": [
- {
- "@type": "WebSite",
- "@id": "https://tunl.online/#website",
- url: "https://tunl.online",
- name: "tunl",
- alternateName: ["tunl online", "tunl.online", "ssh tunl", "tunl reverse tunnel"],
- description:
- "Zero-install OpenSSH reverse tunneling platform to expose localhost to the public internet.",
- inLanguage: "en-US",
- publisher: {
- "@id": "https://tunl.online/#organization",
- },
- },
- {
- "@type": "Organization",
- "@id": "https://tunl.online/#organization",
- name: "tunl",
- url: "https://tunl.online",
- logo: "https://tunl.online/tunl.svg",
- sameAs: [
- "https://github.com/thakurdotdev",
- "https://x.com/thakurdotdev",
- "https://thakur.dev",
- ],
- },
- {
- "@type": "SoftwareApplication",
- "@id": "https://tunl.online/#application",
- name: "tunl",
- alternateName: "tunl online",
- applicationCategory: "DeveloperApplication",
- operatingSystem: "macOS, Linux, Windows, BSD",
- url: "https://tunl.online",
- description:
- "Zero-install OpenSSH reverse tunneling platform. Expose localhost web servers, APIs, WebSockets, and webhooks to the public internet via standard OpenSSH.",
- offers: {
- "@type": "Offer",
- price: "0",
- priceCurrency: "USD",
- },
- featureList: [
- "Zero-install OpenSSH reverse tunneling",
- "Instant public HTTPS URLs with SSL certificates",
- "WebSocket and Server-Sent Events (SSE) support",
- "Persistent custom subdomains with Ed25519 SSH keys",
- "Real-time HTTP request inspection and telemetry",
- ],
- },
- {
- "@type": "HowTo",
- "@id": "https://tunl.online/#howto",
- name: "How to Expose Localhost to the Internet using SSH with tunl",
- description:
- "A quick 3-step guide to tunneling localhost to a public HTTPS URL using standard OpenSSH on tunl.online.",
- step: [
- {
- "@type": "HowToStep",
- name: "Start your local server",
- text: "Run your web server or API locally on any TCP port (e.g., localhost:3000).",
- },
- {
- "@type": "HowToStep",
- name: "Run the tunl SSH command",
- text: "Open your terminal and execute: ssh -R 80:localhost:3000 -p 2222 tunl.online",
- },
- {
- "@type": "HowToStep",
- name: "Access your public HTTPS URL",
- text: "Receive an instant live HTTPS endpoint (e.g., https://xyz.tunl.online) with automatic SSL certificates.",
- },
- ],
- },
- {
- "@type": "FAQPage",
- "@id": "https://tunl.online/#faq",
- mainEntity: [
- {
- "@type": "Question",
- name: "What is tunl online?",
- acceptedAnswer: {
- "@type": "Answer",
- text: "tunl (tunl.online) is a zero-install reverse tunneling platform powered by standard OpenSSH. It allows developers to expose local development servers, APIs, WebSockets, and webhooks to the public internet over secure HTTPS without installing any proprietary CLI agent or daemon.",
- },
- },
- {
- "@type": "Question",
- name: "How does tunl create an SSH tunnel without installing any CLI?",
- acceptedAnswer: {
- "@type": "Answer",
- text: "tunl uses native OpenSSH remote port forwarding (ssh -R). Since OpenSSH is preinstalled on macOS, Linux, and Windows 10/11, you can simply run 'ssh -R 80:localhost:3000 -p 2222 tunl.online' directly in your command line.",
- },
- },
- {
- "@type": "Question",
- name: "How does tunl compare to ngrok and Cloudflare Tunnel?",
- acceptedAnswer: {
- "@type": "Answer",
- text: "Unlike ngrok or Cloudflare Tunnel (cloudflared), tunl requires zero CLI downloads, zero background daemons, and zero account registration for ephemeral tunnels. It uses standard OpenSSH cryptographic keys instead of proprietary authentication tokens.",
- },
- },
- {
- "@type": "Question",
- name: "Is tunl free to use?",
- acceptedAnswer: {
- "@type": "Answer",
- text: "Yes, tunl is free to use. You can start anonymous ephemeral tunnels immediately with no credit card or account. Registering an SSH key allows you to claim persistent custom subdomains at no cost during developer preview.",
- },
- },
- {
- "@type": "Question",
- name: "Can I reserve a custom persistent subdomain on tunl.online?",
- acceptedAnswer: {
- "@type": "Answer",
- text: "Yes! Log in and register your OpenSSH public key (Ed25519 or RSA). Once registered, your custom subdomain (e.g., my-app.tunl.online) is tied to your key fingerprint and reserved exclusively for you.",
- },
- },
- {
- "@type": "Question",
- name: "Does tunl support WebSockets, Next.js HMR, and webhook callbacks?",
- acceptedAnswer: {
- "@type": "Answer",
- text: "Yes, tunl fully supports HTTP/1.1 protocol upgrades including WebSockets, Server-Sent Events (SSE), and Vite/Next.js Hot Module Replacement, as well as webhook payloads from Stripe, GitHub, Shopify, and Slack.",
- },
- },
- ],
- },
- ],
-};
+const jsonLdGraph = buildJsonLdGraph();
export default function RootLayout({
children,
diff --git a/dashboard/app/manifest.ts b/dashboard/app/manifest.ts
index 7dbc143..3f9d267 100644
--- a/dashboard/app/manifest.ts
+++ b/dashboard/app/manifest.ts
@@ -2,14 +2,15 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
- name: "tunl — Free OpenSSH Reverse Tunneling Platform",
+ name: "tunl — Make localhost public with one SSH command",
short_name: "tunl",
description:
- "Expose localhost web servers, APIs, and webhooks to the public internet via standard OpenSSH. No CLI binary downloads required.",
+ "Zero-install OpenSSH reverse tunnel. Expose localhost web servers, APIs, WebSockets, and webhooks with one ssh command. Free ngrok alternative.",
start_url: "/",
display: "standalone",
- background_color: "#0a0a0a",
- theme_color: "#0a0a0a",
+ background_color: "#0a0c0b",
+ theme_color: "#2fd272",
+ categories: ["developer", "utilities", "productivity"],
icons: [
{
src: "/tunl.svg",
diff --git a/dashboard/app/opengraph-image.tsx b/dashboard/app/opengraph-image.tsx
index f05defb..d18bb99 100644
--- a/dashboard/app/opengraph-image.tsx
+++ b/dashboard/app/opengraph-image.tsx
@@ -1,7 +1,8 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
-export const alt = "tunl — Free SSH Reverse Tunnel Platform (tunl.online)";
+export const alt =
+ "tunl — Make localhost public with one SSH command. Free zero-install OpenSSH reverse tunnel (tunl.online)";
export const size = {
width: 1200,
height: 630,
diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx
index 8db0af7..eaf0948 100644
--- a/dashboard/app/page.tsx
+++ b/dashboard/app/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import { Comparison } from "@/components/landing/comparison";
import { Faq } from "@/components/landing/faq";
import { Features } from "@/components/landing/features";
@@ -6,15 +7,40 @@ import { Hero } from "@/components/landing/hero";
import { HowItWorks } from "@/components/landing/how-it-works";
import { LandingFooter } from "@/components/landing/landing-footer";
import { LandingNav } from "@/components/landing/landing-nav";
+import { LandingShell } from "@/components/landing/landing-shell";
import { Pricing } from "@/components/landing/pricing";
import { UseCases } from "@/components/landing/use-cases";
import { WhySsh } from "@/components/landing/why-ssh";
+import { SEO_DESCRIPTION, SEO_KEYWORDS, SEO_TITLE, SITE_URL } from "@/lib/seo";
+import "./landing.css";
+
+export const metadata: Metadata = {
+ title: {
+ absolute: SEO_TITLE,
+ },
+ description: SEO_DESCRIPTION,
+ keywords: SEO_KEYWORDS,
+ alternates: {
+ canonical: SITE_URL,
+ },
+ openGraph: {
+ title: "tunl — Make localhost public with one SSH command",
+ description: SEO_DESCRIPTION,
+ url: SITE_URL,
+ type: "website",
+ },
+ twitter: {
+ title: "tunl — Make localhost public with one SSH command",
+ description:
+ "Zero-install OpenSSH reverse tunnel. Expose localhost, APIs, WebSockets, and webhooks with one ssh command.",
+ },
+};
export default function LandingPage() {
return (
-
+
-
+
@@ -26,6 +52,6 @@ export default function LandingPage() {
-
+
);
}
diff --git a/dashboard/components/hero-flow.tsx b/dashboard/components/hero-flow.tsx
index 491187f..f532424 100644
--- a/dashboard/components/hero-flow.tsx
+++ b/dashboard/components/hero-flow.tsx
@@ -1,232 +1,8 @@
"use client";
-import { Check, Copy } from "lucide-react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-
-const SSH_COMMAND = "ssh -R 80:localhost:3000 -p 2222 tunl.online";
-
-const FLOW_REQUESTS = [
- { time: "14:07:39", method: "GET", path: "/", status: "200 OK", latency: "291ms" },
- {
- time: "14:07:40",
- method: "POST",
- path: "/api/webhooks/stripe",
- status: "200 OK",
- latency: "34ms",
- },
- { time: "14:07:41", method: "GET", path: "/api/v1/users", status: "200 OK", latency: "12ms" },
-];
+import { HeroTerminal } from "@/components/landing/hero-terminal";
+/** Auth-panel demo — same live tunl terminal output as the landing hero. */
export function HeroFlow() {
- const [copied, setCopied] = useState(false);
- const [flowStage, setFlowStage] = useState(0);
- const [visibleRequests, setVisibleRequests] = useState(0);
-
- const handleCopy = () => {
- navigator.clipboard.writeText(SSH_COMMAND);
- setCopied(true);
- toast.success("Command copied to clipboard");
- setTimeout(() => setCopied(false), 2000);
- };
-
- // Staggered sequential flow animation on mount
- useEffect(() => {
- const timers: NodeJS.Timeout[] = [];
-
- timers.push(setTimeout(() => setFlowStage(1), 50));
- timers.push(setTimeout(() => setFlowStage(2), 250));
- timers.push(setTimeout(() => setFlowStage(3), 500));
- timers.push(setTimeout(() => setFlowStage(4), 750));
- timers.push(setTimeout(() => setFlowStage(5), 1000));
- timers.push(setTimeout(() => setFlowStage(6), 1250));
-
- timers.push(setTimeout(() => setVisibleRequests(1), 1450));
- timers.push(setTimeout(() => setVisibleRequests(2), 1750));
- timers.push(setTimeout(() => setVisibleRequests(3), 2050));
-
- // Failsafe for background tab throttling
- timers.push(
- setTimeout(() => {
- setFlowStage(6);
- setVisibleRequests(3);
- }, 2500),
- );
-
- return () => timers.forEach(clearTimeout);
- }, []);
-
- return (
-
- {/* SSH Command Box */}
-
-
-
- $
- {SSH_COMMAND}
-
-
- {copied ? (
- <>
-
- Copied
- >
- ) : (
- <>
-
- Copy
- >
- )}
-
-
-
-
- {/* Flow Diagram (Animated left-to-right / top-to-bottom) */}
-
-
- {/* Localhost */}
-
= 1 ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
- }`}
- >
-
- Your machine
-
- localhost:3000
-
-
- {/* SSH Arrow (Desktop) */}
-
= 2 ? "translate-x-0 opacity-100" : "-translate-x-1 opacity-0"
- }`}
- >
-
-
- SSH
-
-
-
-
-
-
-
- {/* Mobile SSH Divider */}
-
= 2 ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0"
- }`}
- >
-
-
- SSH
-
-
-
-
- {/* tunl node */}
-
= 3 ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
- }`}
- >
-
- tunl control plane
-
-
- port 2222
-
-
-
- {/* HTTPS Arrow (Desktop) */}
-
= 4 ? "translate-x-0 opacity-100" : "-translate-x-1 opacity-0"
- }`}
- >
-
-
- HTTPS
-
-
-
-
-
-
-
- {/* Mobile HTTPS Divider */}
-
= 4 ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0"
- }`}
- >
-
-
- HTTPS
-
-
-
-
- {/* Public URL */}
-
= 5 ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
- }`}
- >
-
- Public Internet
-
- my-app.tunl.online
-
-
-
- {/* Request Telemetry Stream */}
-
= 6 ? "opacity-100" : "opacity-0"
- }`}
- >
-
- {FLOW_REQUESTS.map((req, i) => (
-
-
-
- {req.time}
-
- {req.method}
- {req.path}
-
-
- {req.status}
- {req.latency}
-
-
- ))}
-
-
-
-
- );
+ return
;
}
diff --git a/dashboard/components/landing/comparison.tsx b/dashboard/components/landing/comparison.tsx
index 06721ef..87cf509 100644
--- a/dashboard/components/landing/comparison.tsx
+++ b/dashboard/components/landing/comparison.tsx
@@ -1,175 +1,124 @@
-import { Check, Minus } from "lucide-react";
+type Cell = { kind: "y" | "n" | "t"; text: string };
-interface ComparisonFeature {
- name: string;
- description: string;
- tunl: boolean | string;
- ngrok: boolean | string;
- cloudflare: boolean | string;
-}
-
-const COMPARISON_FEATURES: ComparisonFeature[] = [
+const ROWS: {
+ feature: string;
+ note?: string;
+ tunl: Cell;
+ ngrok: Cell;
+ cloudflare: Cell;
+}[] = [
+ {
+ feature: "Runs on the OpenSSH you already have",
+ note: "No agent or binary to install",
+ tunl: { kind: "y", text: "Yes" },
+ ngrok: { kind: "y", text: "Via SSH mode*" },
+ cloudflare: { kind: "n", text: "Needs cloudflared" },
+ },
{
- name: "Zero CLI Download",
- description: "Uses standard preinstalled OpenSSH client directly from your terminal",
- tunl: true,
- ngrok: false,
- cloudflare: false,
+ feature: "No account for a first tunnel",
+ note: "Run a tunnel before signing up",
+ tunl: { kind: "y", text: "Yes" },
+ ngrok: { kind: "n", text: "Account required" },
+ cloudflare: { kind: "t", text: "Quick Tunnels only" },
},
{
- name: "No Signup Required",
- description: "Run tunnels immediately without signing up or creating an account",
- tunl: true,
- ngrok: false,
- cloudflare: false,
+ feature: "SSH key authentication",
+ note: "Ed25519 and RSA public keys",
+ tunl: { kind: "y", text: "Yes" },
+ ngrok: { kind: "y", text: "In SSH mode*" },
+ cloudflare: { kind: "n", text: "Login or token" },
},
{
- name: "Public Key Authentication",
- description: "Secure cryptographic authentication using Ed25519 & RSA SSH keys",
- tunl: true,
- ngrok: false,
- cloudflare: false,
+ feature: "Automatic HTTPS and TLS",
+ tunl: { kind: "y", text: "Yes" },
+ ngrok: { kind: "y", text: "Yes" },
+ cloudflare: { kind: "y", text: "Yes" },
},
{
- name: "Automatic HTTPS / TLS",
- description: "Instant SSL certificates and TLS termination on public tunnel URLs",
- tunl: true,
- ngrok: true,
- cloudflare: true,
+ feature: "Persistent subdomain",
+ note: "A fixed name that survives restarts",
+ tunl: { kind: "t", text: "Free in preview, tied to your SSH key" },
+ ngrok: { kind: "t", text: "1 free dev domain with an account" },
+ cloudflare: { kind: "t", text: "Needs a domain on Cloudflare DNS" },
},
{
- name: "Persistent Custom Subdomains",
- description: "Claim reserved static subdomains tied directly to your SSH key",
- tunl: "Free in Preview",
- ngrok: "Paid Tier Only",
- cloudflare: "Requires Domain",
+ feature: "WebSocket and SSE upgrades",
+ tunl: { kind: "y", text: "Yes" },
+ ngrok: { kind: "y", text: "Yes" },
+ cloudflare: { kind: "y", text: "Yes" },
},
{
- name: "Custom Domains",
- description: "Route traffic from your own domain (e.g. api.yourbrand.com) directly to local tunnels",
- tunl: "Free in Preview",
- ngrok: "Paid Tier Only",
- cloudflare: "Complex DNS Setup",
+ feature: "Request inspection in a web UI",
+ note: "Headers, payloads, status and latency",
+ tunl: { kind: "y", text: "Yes" },
+ ngrok: { kind: "y", text: "Yes" },
+ cloudflare: { kind: "t", text: "Audit logs" },
},
{
- name: "WebSocket & SSE Protocol Upgrades",
- description: "Full HTTP/1.1 connection upgrades for Vite, Next.js HMR, and sockets",
- tunl: true,
- ngrok: true,
- cloudflare: true,
+ feature: "Custom domains",
+ tunl: { kind: "t", text: "In development" },
+ ngrok: { kind: "t", text: "Paid plans" },
+ cloudflare: { kind: "y", text: "Via Cloudflare DNS" },
},
{
- name: "Real-time Traffic Telemetry",
- description: "Inspect HTTP headers, payloads, status codes, and latency in web UI",
- tunl: true,
- ngrok: true,
- cloudflare: "Audit Logs",
+ feature: "TCP forwarding",
+ tunl: { kind: "t", text: "In development" },
+ ngrok: { kind: "y", text: "Yes" },
+ cloudflare: { kind: "y", text: "Yes" },
},
];
+function CellValue({ cell }: { cell: Cell }) {
+ return
{cell.text} ;
+}
+
export function Comparison() {
return (
-
-
-
-
- Comparison & Alternatives
-
-
- tunl vs Traditional Tunneling Tools
-
-
- Why developers choose{" "}
- tunl.online over proprietary
- agents like ngrok or cloudflared.
+
+
+
+
tunl next to the tools you may use now.
+
+ A side by side with ngrok and Cloudflare Tunnel, including where tunl is still catching
+ up.
-
- {/* Comparison Table */}
-
-
+
+
-
-
- Feature
-
-
- tunl (tunl.online)
-
-
- ngrok
-
-
- Cloudflare Tunnel
-
+
+ Feature
+ tunl
+ ngrok
+ Cloudflare Tunnel
-
- {COMPARISON_FEATURES.map((feat) => (
-
-
- {feat.name}
-
- {feat.description}
-
+
+ {ROWS.map((row) => (
+
+
+ {row.feature}
+ {row.note ? {row.note} : null}
-
- {typeof feat.tunl === "boolean" ? (
- feat.tunl ? (
-
- Yes
-
- ) : (
-
- )
- ) : (
- {feat.tunl}
- )}
+
+
-
- {typeof feat.ngrok === "boolean" ? (
- feat.ngrok ? (
-
- Yes
-
- ) : (
-
- No
-
- )
- ) : (
- {feat.ngrok}
- )}
+
+
-
- {typeof feat.cloudflare === "boolean" ? (
- feat.cloudflare ? (
-
- Yes
-
- ) : (
-
- No
-
- )
- ) : (
- {feat.cloudflare}
- )}
+
+
))}
+
+ * ngrok's SSH mode needs an ngrok account and an uploaded public key, and has
+ limitations compared with the ngrok agent. Based on each project's public
+ documentation, October 2026.
+
);
diff --git a/dashboard/components/landing/constants.ts b/dashboard/components/landing/constants.ts
new file mode 100644
index 0000000..2020621
--- /dev/null
+++ b/dashboard/components/landing/constants.ts
@@ -0,0 +1 @@
+export const SSH_COMMAND = "ssh -R 80:localhost:3000 -p 2222 tunl.online";
diff --git a/dashboard/components/landing/copy-button.tsx b/dashboard/components/landing/copy-button.tsx
new file mode 100644
index 0000000..f46c293
--- /dev/null
+++ b/dashboard/components/landing/copy-button.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useRef, useState } from "react";
+
+export function CopyButton({ text }: { text: string }) {
+ const [label, setLabel] = useState("Copy");
+ const timerRef = useRef | null>(null);
+
+ const announce = (message: string) => {
+ const live = document.getElementById("landing-live");
+ if (live) live.textContent = message;
+ };
+
+ const done = (ok: boolean) => {
+ setLabel(ok ? "Copied" : "Select and copy");
+ announce(ok ? "Command copied to clipboard" : "Copy failed. Select the command and copy it manually.");
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => setLabel("Copy"), 1800);
+ };
+
+ const fallback = () => {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.setAttribute("readonly", "");
+ ta.style.cssText = "position:fixed;opacity:0";
+ document.body.appendChild(ta);
+ ta.select();
+ let ok = false;
+ try {
+ ok = document.execCommand("copy");
+ } catch {
+ ok = false;
+ }
+ document.body.removeChild(ta);
+ done(ok);
+ };
+
+ const handleCopy = () => {
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(() => done(true), fallback);
+ } else {
+ fallback();
+ }
+ };
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/dashboard/components/landing/faq-data.ts b/dashboard/components/landing/faq-data.ts
new file mode 100644
index 0000000..9b2e5e0
--- /dev/null
+++ b/dashboard/components/landing/faq-data.ts
@@ -0,0 +1,53 @@
+export interface FaqItem {
+ question: string;
+ answer: string;
+}
+
+/** Shared FAQ copy for the landing page UI and FAQPage JSON-LD. */
+export const FAQ_ITEMS: FaqItem[] = [
+ {
+ question: "What is tunl?",
+ answer:
+ "tunl is a zero-install reverse tunneling platform powered by standard OpenSSH. It exposes local development servers, APIs, WebSockets and webhooks to the public internet over HTTPS, with no proprietary CLI binary or background daemon.",
+ },
+ {
+ question: "How do I create a tunnel without installing anything?",
+ answer:
+ "Run `ssh -R 80:localhost:3000 -p 2222 tunl.online` in your terminal, changing 3000 to your local port. You get an HTTPS endpoint straight away, and no signup is needed.",
+ },
+ {
+ question: "What if my network blocks port 2222?",
+ answer:
+ "tunl connects outbound over TCP port 2222. If the command hangs or times out, your network or firewall may be blocking that port. Try another network, or ask your network admin to allow outbound connections to tunl.online on port 2222.",
+ },
+ {
+ question: "How do I keep a tunnel alive?",
+ answer:
+ "A tunnel is a standard SSH session, so it ends when the connection drops. Add `-o ServerAliveInterval=30` so OpenSSH sends keepalives, or wrap the command in a loop or a tool such as autossh to reconnect automatically.",
+ },
+ {
+ question: "How does tunl compare to ngrok and Cloudflare Tunnel?",
+ answer:
+ "tunl needs no account for a basic tunnel and nothing to set up: no agent, no auth token, no key upload. ngrok also has an SSH mode, but it needs an account and a registered public key. Cloudflare's Quick Tunnels need no account but do need cloudflared installed. The comparison table above has the details, including features tunl does not have yet.",
+ },
+ {
+ question: "Is tunl free to use?",
+ answer:
+ "Yes. The Ephemeral tier is free forever, and the Reserved tier is $0 during developer preview.",
+ },
+ {
+ question: "Can I reserve a persistent subdomain?",
+ answer:
+ "Yes. Register an SSH key and claim a fixed name such as `name.tunl.online`, linked to that key.",
+ },
+ {
+ question: "Does it support WebSockets, Next.js HMR and webhooks?",
+ answer:
+ "Yes. tunl supports full HTTP/1.1 upgrades for WebSockets, Server-Sent Events and hot module replacement, and it receives webhook callbacks from services like Stripe, GitHub and Shopify.",
+ },
+ {
+ question: "Is it secure?",
+ answer:
+ "Authentication uses Ed25519 or RSA public keys over an encrypted SSH transport. Forwarding is scoped to the single local port you choose, and you can allowlist IP ranges and revoke keys from the dashboard.",
+ },
+];
diff --git a/dashboard/components/landing/faq.tsx b/dashboard/components/landing/faq.tsx
index 1a54bfa..186c824 100644
--- a/dashboard/components/landing/faq.tsx
+++ b/dashboard/components/landing/faq.tsx
@@ -1,111 +1,31 @@
-"use client";
+import { FAQ_ITEMS } from "@/components/landing/faq-data";
+import type { ReactNode } from "react";
-import { ChevronDown } from "lucide-react";
-import { useState } from "react";
-
-interface FaqItem {
- question: string;
- answer: string;
+function renderAnswer(text: string): ReactNode {
+ const parts = text.split(/(`[^`]+`)/g);
+ return parts.map((part, i) =>
+ part.startsWith("`") && part.endsWith("`") ? (
+
+ {part.slice(1, -1)}
+
+ ) : (
+ {part}
+ ),
+ );
}
-const FAQ_ITEMS: FaqItem[] = [
- {
- question: "What is tunl online?",
- answer:
- "tunl (tunl.online) is a zero-install reverse tunneling platform powered by standard OpenSSH. It allows developers to securely expose local development servers, APIs, WebSockets, and webhooks to the public internet over HTTPS without installing any proprietary CLI binary or background daemon.",
- },
- {
- question: "How do I create an SSH tunnel with tunl without installing software?",
- answer:
- "tunl relies entirely on native OpenSSH remote port forwarding (`ssh -R`). Since OpenSSH is preinstalled on macOS, Linux, and Windows 10/11, you only need to run a single command in your terminal: `ssh -R 80:localhost:3000 -p 2222 tunl.online`. Your local server is immediately accessible via a secure HTTPS URL.",
- },
- {
- question: "How does tunl compare to ngrok and Cloudflare Tunnel?",
- answer:
- "Unlike ngrok or Cloudflare Tunnel (cloudflared), tunl requires no custom binary downloads, no background agents, and no account signup for ephemeral tunnels. It uses cryptographic OpenSSH public keys (Ed25519/RSA) instead of proprietary authentication tokens or configuration files.",
- },
- {
- question: "Is tunl free to use?",
- answer:
- "Yes, tunl is 100% free for basic tunneling. You can start anonymous ephemeral tunnels instantly with no credit card or account. Registering an SSH key allows you to claim persistent custom subdomains at no cost during developer preview.",
- },
- {
- question: "Can I reserve a persistent custom subdomain on tunl.online?",
- answer:
- "Yes! Simply sign in and add your OpenSSH public key. Once registered, you can reserve a dedicated subdomain (e.g. `yourname.tunl.online`) that is permanently linked to your key fingerprint.",
- },
- {
- question: "Can I bring my own custom domain (e.g. api.mydomain.com)?",
- answer:
- "Yes! tunl supports connecting your own custom domains directly to active tunnels. Add a CNAME pointing to cname.tunl.online along with a TXT verification record, and your domain routes instantly with free automated SSL/TLS.",
- },
- {
- question: "Does tunl support WebSockets, Next.js HMR, and webhook callbacks?",
- answer:
- "Yes. tunl provides full HTTP/1.1 protocol upgrade support for WebSockets, Server-Sent Events (SSE), and Hot Module Replacement in frameworks like Next.js and Vite. It also receives webhook callbacks from Stripe, GitHub, Shopify, and Slack with zero latency.",
- },
- {
- question: "Is SSH reverse tunneling through tunl secure?",
- answer:
- "Yes. All traffic between your machine and tunl is encrypted using standard OpenSSH cryptographic protocols (Ed25519/RSA). Public traffic is encrypted with automatic TLS/HTTPS certificates. Furthermore, port forwarding is strictly scoped to your chosen local TCP port without granting filesystem or network access.",
- },
-];
-
export function Faq() {
- const [openIndex, setOpenIndex] = useState(0);
-
- const toggle = (index: number) => {
- setOpenIndex(openIndex === index ? null : index);
- };
-
return (
-
-
-
-
- Frequently Asked Questions
-
-
- Everything You Need to Know About tunl
-
-
- Common questions about zero-install SSH reverse tunneling, security, and custom
- subdomains on tunl.online .
-
-
-
-
- {FAQ_ITEMS.map((item, index) => {
- const isOpen = openIndex === index;
- return (
-
-
toggle(index)}
- className="hover:bg-muted/20 flex w-full items-center justify-between gap-4 p-5 text-left transition-colors"
- aria-expanded={isOpen}
- aria-controls={`faq-answer-${index}`}
- >
-
- {item.question}
-
-
-
- {isOpen && (
-
- {item.answer}
-
- )}
-
- );
- })}
+
+
+
Questions before the first tunnel.
+
+ {FAQ_ITEMS.map((item) => (
+
+ {item.question}
+ {renderAnswer(item.answer)}
+
+ ))}
diff --git a/dashboard/components/landing/features.tsx b/dashboard/components/landing/features.tsx
index 87eb914..038b180 100644
--- a/dashboard/components/landing/features.tsx
+++ b/dashboard/components/landing/features.tsx
@@ -1,123 +1,154 @@
export function Features() {
return (
-
-
-
- Capabilities
-
+
+
+
+
What every tunnel includes.
+
+ See each request as it arrives, in your terminal and in the web inspector, with the
+ headers, payloads and status codes.
+
+
-
- {/* Tunnel Infrastructure */}
-
-
- Tunnel Infrastructure
-
-
-
-
Automatic HTTPS & TLS
-
- Automatic TLS termination on public endpoints. Every tunnel receives an{" "}
- HTTPS domain with valid SSL
- certificates.
-
-
-
-
- WebSocket & SSE Protocol Upgrade
-
-
- Full HTTP/1.1 connection
- upgrade support for WebSockets, Server-Sent Events, and Vite/Next.js Hot Module
- Replacement.
-
-
-
-
- Request Telemetry & Inspection
-
-
- Inspect incoming HTTP headers, payloads, methods, and status codes in real time
- from the tunl dashboard.
-
-
-
-
Persistent Subdomains
-
- Reserve fixed subdomain aliases (e.g.{" "}
- my-app.tunl.online) linked
- directly to your SSH public key.
-
+
+
+
+
+
+
+
+ tunl.online/inspect/my-app
+
+
+
+
+ GET
+ /
+ 200
+ 47ms
+
+
+ GET
+ /favicon.ico
+ 404
+ 31ms
+
+
+ POST
+ /api/webhooks/stripe
+ 200
+ 34ms
+
+
+ GET
+ /api/v1/users
+ 200
+ 12ms
+
+
+ POST
+ /api/webhooks/github
+ 200
+ 41ms
+
+
+
+
+ POST
+ /api/webhooks/stripe
+ 200 OK
+ 34ms
-
-
- Custom Domains
-
- New
-
-
-
- Attach your own domains (e.g.{" "}
- api.yourbrand.com) directly to
- active tunnels with automated TLS via CNAME and TXT verification.
-
+
+ Request
+ Response
-
+
+ content-type
+ application/json
+ user-agent
+ Stripe/1.0 (+https://stripe.com/docs/webhooks)
+ stripe-signature
+ t=1760361600,v1=5257a8…
+
+
{`{
+ "id": "evt_1Q9xK2",
+ "type": "payment_intent.succeeded",
+ "livemode": false
+}`}
+
+
+ The inspector shows headers, payloads, status codes and latency for every request.
+
+
- {/* Security & Access Control */}
-
-
- Security & Access Control
-
-
-
-
- OpenSSH Key Authentication
-
-
- Authenticate using standard{" "}
- Ed25519 or{" "}
- RSA public key cryptography.
- Zero API tokens or static passwords required.
-
-
-
-
IP Address Allowlisting
-
- Restrict access to active tunnel endpoints by specifying authorized CIDR blocks or
- client IP ranges in your dashboard settings.
-
-
-
-
Scoped Port Forwarding
-
- Forwarding is strictly scoped to the designated local TCP port (e.g.{" "}
- 3000). No local file system or
- network access is exposed.
-
-
-
-
- Key Revocation & Management
-
-
- Register, verify fingerprints, and revoke authorized SSH keys instantly from the
- web console.
-
-
-
+
+
Tunnel infrastructure
+
+
+
Automatic HTTPS and TLS
+
Every tunnel gets an HTTPS domain with a valid certificate, terminated for you.
+
+
+
WebSocket and SSE upgrades
+
+ Full HTTP/1.1 upgrades for WebSockets, Server-Sent Events and Vite or Next.js hot
+ reloading.
+
+
+
+
Request inspection
+
+ Read incoming headers, payloads, methods and status codes in real time from the
+ dashboard.
+
+
+
+
Persistent subdomains
+
+ Reserve a fixed name such as my-app.tunl.online, linked
+ to your SSH key.
+
+
- {/* Quiet Roadmap Line */}
-
-
- In development
-
-
- TCP port forwarding · Team workspaces · Custom rate limits
-
+
+
Security and access control
+
+
+
OpenSSH key authentication
+
Sign in with an Ed25519 or RSA key. No API tokens and no static passwords.
+
+
+
IP allowlisting
+
+ Limit who can reach a tunnel with CIDR blocks or client IP ranges set in your
+ dashboard.
+
+
+
+
Scoped forwarding
+
Only the port you choose is forwarded. No local files or network are exposed.
+
+
+
Key revocation
+
+ Register keys, verify fingerprints and revoke access instantly from the web console.
+
+
+
+
+
+ In development: custom domains (.yourdomain.com), TCP port forwarding and team
+ workspaces.
+
);
diff --git a/dashboard/components/landing/final-cta.tsx b/dashboard/components/landing/final-cta.tsx
index 40903f7..fe278ea 100644
--- a/dashboard/components/landing/final-cta.tsx
+++ b/dashboard/components/landing/final-cta.tsx
@@ -1,62 +1,33 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { ArrowRight, Check, Copy } from "lucide-react";
+import { CopyButton } from "@/components/landing/copy-button";
+import { SSH_COMMAND } from "@/components/landing/constants";
import Link from "next/link";
-import { useState } from "react";
-import { toast } from "sonner";
-
-const SSH_COMMAND = "ssh -R 80:localhost:3000 -p 2222 tunl.online";
export function FinalCta() {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = () => {
- navigator.clipboard.writeText(SSH_COMMAND);
- setCopied(true);
- toast.success("Command copied to clipboard");
- setTimeout(() => setCopied(false), 2000);
- };
-
return (
-
-
-
- Start your first tunnel
-
-
- No signup required. Execute standard SSH in your terminal to get an immediate HTTPS
- endpoint.
-
-
- {/* Command Box */}
-
- $
- {SSH_COMMAND}
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- Open Dashboard
-
-
-
-
- Add SSH Key
-
-
+
+
+
+
+
Start your first tunnel.
+
No signup required. Run standard SSH and get an HTTPS endpoint.
+
+
+
+
+ $
+
+ {SSH_COMMAND}
+
+
+
+
+ Open dashboard
+
+
+ Add SSH key
+
+
+
diff --git a/dashboard/components/landing/hero-terminal.css b/dashboard/components/landing/hero-terminal.css
new file mode 100644
index 0000000..2fcf04d
--- /dev/null
+++ b/dashboard/components/landing/hero-terminal.css
@@ -0,0 +1,189 @@
+/* Shared tunl terminal demo — works on landing and auth */
+.tunl-terminal {
+ --term-mono: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --term-xs: 13px;
+ --term-sm: 15px;
+ --term-mute: #8f9b94;
+ width: 100%;
+}
+
+.tunl-terminal .tg {
+ color: #16c60c;
+}
+.tunl-terminal .ty {
+ color: #f9f1a5;
+}
+.tunl-terminal .tr {
+ color: #e74856;
+}
+.tunl-terminal .tc {
+ color: #61d6d6;
+}
+.tunl-terminal .td {
+ color: #8c8c8c;
+}
+.tunl-terminal .tx {
+ color: #f2f2f2;
+}
+.tunl-terminal .tl {
+ color: #5b8dff;
+ text-decoration: underline;
+}
+
+.tunl-terminal .term {
+ background: #0c0c0c;
+ color: #ccc;
+ border-radius: 12px;
+ border: 1px solid #2a2a2a;
+ padding: 22px;
+ font-family: var(--term-mono);
+ font-size: var(--term-xs);
+ line-height: 1.75;
+ height: 456px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ box-sizing: border-box;
+}
+
+.tunl-terminal.compact .term {
+ padding: 18px;
+}
+
+.tunl-terminal .term p {
+ margin: 0;
+ white-space: nowrap;
+}
+
+.tunl-terminal .term hr {
+ border: 0;
+ border-top: 1px solid #4a4a4a;
+ margin: 10px 0;
+}
+
+.tunl-terminal .term .hd {
+ font-size: var(--term-sm);
+}
+
+.tunl-terminal .term .hd b {
+ font-weight: 600;
+ color: #f2f2f2;
+}
+
+.tunl-terminal .cur {
+ display: inline-block;
+ width: 0.6em;
+ height: 1.05em;
+ background: #16c60c;
+ vertical-align: text-bottom;
+ margin: 0 8px 0 4px;
+ animation: tunl-term-blink 1.1s steps(1) infinite;
+}
+
+@keyframes tunl-term-blink {
+ 50% {
+ opacity: 0;
+ }
+}
+
+.tunl-terminal .caret {
+ display: none;
+ margin: 0 0 0 2px;
+}
+
+.tunl-terminal.anim .term:not(.hdr) .caret {
+ display: inline-block;
+}
+
+.tunl-terminal .kv {
+ display: grid;
+ grid-template-columns: 12ch 1fr;
+ margin: 0;
+}
+
+.tunl-terminal .kv dt {
+ color: #8c8c8c;
+}
+
+.tunl-terminal .kv dd {
+ margin: 0;
+ white-space: nowrap;
+}
+
+.tunl-terminal .pre {
+ margin-top: 14px;
+}
+
+.tunl-terminal.anim .term .pre {
+ opacity: 0;
+ transition: opacity 0.35s ease;
+}
+
+.tunl-terminal.anim .term.hdr .pre {
+ opacity: 1;
+}
+
+.tunl-terminal .lgs {
+ margin-top: 16px;
+ min-width: 58ch;
+}
+
+.tunl-terminal .lg {
+ display: grid;
+ grid-template-columns: 8ch 5ch minmax(12ch, 1fr) 16ch 7ch;
+ column-gap: 1.4ch;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
+.tunl-terminal .lg .p {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tunl-terminal .lg .ms {
+ text-align: right;
+ color: #8c8c8c;
+}
+
+.tunl-terminal .lg.new {
+ animation: tunl-term-rowin 0.25s ease;
+}
+
+@keyframes tunl-term-rowin {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.tunl-terminal .caption {
+ margin: 16px 0 0;
+ font-size: 15px;
+ color: var(--term-mute);
+ font-family: var(--font-geist-sans), system-ui, sans-serif;
+}
+
+.landing .tunl-terminal .caption {
+ color: var(--mute);
+ font-size: var(--t-sm);
+}
+
+@media (max-width: 640px) {
+ .tunl-terminal .term {
+ height: auto;
+ min-height: 360px;
+ padding: 16px;
+ font-size: 12px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .tunl-terminal *,
+ .tunl-terminal *::before,
+ .tunl-terminal *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/dashboard/components/landing/hero-terminal.tsx b/dashboard/components/landing/hero-terminal.tsx
new file mode 100644
index 0000000..42db3bf
--- /dev/null
+++ b/dashboard/components/landing/hero-terminal.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import { SSH_COMMAND } from "@/components/landing/constants";
+import { cn } from "@/lib/utils";
+import { useEffect, useRef, useState } from "react";
+import "./hero-terminal.css";
+
+type LogEntry = [string, string, number, string, number, number];
+
+const SEQ: LogEntry[] = [
+ ["GET", "/", 200, "OK", 47, 47],
+ ["GET", "/favicon.ico", 404, "Not Found", 31, 31],
+ ["POST", "/api/webhooks/stripe", 200, "OK", 34, 34],
+ ["GET", "/api/v1/users", 200, "OK", 12, 12],
+];
+
+const POOL: LogEntry[] = [
+ ["GET", "/", 200, "OK", 30, 70],
+ ["GET", "/", 304, "Not Modified", 25, 60],
+ ["POST", "/api/webhooks/stripe", 200, "OK", 20, 60],
+ ["POST", "/api/webhooks/github", 200, "OK", 25, 60],
+ ["GET", "/api/v1/users", 200, "OK", 8, 30],
+ ["GET", "/api/v1/users/42", 200, "OK", 8, 30],
+ ["POST", "/api/v1/orders", 201, "Created", 40, 120],
+ ["GET", "/favicon.ico", 404, "Not Found", 20, 80],
+ ["POST", "/auth/callback", 302, "Found", 15, 50],
+];
+
+const STATIC_LOGS = [
+ { time: "13:39:32", method: "GET", path: "/", status: "200 OK", tone: "tg", ms: "47ms" },
+ {
+ time: "13:39:34",
+ method: "GET",
+ path: "/favicon.ico",
+ status: "404 Not Found",
+ tone: "ty",
+ ms: "31ms",
+ },
+ {
+ time: "13:39:41",
+ method: "POST",
+ path: "/api/webhooks/stripe",
+ status: "200 OK",
+ tone: "tg",
+ ms: "34ms",
+ },
+ {
+ time: "13:39:47",
+ method: "GET",
+ path: "/api/v1/users",
+ status: "200 OK",
+ tone: "tg",
+ ms: "12ms",
+ },
+ {
+ time: "13:39:52",
+ method: "GET",
+ path: "/",
+ status: "304 Not Modified",
+ tone: "tc",
+ ms: "45ms",
+ },
+ {
+ time: "13:40:03",
+ method: "POST",
+ path: "/api/webhooks/github",
+ status: "200 OK",
+ tone: "tg",
+ ms: "41ms",
+ },
+];
+
+const MAX = 7;
+
+function pad(x: number) {
+ return (x < 10 ? "0" : "") + x;
+}
+
+function stamp() {
+ const d = new Date();
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+function tone(c: number) {
+ return c < 300 ? "tg" : c < 400 ? "tc" : c < 500 ? "ty" : "tr";
+}
+
+interface LogRow {
+ id: number;
+ time: string;
+ method: string;
+ path: string;
+ status: string;
+ tone: string;
+ ms: string;
+ animate: boolean;
+}
+
+export function HeroTerminal({
+ showCaption = true,
+ compact = false,
+ className,
+}: {
+ showCaption?: boolean;
+ compact?: boolean;
+ className?: string;
+} = {}) {
+ const termRef = useRef
(null);
+ const [animOk, setAnimOk] = useState(false);
+ const [typed, setTyped] = useState(SSH_COMMAND);
+ const [showHdr, setShowHdr] = useState(true);
+ const [logs, setLogs] = useState(() =>
+ STATIC_LOGS.map((l, i) => ({ ...l, id: i, animate: false })),
+ );
+
+ useEffect(() => {
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (reduce || !("IntersectionObserver" in window)) return;
+ setAnimOk(true);
+ setTyped("");
+ setShowHdr(false);
+ setLogs([]);
+ }, []);
+
+ useEffect(() => {
+ if (!animOk || !termRef.current) return;
+
+ let n = 0;
+ let last: LogEntry | null = null;
+ let timer: ReturnType | null = null;
+ let visible = false;
+ let started = false;
+ let cancelled = false;
+ let rowId = 0;
+
+ const next = (): LogEntry => {
+ if (n < SEQ.length) return SEQ[n++];
+ let e: LogEntry;
+ do {
+ e = POOL[Math.floor(Math.random() * POOL.length)];
+ } while (e === last);
+ last = e;
+ n++;
+ return e;
+ };
+
+ const addLine = (e: LogEntry) => {
+ const ms = e[4] + Math.round(Math.random() * (e[5] - e[4]));
+ const row: LogRow = {
+ id: ++rowId,
+ time: stamp(),
+ method: e[0],
+ path: e[1],
+ status: `${e[2]} ${e[3]}`,
+ tone: tone(e[2]),
+ ms: `${ms}ms`,
+ animate: true,
+ };
+ setLogs((prev) => {
+ const nextLogs = [...prev, row];
+ return nextLogs.length > MAX ? nextLogs.slice(nextLogs.length - MAX) : nextLogs;
+ });
+ };
+
+ const tick = () => {
+ timer = null;
+ if (cancelled || !visible || document.hidden) return;
+ addLine(next());
+ timer = setTimeout(tick, n <= SEQ.length ? 650 : 1300 + Math.random() * 1700);
+ };
+
+ const resume = () => {
+ if (started && !timer && visible && !document.hidden && !cancelled) {
+ timer = setTimeout(tick, 500);
+ }
+ };
+
+ const play = () => {
+ let i = 0;
+ const step = () => {
+ if (cancelled) return;
+ i++;
+ setTyped(SSH_COMMAND.slice(0, i));
+ if (i < SSH_COMMAND.length) {
+ setTimeout(step, 26);
+ } else {
+ setTimeout(() => {
+ if (cancelled) return;
+ setShowHdr(true);
+ timer = setTimeout(tick, 600);
+ }, 260);
+ }
+ };
+ step();
+ };
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ visible = entries[0]?.isIntersecting ?? false;
+ if (visible && !started) {
+ started = true;
+ play();
+ } else if (visible) {
+ resume();
+ }
+ },
+ { threshold: 0.3 },
+ );
+
+ io.observe(termRef.current);
+ document.addEventListener("visibilitychange", resume);
+
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ io.disconnect();
+ document.removeEventListener("visibilitychange", resume);
+ };
+ }, [animOk]);
+
+ return (
+
+
+
+ $ {typed}
+
+
+
+
+ >_ tunl
+
+ •
+
+ ● Online
+
+
+
+ Account
+ SHA256:k3Fq…x9A (free plan)
+ Forwarding
+
+ localhost:3000 (via ssh -R)
+
+ Public URL
+ https://my-app.tunl.online
+ Inspector
+ https://tunl.online/inspect/my-app
+
+
+
+ Press Ctrl+C or Ctrl+D to stop
+ the tunnel
+
+
+
+ {logs.map((l) => (
+
+ {l.time}
+ {l.method}
+ {l.path}
+ {l.status}
+ {l.ms}
+
+ ))}
+
+
+ {showCaption ? (
+
The request log as it prints in your terminal.
+ ) : null}
+
+ );
+}
diff --git a/dashboard/components/landing/hero.tsx b/dashboard/components/landing/hero.tsx
index 400a812..cfe14a8 100644
--- a/dashboard/components/landing/hero.tsx
+++ b/dashboard/components/landing/hero.tsx
@@ -1,50 +1,43 @@
-"use client";
-
-import { HeroFlow } from "@/components/hero-flow";
-import { Button } from "@/components/ui/button";
-import { ArrowRight } from "lucide-react";
+import { CopyButton } from "@/components/landing/copy-button";
+import { SSH_COMMAND } from "@/components/landing/constants";
+import { HeroTerminal } from "@/components/landing/hero-terminal";
import Link from "next/link";
export function Hero() {
return (
-
-
-
- {/* Descriptor */}
-
- Free OpenSSH Reverse Tunnels · tunl.online
+
+
+
+
Make localhost public with one ssh command.
+
+ tunl is a reverse tunnel built on the OpenSSH you already have. There is no binary to
+ download, and basic tunnels need no account.
- {/* Headline */}
-
- Expose localhost to the internet with one SSH command.
-
-
- {/* Reusable Animated Hero Flow & Command Box */}
-
-
+
+
+ $
+
+ {SSH_COMMAND}
+
+
+ Works with the OpenSSH on macOS, Linux, and Windows 10 and 11. Change 3000 to your local
+ port.
+
- {/* CTAs */}
-
-
-
-
- Start Tunnel
-
-
-
-
- Add SSH Key
-
-
-
-
- Free to use · No credit card or account required for basic tunnels
-
+
+
+ Open dashboard
+
+
+ Add SSH key
+
+
+
-
+
);
}
diff --git a/dashboard/components/landing/how-it-works.tsx b/dashboard/components/landing/how-it-works.tsx
index 47091a6..3ddb665 100644
--- a/dashboard/components/landing/how-it-works.tsx
+++ b/dashboard/components/landing/how-it-works.tsx
@@ -1,114 +1,70 @@
"use client";
+import { useEffect, useRef } from "react";
+
export function HowItWorks() {
- return (
-
-
-
- How it works
-
-
- tunl uses standard SSH remote port forwarding. Your local server connects to tunl over an
- SSH session, and tunl routes public HTTPS traffic back through that connection.
-
+ const flowRef = useRef
(null);
- {/* Desktop Architecture Grid */}
-
-
-
-
- 1. Local Machine
-
-
- localhost:3000
-
-
- Any HTTP server — Next.js, FastAPI, Express, or Django. Running on any local TCP
- port.
-
-
+ useEffect(() => {
+ const el = flowRef.current;
+ if (!el || !("IntersectionObserver" in window)) return;
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
-
-
- 2. SSH Transport
-
-
- tunl.online:2222
-
-
- Standard OpenSSH reverse port forward (
- ssh -R). Encrypted end-to-end
- with Ed25519 key pairs.
-
-
+ const io = new IntersectionObserver(
+ (entries) => {
+ el.classList.toggle("in", entries[0]?.isIntersecting ?? false);
+ },
+ { threshold: 0.4 },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, []);
-
-
- 3. Public Endpoint
-
-
- https://my-app.tunl.online
-
-
- Public HTTPS endpoint with TLS certificates. Accessible globally for webhooks,
- teammates, and mobile testing.
-
-
-
+ return (
+
+
+
+
Standard SSH port forwarding, with HTTPS at the other end.
+
+ Your local server connects to tunl over SSH. Public HTTPS traffic travels back down the
+ same connection.
+
-
- {/* Mobile Vertical Flow */}
-
-
-
- 1. Local Machine
-
-
- localhost:3000
-
-
- Any HTTP server running on any local port.
-
+
+
+
+ 1 Local machine
+
+
localhost:3000
+
Any HTTP server on any local TCP port: Next.js, FastAPI, Express or Django.
-
-
-
-
- SSH -R 80
+
-
-
-
- 2. SSH Transport
-
-
- tunl.online:2222
-
-
- Standard OpenSSH. Encrypted transport layer.
+
+
+ 2 SSH transport
+
+
tunl.online:2222
+
+ A standard OpenSSH reverse forward, encrypted end to end with Ed25519 key pairs.
-
-
-
-
- HTTPS TLS
+
-
-
-
- 3. Public Endpoint
-
-
- https://my-app.tunl.online
-
-
- Public HTTPS endpoint with TLS. Accessible anywhere.
-
+
+
+ 3 Public endpoint
+
+
https://my-app.tunl.online
+
A public HTTPS URL with a valid certificate, for webhooks, teammates and phones.
diff --git a/dashboard/components/landing/landing-footer.tsx b/dashboard/components/landing/landing-footer.tsx
index 9c4e0eb..3d1c5bb 100644
--- a/dashboard/components/landing/landing-footer.tsx
+++ b/dashboard/components/landing/landing-footer.tsx
@@ -1,102 +1,67 @@
-import { Logo } from "@/components/logo";
-import { ModeToggle } from "@/components/mode-toggle";
import Link from "next/link";
export function LandingFooter() {
return (
-
-
-
- {/* Left branding */}
-
-
-
+
+
+
+
+
+ >_ tunl
+
+
Expose localhost web servers to the internet via standard OpenSSH. No CLI download
required.
-
- {/* Right link columns */}
-
-
-
- Product
-
-
-
- Dashboard
-
-
- SSH Keys
-
-
- Inspect Tunnels
-
-
- Comparison
-
-
- FAQ
-
-
-
-
-
-
-
- {/* Bottom bar */}
-
-
© {new Date().getFullYear()} tunl. All rights reserved.
-
+
+ © {new Date().getFullYear()} tunl. Designed and built by{" "}
+
+ Pankaj Thakur
+
+ .
+
);
diff --git a/dashboard/components/landing/landing-nav.tsx b/dashboard/components/landing/landing-nav.tsx
index a31f2ea..5034bec 100644
--- a/dashboard/components/landing/landing-nav.tsx
+++ b/dashboard/components/landing/landing-nav.tsx
@@ -1,117 +1,90 @@
"use client";
-import { Logo } from "@/components/logo";
-import { Button } from "@/components/ui/button";
-import { ArrowRight, Menu, X } from "lucide-react";
import Link from "next/link";
+import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
-export function LandingNav() {
- const [mobileOpen, setMobileOpen] = useState(false);
- const [scrolled, setScrolled] = useState(false);
+const NAV_LINKS = [
+ { href: "#how", label: "How it works" },
+ { href: "#uses", label: "Use cases" },
+ { href: "#capabilities", label: "Capabilities" },
+ { href: "#compare", label: "Compare" },
+ { href: "#pricing", label: "Pricing" },
+ { href: "#faq", label: "FAQ" },
+];
- useEffect(() => {
- const onScroll = () => setScrolled(window.scrollY > 10);
- window.addEventListener("scroll", onScroll, { passive: true });
- return () => window.removeEventListener("scroll", onScroll);
- }, []);
+export function LandingNav() {
+ const { resolvedTheme, setTheme } = useTheme();
+ const [open, setOpen] = useState(false);
useEffect(() => {
- if (mobileOpen) {
- document.body.style.overflow = "hidden";
- } else {
- document.body.style.overflow = "";
- }
- return () => {
- document.body.style.overflow = "";
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
};
- }, [mobileOpen]);
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, []);
- const navLinks = [
- { href: "#how-it-works", label: "How it Works" },
- { href: "#use-cases", label: "Use Cases" },
- { href: "#pricing", label: "Pricing" },
- { href: "#faq", label: "FAQ" },
- ];
+ const toggleTheme = () => {
+ setTheme(resolvedTheme === "dark" ? "light" : "dark");
+ };
return (
-
-
-
-
-
-
-
- {navLinks.map((link) => (
-
+
+
+
+ >_ tunl
+
+
+
-
-
-
-
- Log in
-
+
+
+
+
+
+
+
+
+
+
+
+ Log in
-
-
- Start Tunnel
-
+
+ Open dashboard
-
-
- {/* Mobile controls */}
-
-
- {/* Mobile overlay */}
- {mobileOpen && (
-
- )}
);
}
diff --git a/dashboard/components/landing/landing-shell.tsx b/dashboard/components/landing/landing-shell.tsx
new file mode 100644
index 0000000..f0a331d
--- /dev/null
+++ b/dashboard/components/landing/landing-shell.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { type ReactNode, useEffect, useState } from "react";
+
+export function LandingShell({ children }: { children: ReactNode }) {
+ const [anim, setAnim] = useState(false);
+
+ useEffect(() => {
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (!reduce && "IntersectionObserver" in window) setAnim(true);
+ }, []);
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/dashboard/components/landing/pricing.tsx b/dashboard/components/landing/pricing.tsx
index a1c3705..43fd904 100644
--- a/dashboard/components/landing/pricing.tsx
+++ b/dashboard/components/landing/pricing.tsx
@@ -1,114 +1,52 @@
-import { Button } from "@/components/ui/button";
-import { ArrowRight, Check } from "lucide-react";
import Link from "next/link";
export function Pricing() {
return (
-
-
-
- Pricing
-
-
- Start tunneling immediately without an account. Register an SSH key to reserve persistent
- subdomains.
-
-
- {/* Unified Two-Plan Container */}
-
- {/* Ephemeral Tier */}
-
-
-
-
- Ephemeral Tier
-
-
- Free Forever
-
-
-
- $0
- / month
-
-
- Instant 1-command tunneling for quick debugging, testing, and webhook inspection.
-
-
-
-
- Random subdomain per SSH connection
-
-
-
- Automatic HTTPS & TLS termination
-
-
-
- WebSocket & SSE protocol upgrades
-
-
-
- No account or registration required
-
-
+
+
+
+
Free to start. Free to reserve a name.
+
+ Tunnel straight away without an account. Register an SSH key when you want a subdomain
+ that stays yours.
+
+
+
+
+
Ephemeral
+
Free forever
+
+ $0 / month
-
-
- Start Free Tunnel
-
+
One-command tunnels for quick debugging, testing and webhook inspection.
+
+ Random subdomain per SSH connection
+ Automatic HTTPS and TLS termination
+ WebSocket and SSE protocol upgrades
+ No account or registration required
+
+
+ Open dashboard
-
- {/* Reserved Tier */}
-
-
-
-
- Reserved Tier
-
-
- Developer Preview
-
-
-
- $0
- / preview access
-
-
- Persistent custom subdomains linked directly to your registered OpenSSH public key.
-
-
-
-
- Everything included in Ephemeral
-
-
-
- Persistent static subdomain (
- name.tunl.online)
-
-
-
- OpenSSH Ed25519 key authentication
-
-
-
- Traffic inspector & web dashboard
-
-
-
- Custom domain support (.yourdomain.com)
-
-
-
-
-
-
- Claim Reserved Subdomain
-
-
+
+
Reserved
+
Developer preview
+
+ $0 / preview access
+
A persistent subdomain linked to your registered OpenSSH public key.
+
+ Everything in Ephemeral
+
+ Persistent static subdomain, name.tunl.online
+
+ OpenSSH Ed25519 key authentication
+ Traffic inspector and web dashboard
+
+
+ Claim reserved subdomain
+
diff --git a/dashboard/components/landing/use-cases.tsx b/dashboard/components/landing/use-cases.tsx
index 0a48077..9d64a22 100644
--- a/dashboard/components/landing/use-cases.tsx
+++ b/dashboard/components/landing/use-cases.tsx
@@ -1,60 +1,50 @@
-export function UseCases() {
- const cases = [
- {
- title: "Webhook development",
- description:
- "Receive Stripe, GitHub, or Shopify callbacks on localhost. Avoid manual deployments during integration testing.",
- command: "ssh -R 80:localhost:3000 -p 2222 tunl.online",
- },
- {
- title: "Share work in progress",
- description:
- "Share a live HTTPS endpoint with teammates or clients directly from your local development build.",
- },
- {
- title: "Mobile app testing",
- description:
- "Connect physical mobile devices to local API servers running on your development workstation.",
- },
- {
- title: "OAuth callback development",
- description:
- "Configure OAuth provider redirect URIs to a tunl HTTPS domain during local authentication development.",
- },
- {
- title: "Staging demos",
- description:
- "Demonstrate feature prototypes and dynamic UI state without deploying to temporary cloud servers.",
- },
- {
- title: "Remote device & IoT testing",
- description:
- "Test webhooks and remote HTTP requests from embedded hardware or IoT devices against localhost.",
- },
- ];
+import { SSH_COMMAND } from "@/components/landing/constants";
+export function UseCases() {
return (
-
-
-
- Use Cases
-
-
- Practical developer workflows where exposing localhost to the internet is required.
-
-
-
- {cases.map((c) => (
-
-
{c.title}
-
{c.description}
- {c.command && (
-
- $ {c.command}
-
- )}
+
+
+
+
For when a local server has to be reachable.
+
+
+
+
+
Test webhooks without deploying
+
+ Receive Stripe, GitHub or Shopify callbacks on localhost while you build the
+ integration. Paste your tunnel URL into the provider's dashboard and watch each
+ delivery arrive in the inspector.
+
+
+
+
+ $ {SSH_COMMAND}
+
+
Webhook endpoint:
+
https://my-app.tunl.online/api/webhooks/stripe
- ))}
+
+
+ Share work in progress
+ Send teammates or clients a live HTTPS link straight from your local build.
+
+
+ Test on real phones
+ Point physical devices at the API server running on your workstation.
+
+
+ Build OAuth sign-in locally
+ Register a tunl HTTPS address as the redirect URI while you develop.
+
+
+ Demo without a cloud server
+ Show prototypes and live UI state without standing up a temporary deployment.
+
+
+ Reach your laptop from IoT hardware
+ Send webhooks and HTTP requests from embedded devices to localhost.
+
diff --git a/dashboard/components/landing/why-ssh.tsx b/dashboard/components/landing/why-ssh.tsx
index 8442965..6ff72a3 100644
--- a/dashboard/components/landing/why-ssh.tsx
+++ b/dashboard/components/landing/why-ssh.tsx
@@ -1,56 +1,33 @@
export function WhySsh() {
return (
-
-
-
- Standard SSH. Nothing extra.
-
-
- Most tunneling utilities require installing a custom CLI agent. tunl relies on the
- standard OpenSSH client already installed on your system.
-
-
-
- {/* What you don't need */}
-
-
- No proprietary client stack
-
-
- {[
- "No CLI binary to download",
- "No background daemon process",
- "No API tokens for basic tunneling",
- "No local configuration files",
- "No custom package manager updates",
- ].map((item) => (
-
- {item}
-
- ))}
+
+
+
+
Nothing to install.
+
+ Most tunneling tools ask you to add a custom agent. tunl uses the OpenSSH client already
+ on your machine.
+
+
+
+
+
What you skip
+
+ A CLI binary to download
+ A background daemon process
+ API tokens for basic tunneling
+ Local configuration files
+ Custom package manager updates
-
- {/* What you already have */}
-
-
- Native system capabilities
-
-
- {[
- "OpenSSH preinstalled on macOS, Linux, and Windows 10/11",
- "Ed25519 & RSA public key authentication",
- "Encrypted SSH transport layer",
- "Outbound client connection over TCP port 2222",
- "Standard OpenSSH port forwarding syntax",
- ].map((item) => (
-
- {item}
-
- ))}
+
+
What you already have
+
+ OpenSSH on macOS, Linux and Windows 10 and 11
+ Ed25519 and RSA key authentication
+ An encrypted SSH transport
+ Standard port forwarding syntax
+ Outbound access to TCP port 2222, the only network requirement
diff --git a/dashboard/lib/seo.ts b/dashboard/lib/seo.ts
new file mode 100644
index 0000000..7c37e84
--- /dev/null
+++ b/dashboard/lib/seo.ts
@@ -0,0 +1,198 @@
+import { FAQ_ITEMS } from "@/components/landing/faq-data";
+
+export const SITE_URL = "https://tunl.online";
+export const SITE_NAME = "tunl";
+export const SSH_COMMAND = "ssh -R 80:localhost:3000 -p 2222 tunl.online";
+
+export const SEO_TITLE =
+ "tunl — Make localhost public with one SSH command | Free OpenSSH Reverse Tunnel";
+
+export const SEO_DESCRIPTION =
+ "tunl is a zero-install OpenSSH reverse tunnel. Expose localhost web servers, APIs, WebSockets, and webhooks with one ssh command — no CLI download, no account required for basic tunnels. Free ngrok alternative on tunl.online.";
+
+export const SEO_KEYWORDS = [
+ "tunl",
+ "tunl online",
+ "tunl.online",
+ "ssh tunl",
+ "ssh tunnel",
+ "ssh reverse tunnel",
+ "ssh reverse proxy",
+ "openssh tunnel",
+ "openssh reverse tunnel",
+ "expose localhost online",
+ "expose localhost to internet",
+ "make localhost public",
+ "public url for localhost",
+ "localhost https tunnel",
+ "localhost to https",
+ "free ssh tunnel",
+ "free reverse tunnel",
+ "free ngrok alternative",
+ "ngrok alternative",
+ "ngrok alternative openssh",
+ "cloudflare tunnel alternative",
+ "cloudflared alternative",
+ "localtunnel alternative",
+ "bore alternative",
+ "serveo alternative",
+ "localhost.run alternative",
+ "dev tunnel",
+ "developer tunnel",
+ "webhook testing",
+ "stripe webhook localhost",
+ "github webhook localhost",
+ "shopify webhook localhost",
+ "oauth redirect localhost",
+ "websocket tunnel",
+ "sse tunnel",
+ "next.js tunnel",
+ "vite hmr tunnel",
+ "share localhost",
+ "demo localhost",
+ "mobile test localhost",
+ "persistent subdomain",
+ "custom subdomain tunnel",
+ "ssh -R",
+ "remote port forwarding",
+ "zero install tunnel",
+ "no install reverse proxy",
+];
+
+export function buildJsonLdGraph() {
+ return {
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "WebSite",
+ "@id": `${SITE_URL}/#website`,
+ url: SITE_URL,
+ name: SITE_NAME,
+ alternateName: [
+ "tunl online",
+ "tunl.online",
+ "ssh tunl",
+ "tunl reverse tunnel",
+ "tunl SSH tunnel",
+ ],
+ description: SEO_DESCRIPTION,
+ inLanguage: "en-US",
+ publisher: { "@id": `${SITE_URL}/#organization` },
+ },
+ {
+ "@type": "Organization",
+ "@id": `${SITE_URL}/#organization`,
+ name: SITE_NAME,
+ url: SITE_URL,
+ logo: {
+ "@type": "ImageObject",
+ url: `${SITE_URL}/tunl.svg`,
+ },
+ sameAs: [
+ "https://github.com/thakurdotdev",
+ "https://x.com/thakurdotdev",
+ "https://thakur.dev",
+ ],
+ founder: {
+ "@type": "Person",
+ name: "Pankaj Thakur",
+ url: "https://thakur.dev",
+ },
+ },
+ {
+ "@type": "SoftwareApplication",
+ "@id": `${SITE_URL}/#application`,
+ name: "tunl",
+ alternateName: ["tunl online", "tunl.online"],
+ applicationCategory: "DeveloperApplication",
+ applicationSubCategory: "Reverse Tunnel / Localhost Sharing",
+ operatingSystem: "macOS, Linux, Windows 10, Windows 11, BSD",
+ url: SITE_URL,
+ downloadUrl: SITE_URL,
+ installUrl: SITE_URL,
+ softwareVersion: "1.0",
+ description: SEO_DESCRIPTION,
+ offers: {
+ "@type": "Offer",
+ price: "0",
+ priceCurrency: "USD",
+ availability: "https://schema.org/InStock",
+ description:
+ "Ephemeral tunnels are free forever. Reserved subdomains are free during developer preview.",
+ },
+ featureList: [
+ "Zero-install OpenSSH reverse tunneling",
+ "Instant public HTTPS URLs with automatic TLS",
+ "WebSocket and Server-Sent Events (SSE) upgrades",
+ "Persistent subdomains tied to SSH keys",
+ "Real-time HTTP request inspection",
+ "No account required for basic tunnels",
+ "Ed25519 and RSA public key authentication",
+ "IP allowlisting and key revocation",
+ ],
+ keywords: SEO_KEYWORDS.join(", "),
+ publisher: { "@id": `${SITE_URL}/#organization` },
+ },
+ {
+ "@type": "WebPage",
+ "@id": `${SITE_URL}/#webpage`,
+ url: SITE_URL,
+ name: SEO_TITLE,
+ description: SEO_DESCRIPTION,
+ isPartOf: { "@id": `${SITE_URL}/#website` },
+ about: { "@id": `${SITE_URL}/#application` },
+ primaryImageOfPage: {
+ "@type": "ImageObject",
+ url: `${SITE_URL}/opengraph-image`,
+ },
+ inLanguage: "en-US",
+ },
+ {
+ "@type": "HowTo",
+ "@id": `${SITE_URL}/#howto`,
+ name: "How to make localhost public with one SSH command using tunl",
+ description:
+ "Expose a local development server to a public HTTPS URL using standard OpenSSH on tunl.online — no binary to install.",
+ totalTime: "PT1M",
+ tool: [
+ {
+ "@type": "HowToTool",
+ name: "OpenSSH client (preinstalled on macOS, Linux, and Windows 10/11)",
+ },
+ ],
+ step: [
+ {
+ "@type": "HowToStep",
+ position: 1,
+ name: "Start your local server",
+ text: "Run your web server or API locally on any TCP port (for example localhost:3000).",
+ },
+ {
+ "@type": "HowToStep",
+ position: 2,
+ name: "Run the tunl SSH command",
+ text: `Open your terminal and run: ${SSH_COMMAND}`,
+ },
+ {
+ "@type": "HowToStep",
+ position: 3,
+ name: "Use your public HTTPS URL",
+ text: "Copy the public HTTPS endpoint (for example https://my-app.tunl.online) for webhooks, teammates, or mobile testing.",
+ },
+ ],
+ },
+ {
+ "@type": "FAQPage",
+ "@id": `${SITE_URL}/#faq`,
+ mainEntity: FAQ_ITEMS.map((item) => ({
+ "@type": "Question",
+ name: item.question,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: item.answer.replace(/`/g, ""),
+ },
+ })),
+ },
+ ],
+ };
+}