og-frame.tsx164 lines · main
1import { ImageResponse } from 'next/og';
2
3/**
4 * Shared layout for every opengraph-image.tsx in the marketing surface.
5 * Each route's file owns the metadata (alt + this re-export) and calls
6 * `renderOg` with its title + subtitle.
7 *
8 * Constraints from next/og:
9 * - flexbox-only css (no grid)
10 * - no <Image>, no SVG file refs; everything's inline css
11 * - default sans fallback — Geist isn't auto-available in the og runtime
12 * and fetching woff2 inside ImageResponse is more complexity than the
13 * payoff justifies. The visual identity carries via colour + layout.
14 */
15
16export const OG_SIZE = { width: 1200, height: 630 } as const;
17export const OG_CONTENT_TYPE = 'image/png' as const;
18
19const COLORS = {
20 bg: '#0a0b0d',
21 text: '#f1f5f9',
22 muted: '#94a3b8',
23 subtle: '#64748b',
24 primary: '#00e87a', // briven green per BRAND.md
25 heartRed: '#e8344a',
26 borderSubtle: '#1e2128',
27} as const;
28
29interface RenderOgInput {
30 /** Big block. Up to ~30 chars per line; we don't auto-wrap. */
31 title: string;
32 /** Smaller line under the title; optional. */
33 subtitle?: string;
34 /** Tiny mono uppercase tag in the brand colour at the top — section name. */
35 eyebrow?: string;
36}
37
38export function renderOg(input: RenderOgInput): ImageResponse {
39 return new ImageResponse(
40 (
41 <div
42 style={{
43 height: '100%',
44 width: '100%',
45 display: 'flex',
46 flexDirection: 'column',
47 justifyContent: 'space-between',
48 backgroundColor: COLORS.bg,
49 padding: '64px',
50 color: COLORS.text,
51 fontFamily: 'system-ui, sans-serif',
52 // Grid backdrop matches the marketing pages' BackgroundGrid.
53 backgroundImage:
54 'linear-gradient(to right, #1e2128 1px, transparent 1px), linear-gradient(to bottom, #1e2128 1px, transparent 1px)',
55 backgroundSize: '48px 48px',
56 }}
57 >
58 {/* Top — brand mark */}
59 <div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
60 <div
61 style={{
62 width: '40px',
63 height: '40px',
64 borderRadius: '10px',
65 backgroundColor: COLORS.primary,
66 display: 'flex',
67 alignItems: 'center',
68 justifyContent: 'center',
69 color: COLORS.bg,
70 fontSize: '26px',
71 fontWeight: 700,
72 fontFamily: 'monospace',
73 }}
74 >
75 b
76 </div>
77 <span
78 style={{
79 fontSize: '28px',
80 fontWeight: 500,
81 fontFamily: 'monospace',
82 letterSpacing: '-0.01em',
83 }}
84 >
85 briven
86 </span>
87 <span
88 style={{
89 fontSize: '22px',
90 color: COLORS.subtle,
91 fontFamily: 'monospace',
92 }}
93 >
94 · tech
95 </span>
96 </div>
97
98 {/* Middle — title + subtitle */}
99 <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
100 {input.eyebrow ? (
101 <div
102 style={{
103 fontSize: '22px',
104 color: COLORS.primary,
105 fontFamily: 'monospace',
106 textTransform: 'uppercase',
107 letterSpacing: '0.12em',
108 }}
109 >
110 {input.eyebrow}
111 </div>
112 ) : null}
113 <h1
114 style={{
115 margin: 0,
116 fontSize: '82px',
117 fontWeight: 500,
118 lineHeight: 1.05,
119 letterSpacing: '-0.03em',
120 maxWidth: '1000px',
121 // `\n` in input.title renders as a line break.
122 whiteSpace: 'pre-wrap',
123 }}
124 >
125 {input.title}
126 </h1>
127 {input.subtitle ? (
128 <p
129 style={{
130 margin: 0,
131 fontSize: '30px',
132 color: COLORS.muted,
133 lineHeight: 1.4,
134 maxWidth: '950px',
135 }}
136 >
137 {input.subtitle}
138 </p>
139 ) : null}
140 </div>
141
142 {/* Bottom — domain + flanders */}
143 <div
144 style={{
145 display: 'flex',
146 alignItems: 'center',
147 justifyContent: 'space-between',
148 fontSize: '22px',
149 color: COLORS.subtle,
150 fontFamily: 'monospace',
151 borderTop: `1px solid ${COLORS.borderSubtle}`,
152 paddingTop: '20px',
153 }}
154 >
155 <span>briven.tech</span>
156 <span>
157 built with <span style={{ color: COLORS.heartRed }}>♥</span> in Flanders
158 </span>
159 </div>
160 </div>
161 ),
162 { ...OG_SIZE },
163 );
164}