<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Varun Krishnan</title>
    <description>The latest articles on DEV Community by Varun Krishnan (@not_varunkv).</description>
    <link>https://dev.to/not_varunkv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3608714%2F3d7fbcf6-347c-4905-b0ec-2b7ec91677b4.jpg</url>
      <title>DEV Community: Varun Krishnan</title>
      <link>https://dev.to/not_varunkv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/not_varunkv"/>
    <language>en</language>
    <item>
      <title>Supabase Auth Schema Explained: Users, Identities, Sessions</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/supabase-auth-schema-explained-users-identities-sessions-5b88</link>
      <guid>https://dev.to/not_varunkv/supabase-auth-schema-explained-users-identities-sessions-5b88</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Supabase stores auth in a separate &lt;code&gt;auth&lt;/code&gt; schema not your &lt;code&gt;public&lt;/code&gt; schema. Every user has exactly &lt;strong&gt;one row in &lt;code&gt;auth.users&lt;/code&gt;&lt;/strong&gt;, one or more rows in &lt;strong&gt;&lt;code&gt;auth.identities&lt;/code&gt;&lt;/strong&gt; (one per login provider), and can hold &lt;strong&gt;multiple active &lt;code&gt;auth.sessions&lt;/code&gt;&lt;/strong&gt;, each backed by a &lt;code&gt;refresh_tokens&lt;/code&gt; row. If you've ever poked at a Supabase database and seen a cluster of tables you didn't create, these are the ones, and this is what they do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth.users 1───* auth.identities
auth.users 1───* auth.sessions
auth.sessions 1───* auth.refresh_tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why an &lt;code&gt;auth&lt;/code&gt; schema at all
&lt;/h2&gt;

&lt;p&gt;Supabase deliberately keeps auth separate from your app's tables. Your &lt;code&gt;public&lt;/code&gt; schema is where your own models live; &lt;code&gt;auth&lt;/code&gt; is locked down and managed by the auth service (GoTrue). You read from it, you don't write to it. That separation is why your migrations never touch these tables and why introspecting a Supabase database shows you a schema you didn't write.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four tables that matter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.users&lt;/code&gt; one row per user.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID primary key; the user's stable identifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;email&lt;/code&gt; / &lt;code&gt;phone&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Contact + login identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;encrypted_password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bcrypt hash (only for email/password auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw_app_meta_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provider claims: &lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;providers&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw_user_meta_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom metadata you set on the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_sso_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;True when sign-in came through SSO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;confirmed_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the primary identity was confirmed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.identities&lt;/code&gt; - one row per login method.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A user signing in with email &lt;strong&gt;and&lt;/strong&gt; GitHub gets two rows here. &lt;code&gt;provider_id&lt;/code&gt; is the provider's identifier for that user; &lt;code&gt;identity_data&lt;/code&gt; is the raw claim payload (name, avatar, email) the provider returned. The FK &lt;code&gt;user_id → users.id&lt;/code&gt; is what joins them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.sessions&lt;/code&gt; - a browser/token session.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One session per logged-in device roughly. &lt;code&gt;aal&lt;/code&gt; (assurance level), &lt;code&gt;user_agent&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;, and &lt;code&gt;not_after&lt;/code&gt; all live here. &lt;code&gt;factor_id&lt;/code&gt; links to MFA factors when you have TOTP enabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.refresh_tokens&lt;/code&gt; - the long-lived token backing a session.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Access tokens are short (JWT, ~1h). Refresh tokens are long and stored here, &lt;code&gt;revoked&lt;/code&gt; flag included, with &lt;code&gt;parent&lt;/code&gt; used to detect token reuse and rotate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they relate (the joins you'll actually write)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identities&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query is the 80% case: "who is signed in, through which provider, on which sessions." Every relationship is a plain foreign key &lt;code&gt;identities.user_id&lt;/code&gt;, &lt;code&gt;sessions.user_id&lt;/code&gt;, &lt;code&gt;refresh_tokens.session_id&lt;/code&gt; which is exactly what a schema diagram turns into readable arrows. If you'd rather trace them visually, paste a read-only connection string into &lt;a href="https://dbdiagramr.space/visualize" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; and it will pull and render the same tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's usually NOT your business
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;auth.instances&lt;/code&gt;, &lt;code&gt;auth.audit_log_entries&lt;/code&gt;, and &lt;code&gt;auth.schema_migrations&lt;/code&gt; are Supabase's own bookkeeping. &lt;code&gt;audit_log_entries&lt;/code&gt; records admin actions inside the auth service; &lt;code&gt;instances&lt;/code&gt; is leftover multi-tenancy plumbing. If a diagram shows them, ignore them your reads live in the other four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never write to &lt;code&gt;auth&lt;/code&gt; tables directly.&lt;/strong&gt; Use the Supabase client / Admin API. Direct inserts create inconsistent state (orphaned identities, unhashed passwords).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign-key joins work across schemas.&lt;/strong&gt; &lt;code&gt;auth.users.id&lt;/code&gt; is the same UUID you'd use in &lt;code&gt;public&lt;/code&gt; join &lt;code&gt;public.profiles.user_id → auth.users.id&lt;/code&gt; for your own profile data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A user with no identity row is a sign of trouble.&lt;/strong&gt; Every normal user has at least one. Orphaned &lt;code&gt;identities&lt;/code&gt; without a &lt;code&gt;users&lt;/code&gt; row point at a cleanup/import bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions accumulate.&lt;/strong&gt; Old sessions linger; your diagram showing many sessions per user isn't a leak, it's devices and tabs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This whole picture is easier to keep straight when you can see it. The &lt;a href="https://dbdiagramr.space/schema/supabase" rel="noopener noreferrer"&gt;Supabase auth schema diagram&lt;/a&gt; was generated by introspecting a live Supabase database users, identities, sessions, and refresh_tokens with their foreign keys rendered as relationships. Open it next time you're debugging a sign-in flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where is the Supabase auth schema?&lt;/strong&gt;&lt;br&gt;
In the &lt;code&gt;auth&lt;/code&gt; schema, separate from your &lt;code&gt;public&lt;/code&gt; schema &lt;code&gt;auth.users&lt;/code&gt;, &lt;code&gt;auth.identities&lt;/code&gt;, &lt;code&gt;auth.sessions&lt;/code&gt;, &lt;code&gt;auth.refresh_tokens&lt;/code&gt;, plus internal tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is &lt;code&gt;auth.users&lt;/code&gt; used for?&lt;/strong&gt;&lt;br&gt;
It's the single source of truth for who can sign in. One row per user, with email/phone, password hash, metadata, and provider flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does one user have multiple identities?&lt;/strong&gt;&lt;br&gt;
Each login method is a separate &lt;code&gt;auth.identities&lt;/code&gt; row. Email + GitHub + Google = three rows, all pointing at the same &lt;code&gt;users.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a session and a refresh token?&lt;/strong&gt;&lt;br&gt;
A session is the device/token context; the refresh token is the long-lived credential that renews the short-lived JWT. One session maps to one refresh token chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I join auth tables to my public tables?&lt;/strong&gt;&lt;br&gt;
Yes the auth schema isn't isolated for queries. Use &lt;code&gt;auth.users.id&lt;/code&gt; as the join key with your &lt;code&gt;public.*&lt;/code&gt; tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;https://dbdiagramr.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/VarunKvK/dbdiagramr" rel="noopener noreferrer"&gt;https://github.com/VarunKvK/dbdiagramr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this is useful to you, a GitHub star helps a solo dev keep building in public. I started this tool because I kept needing to visualize exactly these tables after reading migrations.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>PostgreSQL Connection String: Supabase, Neon, Railway</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/postgresql-connection-string-explained-supabase-neon-railway-2h7o</link>
      <guid>https://dev.to/not_varunkv/postgresql-connection-string-explained-supabase-neon-railway-2h7o</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;A PostgreSQL connection string is a single URL that tells a client how to reach your database. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;postgresql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="n"&gt;YOUR&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;PASSWORD&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abcdefghij&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;co&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every provider builds on the same format, and the differences that trip people up are almost never the syntax. They're the &lt;strong&gt;port and host&lt;/strong&gt; your provider hands you. Supabase alone has three different strings for the same database, and two of them won't work from serverless apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a connection string
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://  user  :  password   @     host        :  port  /  database
     |          |         |              |              |          |
  protocol    username  secret         server        port       db name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;postgres://&lt;/code&gt; also works in most clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;In Supabase: &lt;code&gt;postgres.[project-ref]&lt;/code&gt; on pooler URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[your-password]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL-encode special characters (&lt;code&gt;@&lt;/code&gt; → &lt;code&gt;%40&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.xxxx.supabase.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The server; provider-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6543 = transaction pooler&lt;/strong&gt; (Supabase/Neon)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default database name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can append query parameters after the database. The two you'll actually meet are &lt;code&gt;?sslmode=require&lt;/code&gt; (force TLS) and &lt;code&gt;?channel_binding=require&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase: three strings for one database
&lt;/h2&gt;

&lt;p&gt;Supabase is where most people hit the connection-string wall, because the &lt;em&gt;right&lt;/em&gt; string depends entirely on where your code runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.[project-ref].supabase.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Migrations, &lt;code&gt;pg_dump&lt;/code&gt;, long-lived backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared pooler (session)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-[region].pooler.supabase.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent backend on IPv4-only networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared pooler (transaction)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-[region].pooler.supabase.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;6543&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Serverless / edge / short-lived connections&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Direct
postgresql://postgres:[YOUR-PASSWORD]@db.[project-ref].supabase.co:5432/postgres

# Shared pooler session mode
postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres

# Shared pooler transaction mode (the one most apps want)
postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:6543/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The gotcha:&lt;/strong&gt; direct connections run on &lt;strong&gt;IPv6&lt;/strong&gt;. On an IPv4-only network you get an &lt;code&gt;ENOTFOUND&lt;/code&gt;, not a friendly error, just a silent failure to connect. The shared pooler is IPv4-only and fixes it. And if your app is serverless (Vercel functions, edge runtimes, Lambda), use &lt;strong&gt;transaction mode (port 6543)&lt;/strong&gt;. It's built for the many-short-connections pattern serverless forces.&lt;/p&gt;

&lt;p&gt;This exact confusion is why tools that accept a connection string usually ask you to grab the &lt;em&gt;transaction pooler&lt;/em&gt; URL. &lt;a href="https://dbdiagramr.space/visualize" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt;, for instance, has a step in its how-it-works guide telling you to select the Transaction pooler in the Supabase dashboard before pasting. Otherwise the connection silently fails from IPv4-only hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neon: pooled vs direct
&lt;/h2&gt;

&lt;p&gt;Neon makes the two variants explicit in the hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Pooled (through PgBouncer, use by default)
postgresql://user:pass@ep-cool-rain-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require

# Direct
postgresql://user:pass@ep-cool-rain-123456.us-east-2.aws.neon.tech/neondb?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: use the &lt;strong&gt;&lt;code&gt;-pooler&lt;/code&gt;&lt;/strong&gt; host unless you have a specific reason not to. It handles thousands of concurrent clients and is the right call for serverless. Grab both from the &lt;em&gt;Connect&lt;/em&gt; button in the Neon dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Railway: the plain standard
&lt;/h2&gt;

&lt;p&gt;Railway gives you a no-frills connection string with no pooler decision to make:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;postgresql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="n"&gt;YOUR&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;PASSWORD&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;railway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;railway&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the textbook format, which makes it the easiest one to read and the easiest to misplace a password in. Store it in Railway's &lt;code&gt;Variables&lt;/code&gt; tab as &lt;code&gt;DATABASE_URL&lt;/code&gt;, never in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  All the formats in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Connection string shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard / Railway&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://user:pass@host:5432/db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (direct)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://postgres:pass@db.[ref].supabase.co:5432/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (session pooler)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres://postgres.[ref]:pass@aws-[region].pooler.supabase.com:5432/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (transaction pooler)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres://postgres.[ref]:pass@aws-[region].pooler.supabase.com:6543/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neon (pooled)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/db?sslmode=require&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neon (direct)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://user:pass@ep-xxx.region.aws.neon.tech/db?sslmode=require&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Gotchas that will actually bite you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IPv4 vs IPv6.&lt;/strong&gt; Supabase direct connections are IPv6. If you're on an IPv4-only network or using a tool on an IPv4-only host, you'll get &lt;code&gt;ENOTFOUND&lt;/code&gt; and no obvious reason why. The pooler strings are IPv4 and sidestep it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless wants the transaction pooler.&lt;/strong&gt; Long-lived backends can hold a connection. Serverless functions can't. Each invocation opens a new one. Session-pooling chokes on that pattern; transaction pooling (port &lt;strong&gt;6543&lt;/strong&gt;) exists for exactly it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A connection string is a secret.&lt;/strong&gt; It contains your password in plain text. Never commit it, never paste it into a shared doc, and use a pooler/proxy string when a third-party tool needs it. Most good tools never store it beyond the one request. (dbdiagramr introspects your schema and discards the string immediately.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL-encode passwords.&lt;/strong&gt; A password containing &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, or &lt;code&gt;/&lt;/code&gt; breaks the URL. Encode those characters (&lt;code&gt;@&lt;/code&gt; → &lt;code&gt;%40&lt;/code&gt;) before pasting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious what that pooler URL actually contains after it connects? The &lt;a href="https://dbdiagramr.space/schema/supabase" rel="noopener noreferrer"&gt;Supabase auth schema diagram&lt;/a&gt; was created by introspecting a live Supabase database through a transaction pooler string. Users, identities, sessions, and refresh_tokens rendered as relationships.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a PostgreSQL connection string?&lt;/strong&gt;&lt;br&gt;
It's a single URL containing everything needed to reach a Postgres database: protocol, username, password, host, port, and database name (e.g. &lt;code&gt;postgresql://user:pass@host:5432/db&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use a direct connection or a pooler?&lt;/strong&gt;&lt;br&gt;
For a persistent backend that can hold a connection open, direct is fine. For serverless or edge functions that open a connection per invocation, use a pooler, either Supabase transaction mode (port 6543) or a Neon &lt;code&gt;-pooler&lt;/code&gt; host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my Supabase connection fail with ENOTFOUND?&lt;/strong&gt;&lt;br&gt;
You're probably using the direct connection string over IPv4, and Supabase's direct endpoint is IPv6. Switch to a shared pooler string (&lt;code&gt;aws-[region].pooler.supabase.com&lt;/code&gt;) or add the IPv4 add-on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is my connection string a secret?&lt;/strong&gt;&lt;br&gt;
Yes, it's your password in plain text. Keep it in environment variables, rotate it if it leaks, and only hand it to tools that won't store it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;https://dbdiagramr.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/VarunKvK/dbdiagramr" rel="noopener noreferrer"&gt;https://github.com/VarunKvK/dbdiagramr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this is useful to you, a GitHub star helps a solo dev keep building in public. I hit the IPv6 wall the first time I tried to visualize a Supabase database from a serverless app. Figured others would save the hour.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Visualize a PostgreSQL Schema: 5 Ways Compared</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-visualize-a-postgre-schema-5-ways-compared-35od</link>
      <guid>https://dev.to/not_varunkv/how-to-visualize-a-postgre-schema-5-ways-compared-35od</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Your Postgres schema is perfectly readable to the database and almost unreadable to a human. The tables, columns, and foreign keys you need are spread across &lt;code&gt;pg_catalog&lt;/code&gt;, &lt;code&gt;information_schema&lt;/code&gt;, and a few hundred lines of migration SQL. Visualization means collapsing all of that into a picture you can actually reason about.&lt;/p&gt;

&lt;p&gt;There are five practical ways to get there. They are not equivalent. Each makes a different trade between speed, depth, and how current the diagram stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five ways at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Interactive&lt;/th&gt;
&lt;th&gt;Stays current&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;psql&lt;/code&gt; + &lt;code&gt;pg_catalog&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Always (it's live)&lt;/td&gt;
&lt;td&gt;Quick inspection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IDE built-ins (pgAdmin, DBeaver)&lt;/td&gt;
&lt;td&gt;Already installed&lt;/td&gt;
&lt;td&gt;Partly&lt;/td&gt;
&lt;td&gt;Snapshot, manual re-run&lt;/td&gt;
&lt;td&gt;One-off look&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual diagramming (draw.io, dbdiagram.io)&lt;/td&gt;
&lt;td&gt;Short&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Drifts (you maintain it)&lt;/td&gt;
&lt;td&gt;Designing a new schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations → DDL (Prisma, Rails)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Re-run on change&lt;/td&gt;
&lt;td&gt;Documenting from code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live connection string (dbdiagramr)&lt;/td&gt;
&lt;td&gt;None (paste URL)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Regenerate in 10 seconds&lt;/td&gt;
&lt;td&gt;Understanding a real DB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. psql and pg_catalog: the baseline
&lt;/h2&gt;

&lt;p&gt;The fastest text-only view is &lt;code&gt;psql&lt;/code&gt;. &lt;code&gt;\dt&lt;/code&gt; lists all tables, &lt;code&gt;\d table_name&lt;/code&gt; shows one table's structure including its foreign keys. To see every relationship in one shot, query &lt;code&gt;information_schema&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_column_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_column_usage&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FOREIGN KEY'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Precise and always available. But it's a list, not a picture. Past a dozen tables you're reconstructing the graph in your head, which is exactly what visualization is supposed to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. IDE built-ins: pgAdmin, DBeaver, DataGrip
&lt;/h2&gt;

&lt;p&gt;If you already have a GUI client, it probably ships with an ER diagram generator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgAdmin 4&lt;/strong&gt;: right-click the database → &lt;em&gt;ERD For Database&lt;/em&gt;. Free, already installed. Auto-layout struggles past a few dozen tables, and it regenerates by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DBeaver&lt;/strong&gt; (Community is enough): open the &lt;em&gt;ER Diagram&lt;/em&gt; tab. Genuinely free, cross-platform, exports PNG/SVG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataGrip&lt;/strong&gt;: &lt;em&gt;Diagrams → Show Visualization&lt;/em&gt;. The best-feeling interactive diagram of the three; paid, single-user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest limitation they all share: &lt;strong&gt;any IDE-based diagram is a snapshot&lt;/strong&gt;. Nothing to share but an exported image, and it drifts the moment someone runs a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Manual diagramming: draw.io, dbdiagram.io, DrawSQL
&lt;/h2&gt;

&lt;p&gt;The classic approach, and great for designing a schema you haven't built yet. dbdiagram.io and DrawSQL are excellent editors. You write DBML or drag tables, and get a clean diagram you can export, share, and version.&lt;/p&gt;

&lt;p&gt;The catch is &lt;strong&gt;drift&lt;/strong&gt;. The diagram is a hand-made copy of the schema at one moment in time. The day someone adds a column or a foreign key, the diagram is wrong. A wrong diagram is worse than none, because people trust it. Building it from scratch takes 30 minutes to an hour, and it's stale the moment a migration lands.&lt;/p&gt;

&lt;p&gt;Use these when you're &lt;em&gt;designing&lt;/em&gt;. They struggle to &lt;em&gt;document a real schema over time&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Generate from your migrations
&lt;/h2&gt;

&lt;p&gt;If your schema lives in migration files (Prisma, Drizzle, Rails, Flyway), you can derive the structure from code instead of a live connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dump structure only, never row data&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;--schema-only&lt;/span&gt; mydb &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; schema.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then feed the DDL to any SQL-to-diagram tool. This is the right path when you can't or don't want to expose credentials. The trade-off: it documents the migrations, not necessarily what's actually deployed. Regenerating on every change is automation you have to build and maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Paste a live connection string: the "always current in 10 seconds" option
&lt;/h2&gt;

&lt;p&gt;The approach that solves both accuracy and effort: point a tool at the &lt;strong&gt;real database&lt;/strong&gt; and let it introspect the schema itself. No schema code, no hand-arranging, nothing to keep in sync. The diagram &lt;em&gt;is&lt;/em&gt; what's in your database right now.&lt;/p&gt;

&lt;p&gt;That's exactly what &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; does. You &lt;a href="https://dbdiagramr.space/visualize" rel="noopener noreferrer"&gt;paste a PostgreSQL connection string into it&lt;/a&gt; and it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connects and reads the schema, &lt;em&gt;structure only, never your row data&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Queries &lt;code&gt;information_schema&lt;/code&gt; for tables, columns, primary keys, and foreign keys&lt;/li&gt;
&lt;li&gt;Renders an interactive ER diagram you can pan, zoom, drag tables around, and hover over to trace relationships&lt;/li&gt;
&lt;li&gt;Exports as SVG or PNG&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because it introspects the live database, there's no drift to manage. Change a migration, paste the same string again, and you have an up-to-date diagram in under 10 seconds. Your connection string is never stored. It's introspected and discarded immediately.&lt;/p&gt;

&lt;p&gt;Not sure what an ER diagram even looks like yet? The &lt;a href="https://dbdiagramr.space/schema" rel="noopener noreferrer"&gt;schema library&lt;/a&gt; has live diagrams of the Supabase auth, NextAuth.js, Laravel, and Django schemas you can pan and zoom before you connect your own database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you should care about "stays current"
&lt;/h2&gt;

&lt;p&gt;Every method above the last one shares the same failure mode: the diagram is a snapshot, and keeping it current is your problem. Walk into any team that's been around a while and you'll find a "schema diagram" in a wiki from eight months ago. A wrong diagram is worse than none. It's confidently wrong.&lt;/p&gt;

&lt;p&gt;The test that settles it: &lt;strong&gt;how much work does it take to make this picture true again?&lt;/strong&gt; If the answer is "re-export from my IDE" or "drag the boxes by hand one more time," the humans will stop doing it and the diagram will lie to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the easiest way to generate an ER diagram from a Postgres database?&lt;/strong&gt;&lt;br&gt;
If you have a GUI client open, it's a built-in feature. pgAdmin's &lt;em&gt;ERD For Database&lt;/em&gt;, DBeaver's &lt;em&gt;ER Diagram&lt;/em&gt; tab, or DataGrip's visualization all generate one in a couple of clicks. For a shareable diagram that matches your database &lt;em&gt;as it is right now&lt;/em&gt;, a live-introspection tool is fastest because there's nothing to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I create a Postgres ERD without connecting to the live database?&lt;/strong&gt;&lt;br&gt;
Yes. Run &lt;code&gt;pg_dump --schema-only&lt;/code&gt; and feed the DDL to a SQL-to-diagram tool, or parse your migration files. That's the safer path when you can't or don't want to expose production credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I keep a schema diagram up to date?&lt;/strong&gt;&lt;br&gt;
Manual tools require manual regeneration, so in practice they drift. The reliable fix is introspection: regenerate straight from the live database (a paste of the connection string) or wire documentation generation into CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does pgAdmin make ER diagrams?&lt;/strong&gt;&lt;br&gt;
Yes. pgAdmin 4 includes an ERD tool. Right-click the database → &lt;em&gt;ERD For Database&lt;/em&gt;, or &lt;em&gt;Tools → ERD Tool&lt;/em&gt;. Free and built in, though the auto-layout is best on small-to-medium schemas.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Just want a quick look? &lt;code&gt;psql&lt;/code&gt; or your IDE's built-in diagram. Fast, local, gone tomorrow, which is fine.&lt;/li&gt;
&lt;li&gt;Designing a new schema? draw.io, dbdiagram.io, or DrawSQL are the right tools for the job.&lt;/li&gt;
&lt;li&gt;Need to &lt;em&gt;understand a database that already exists&lt;/em&gt;, or document one that keeps changing? Introspect the live schema. That's the perspective that actually survives contact with real databases.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;https://dbdiagramr.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/VarunKvK/dbdiagramr" rel="noopener noreferrer"&gt;https://github.com/VarunKvK/dbdiagramr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this is useful to you, a GitHub star helps a solo dev keep building in public. I got tired of hand-drawing diagrams that went stale. Figured others did too.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Swapped My Hand-Rolled SVG ER Diagram for React Flow + dagre . Here's What It Cost and What It Won</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 13 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/i-swapped-my-hand-rolled-svg-er-diagram-for-react-flow-dagre-heres-what-it-cost-and-what-it-won-56eg</link>
      <guid>https://dev.to/not_varunkv/i-swapped-my-hand-rolled-svg-er-diagram-for-react-flow-dagre-heres-what-it-cost-and-what-it-won-56eg</guid>
      <description>&lt;h2&gt;
  
  
  Where the story starts
&lt;/h2&gt;

&lt;p&gt;I built dbdiagramr paste a PostgreSQL connection string, get an interactive ER diagram in under 10 seconds. The first version rendered everything as &lt;strong&gt;pure SVG&lt;/strong&gt; I generated by hand. No canvas, no diagram library. That was a deliberate choice, and it worked.&lt;/p&gt;

&lt;p&gt;But as I pushed it from "a demo" to "a product" with a schema library and homepage previews, the hand-rolled renderer started fighting me. So I swapped the rendering engine for &lt;strong&gt;React Flow v12 + dagre&lt;/strong&gt;. This is that migration what broke, what I chose, and what the trade-offs actually look like in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fps9gvycc9c24h5wia9qd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fps9gvycc9c24h5wia9qd.png" alt="Db-diagramr result after using the library" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Interactive diagrams on the &lt;strong&gt;homepage demo&lt;/strong&gt; and every &lt;code&gt;/schema/[slug]&lt;/code&gt; detail page&lt;/li&gt;
&lt;li&gt;Hover a table → unrelated tables dim to 15% opacity so you can trace foreign-key relationships&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;same&lt;/strong&gt; diagram engine for static (SSG) pages and the client-side interactive canvas&lt;/li&gt;
&lt;li&gt;PNG + SVG export that matches what's on screen&lt;/li&gt;
&lt;li&gt;Keep pages fully static-server-rendered with a client hydration layer on top&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the first version did (and where it strained)
&lt;/h2&gt;

&lt;p&gt;The MVP rendered a static grid with straight-line connectors. It was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exportable for free&lt;/strong&gt; - SVG &lt;em&gt;is&lt;/em&gt; the document, serialize and you're done&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies&lt;/strong&gt; - one pure function, &lt;code&gt;schema in → SVG string out&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy to reason about&lt;/strong&gt; - four TypeScript types underpin the whole app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first pain was &lt;strong&gt;foreign-key routing&lt;/strong&gt;. A clean diagram won't draw a line from table A to table B if it slices through three unrelated tables. My first pass was a four-way conditional that picked which edge to exit from based on relative position (above/below/left/right). It worked for small schemas and got fiddly as schemas grew. I already knew I'd revisit it.&lt;/p&gt;

&lt;p&gt;The real push, though, was &lt;strong&gt;interactivity and maintainability&lt;/strong&gt;. To get hover-to-trace, drag, pan, zoom, and the relationship tracing, I was going to bolt a lot of custom logic onto raw SVG. React Flow already solves that nodes, edges, viewport, minimap, controls and it's MIT licensed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why dagre (and not the obvious alternative)
&lt;/h2&gt;

&lt;p&gt;When you need a layout engine inside a diagram tool, the first name everyone mentions is ELK (via &lt;code&gt;elkjs&lt;/code&gt;). I seriously considered it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Great hierarchical layouts out of the box&lt;/li&gt;
&lt;li&gt;Nice output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there's one hard blocker for dbdiagramr: &lt;strong&gt;it's MIT-only&lt;/strong&gt;. ELK is licensed under &lt;strong&gt;EPL-2.0&lt;/strong&gt; (and GPL-3.0 in places). Mixing that into an MIT product means taking on a license I don't want to reason about for a small open-source tool.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;dagre&lt;/strong&gt;. It's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MIT&lt;/strong&gt; - same license as the project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synchronous&lt;/strong&gt; - runs in static-generation with zero async plumbing (all four schema pages are built at build time, so dagre's sync API keeps things simple)&lt;/li&gt;
&lt;li&gt;Ships its own TypeScript types - no &lt;code&gt;@types/dagre&lt;/code&gt; needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combination was worth far more than a slightly better layout. The constraint turned an easy pick into the right pick.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dagre&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@dagrejs/dagre&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;layoutSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Rect&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;dagre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;graphlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setGraph&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;nodesep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ranksep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rankdir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// add each table node, sized from its columns&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tableSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// foreign keys → edges&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;foreignKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referencesTable&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;dagre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Rect&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One renderer, two outputs
&lt;/h2&gt;

&lt;p&gt;The key architectural move was keeping a &lt;strong&gt;single layout source of truth&lt;/strong&gt; and letting two layers read from it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;renderDiagramSVG(schema, layout)&lt;/code&gt;&lt;/strong&gt; - produces the static SVG that SSG pages and the PNG export use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;SchemaDiagram schema={...} /&amp;gt;&lt;/code&gt;&lt;/strong&gt; - a React Flow canvas that consumes the &lt;em&gt;same&lt;/em&gt; dagre layout, so the interactive diagram matches the static one pixel-for-pixel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That one function is what guarantees the homepage, the schema library thumbnails, and the detail-page interactive canvas all agree. No drift between "what you see" and "what you export".&lt;/p&gt;

&lt;h2&gt;
  
  
  The interactive layer
&lt;/h2&gt;

&lt;p&gt;On top of dagre's layout, the React Flow layer gives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hover-to-trace&lt;/strong&gt; - on hover, unrelated tables drop to &lt;strong&gt;15% opacity&lt;/strong&gt; (related stay at 100%); edges animate so you can follow the foreign key through the diagram&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom nodes&lt;/strong&gt; - a &lt;code&gt;TableNode&lt;/code&gt; with &lt;strong&gt;row-anchored handles&lt;/strong&gt;: FK columns get a source handle on the right, referenced columns a target handle on the left, with PK/FK badges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cardinality on edges&lt;/strong&gt; - &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;*&lt;/code&gt; badges rendered via an edge label layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewport extras&lt;/strong&gt; - background zoom, minimap, and a &lt;strong&gt;download panel&lt;/strong&gt; exporting SVG or PNG (PNG is rasterized at &lt;strong&gt;2×&lt;/strong&gt; for crisp export)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it sits on dagre's layout &lt;code&gt;rankdir: LR&lt;/code&gt;, 60px node spacing, 90px rank separation so related tables end up grouped left-to-right instead of scattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real schema looks like when you do it
&lt;/h2&gt;

&lt;p&gt;The site ships a &lt;strong&gt;free schema library&lt;/strong&gt; rendered this way real public schemas introspected into the exact &lt;code&gt;Schema&lt;/code&gt; type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supabase auth - 7 tables, 3 foreign keys&lt;/li&gt;
&lt;li&gt;NextAuth.js / Auth.js - 4 tables, 2 relationships&lt;/li&gt;
&lt;li&gt;Laravel 11 default - 7 tables&lt;/li&gt;
&lt;li&gt;Django auth - 9 tables, 9 relationships&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The big one, Django (9 tables, 9 relationships), is nearly a star topology most tables reference the auth user table. dagre handles it fine; the lesson is to let a real layout engine deal with that instead of my hand-rolled routing conditional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honestly, what did it cost?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A static model is simpler.&lt;/strong&gt; The pure-SVG version truly was dependency-free. React Flow pulls in more code for edge routing, viewports, and type plumbing. That's a real trade, and I don't regret making it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License is a design constraint.&lt;/strong&gt; The MIT-only requirement is what made dagre the right call over ELK and it was worth reasoning at the outset rather than mid-refactor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But what it bought:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive relationship tracing&lt;/strong&gt; the pure-SVG version never had the kind of thing I'd have spent weeks hand-building on raw SVG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One layout, two outputs&lt;/strong&gt; an identical static renderer and canvas that never drift&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export that always matches the screen&lt;/strong&gt; SVG or 2× PNG, straight from the live view&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limitations (still)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL only - no MySQL, SQLite, or MongoDB yet&lt;/li&gt;
&lt;li&gt;Public schema only - custom schemas aren't supported yet&lt;/li&gt;
&lt;li&gt;Real-time sync is still not a thing - you generate, you export, you're done&lt;/li&gt;
&lt;li&gt;dagre handles the schema sizes here fine; at hundreds of tables I'd look at force layouts (and re-graph sync)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you're drawing relationships, don't hand-roll the canvas/network code when a purpose-built, MIT-licensed library exists. Let the library own the viewport and interaction; you keep the part that matters a &lt;strong&gt;small, typed data model&lt;/strong&gt; and a single renderer that powers both static and interactive output. My first instinct was "I can just draw this myself." It took a week of wiring to realize the library was doing me a favor the payoff is interactivity I'd have hand-built for far longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 (App Router)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg&lt;/code&gt; for PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow v12) for the canvas&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@dagrejs/dagre&lt;/code&gt; for layout (MIT)&lt;/li&gt;
&lt;li&gt;MIT licensed, fully self-hostable&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>opensource</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Built a PostgreSQL Schema Visualizer With Pure TypeScript SVG - No Diagramming Library</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Sat, 01 Aug 2026 18:04:13 +0000</pubDate>
      <link>https://dev.to/not_varunkv/i-built-a-postgresql-schema-visualizer-with-pure-typescript-svg-no-diagramming-library-7ha</link>
      <guid>https://dev.to/not_varunkv/i-built-a-postgresql-schema-visualizer-with-pure-typescript-svg-no-diagramming-library-7ha</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I manage a few side projects with PostgreSQL databases. Every time I add a table, change a column, or refactor a relationship, I have to update my ER diagram.&lt;/p&gt;

&lt;p&gt;I was using draw.io. It works. But it's manual. And it's tedious.&lt;/p&gt;

&lt;p&gt;Open the file. Drag a new box. Connect the lines. Repeat for every schema change. It takes 30 minutes to an hour, it gets stale the moment someone commits a migration, and then you're back to answering "wait, what does the schema actually look like?"&lt;/p&gt;

&lt;p&gt;A coworker asks you that question. You open pgAdmin. You click through tables. You mentally assemble the picture. It's slow and it's only in your head.&lt;/p&gt;

&lt;p&gt;There had to be a better way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;dbdiagramr.&lt;/p&gt;

&lt;p&gt;Paste your PostgreSQL connection string. Get an interactive ER diagram in under 10 seconds. Pan, zoom, hover over tables to trace relationships, drag tables around. Export as SVG or PNG.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7w5qlw0bkqnwl1jgqtuz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7w5qlw0bkqnwl1jgqtuz.png" alt="Er-diagram creator named dbdiagram" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The whole flow is four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You paste your connection string&lt;/li&gt;
&lt;li&gt;A server API introspects &lt;code&gt;information_schema&lt;/code&gt; for tables, columns, keys&lt;/li&gt;
&lt;li&gt;A pure TypeScript function generates an SVG diagram&lt;/li&gt;
&lt;li&gt;You pan, zoom, and explore&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No Canvas. No heavy libraries. Just SVG.&lt;/p&gt;

&lt;p&gt;The data model is deliberately small - four types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isPrimaryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ForeignKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referencesTable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referencesColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;foreignKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every diagram is just a &lt;code&gt;Schema&lt;/code&gt; object. Everything else is rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Introspection
&lt;/h2&gt;

&lt;p&gt;The server connects to your database and reads only the &lt;em&gt;structure&lt;/em&gt; — never your data. For each table it runs three queries in parallel against &lt;code&gt;information_schema&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Columns&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_nullable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Primary keys&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY KEY'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Foreign keys&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_column_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_column_usage&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FOREIGN KEY'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These run server-side in a single API route, get assembled into a typed &lt;code&gt;Schema&lt;/code&gt; object, and get sent back as JSON. The client never touches the database directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SVG (and not Canvas)
&lt;/h2&gt;

&lt;p&gt;I chose pure SVG for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accessible by default&lt;/strong&gt; - text is text, colors are colors. No rasterization needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exportable for free&lt;/strong&gt; - SVG &lt;em&gt;is&lt;/em&gt; the document. Serialize it and you're done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dependencies&lt;/strong&gt; - the diagram generator is a pure function: &lt;code&gt;schema&lt;/code&gt; in, SVG string out.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateDiagramSVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// grid layout&lt;/span&gt;
  &lt;span class="c1"&gt;// table cards → &amp;lt;g&amp;gt; elements&lt;/span&gt;
  &lt;span class="c1"&gt;// foreign keys → &amp;lt;line&amp;gt; with arrow markers&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single function produces the whole diagram server-side or client-side. The interactive React component sits on top and layers on pan, zoom, drag, and hover highlighting roughly 400 lines.&lt;/p&gt;

&lt;p&gt;For PNG export, I serialize the current SVG (with tight viewBox and inlined styles), draw it to a canvas, and let the browser encode it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svgString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;serializeSvg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svgElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;svgString&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/svg+xml;charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// draw to canvas, canvas.toBlob → PNG download&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Hard Part: Foreign Key Routing
&lt;/h2&gt;

&lt;p&gt;The most interesting problem wasn't drawing the boxes - it was deciding &lt;em&gt;where the connection lines exit each table&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A foreign key connects column A in table 1 to column B in table 2. But which edge of table 1 should the line leave from? If table 1 is above table 2, the line exits the bottom. If they're side by side, it exits the right side. If one is much taller, it gets complicated.&lt;/p&gt;

&lt;p&gt;The naive version produced lines that crossed through unrelated tables and made the diagram unreadable.&lt;/p&gt;

&lt;p&gt;The fix was a four-way conditional that decides based on relative position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits above target → line exits bottom, enters top&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits below target → line exits top, enters bottom&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits left of target → line exits right edge&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tLeft&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits right of target → line exits left edge&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tRight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple on paper. It still took a while to get right because the cases interact - and the fallback (overlapping boxes) matters more than you'd think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A tiny data model wins.&lt;/strong&gt; Four types. The whole app is built on them, and it keeps the mental overhead near zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The grid layout breaks at - 50 tables.&lt;/strong&gt; A static grid is fine for most schemas but gets cluttered on big ones. Next step would be a force-directed layout or at least smarter clustering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping beats perfecting.&lt;/strong&gt; I could have kept polishing the routing algorithm forever. Shipping it and getting real feedback taught me more than any extra week of tweaking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;p&gt;I'll say the same thing I'd tell a friend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL only. No MySQL, SQLite, or MongoDB yet.&lt;/li&gt;
&lt;li&gt;Public schema only - custom schemas aren't supported yet.&lt;/li&gt;
&lt;li&gt;No real-time sync. You generate, you export, you're done.&lt;/li&gt;
&lt;li&gt;Static grid layout struggles past ~50 tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 (App Router)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg&lt;/code&gt; for PostgreSQL&lt;/li&gt;
&lt;li&gt;Pure SVG for diagrams&lt;/li&gt;
&lt;li&gt;MIT licensed, fully self-hostable&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>showdev</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The contract clause that almost cost me my entire codebase</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Sat, 06 Jun 2026 02:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/the-contract-clause-that-almost-cost-me-my-entire-codebase-j9d</link>
      <guid>https://dev.to/not_varunkv/the-contract-clause-that-almost-cost-me-my-entire-codebase-j9d</guid>
      <description>&lt;p&gt;&lt;strong&gt;I almost signed it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The project looked good. The client seemed reasonable. The money was fair. I'd been freelancing long enough to know the drill get the contract, skim it, sign it, start working.&lt;/p&gt;

&lt;p&gt;This time, for no particular reason, I actually read it.&lt;br&gt;
Buried in section 4 or maybe section 6, I don't remember exactly was a line that stopped me cold. Something about "pre-existing materials." Standard boilerplate, right? Except it wasn't.&lt;br&gt;
The clause said that anything I brought into the project including tools, libraries, and code I'd written before the engagement would become the exclusive property of the client upon completion.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Let that sink in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every utility function I'd spent years refining. Every internal library I reused across client projects. Every tool I'd built for my own workflow. All of it. Theirs. The moment I delivered the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "pre-existing materials" actually means
&lt;/h2&gt;

&lt;p&gt;Most freelance contracts have an IP assignment clause. That's normal the client pays you to build something, they own what you built. Fair enough.&lt;/p&gt;

&lt;p&gt;But "pre-existing materials" is different. It's the stuff you owned before you ever heard of this client. Your code. Your tools. Your background IP.&lt;/p&gt;

&lt;p&gt;A well-written contract carves this out explicitly. Something like: "Contractor retains ownership of all pre-existing materials. Client receives a license to use them as incorporated into the deliverables."&lt;/p&gt;

&lt;p&gt;A badly written or deliberately predatory contract does the opposite. It lumps everything together under one broad assignment clause and hopes you don't notice.&lt;br&gt;
I noticed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did
&lt;/h2&gt;

&lt;p&gt;I pushed back. Told the client the clause needed to be amended before I could sign.&lt;/p&gt;

&lt;p&gt;The response surprised me. They got defensive. Not "oh that's just boilerplate, of course we'll fix it" defensive. Like I'd caught them doing something they knew was wrong.&lt;/p&gt;

&lt;p&gt;That told me everything I needed to know. I walked away from the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to look for in any freelance contract
&lt;/h2&gt;

&lt;p&gt;If you take nothing else from this, scan every contract you sign for these:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. Pre-existing materials / background IP&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Any clause that assigns ownership of things you brought to the project, not just what you built for it. Red flag if there's no &lt;br&gt;
explicit carve-out retaining your ownership.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;2. Perpetual non-competes&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"During and after the term of this agreement" with no time limit. This means forever. A reasonable non-compete has a duration — 6 or 12 months. No duration means no end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;3. Unlimited liability&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If something goes wrong — a bug, a security issue, anything — are you personally on the hook with no cap? Some contracts make you liable for damages that could exceed what you were even paid.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;4. Payment at sole discretion&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Client may withhold payment for deliverables deemed unsatisfactory at Client's sole discretion." Translation: they can refuse to pay for any reason and you have no recourse.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;5. Work for hire with no exceptions&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything you create during the engagement becomes theirs. Fine for the deliverables. Not fine if it includes general tools, open source contributions, or personal projects you worked on simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a safe version looks like
&lt;/h2&gt;

&lt;p&gt;Here's the rewrite I proposed for the pre-existing materials clause:&lt;/p&gt;

&lt;p&gt;"Contractor retains all ownership of pre-existing materials, tools, and background technology used in the project. Client receives a perpetual, non-exclusive, royalty-free license to use such materials solely as incorporated into the deliverables. All intellectual property developed specifically for this project shall be owned by Client upon receipt of full payment."&lt;/p&gt;

&lt;p&gt;That's it. Both parties are protected. Client gets what they paid for. You keep what you built before they ever hired you.&lt;br&gt;
Most reasonable clients will accept this without argument. The ones who don't are telling you something important about how the rest of the engagement will go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I built Clause
&lt;/h2&gt;

&lt;p&gt;After this happened I started paying more attention to contracts. Talked to other freelancers. Turns out this isn't rare it's common. &lt;/p&gt;

&lt;p&gt;Clients use broad boilerplate and count on developers being too busy, too eager, or too intimidated to read it carefully.&lt;/p&gt;

&lt;p&gt;So I built a tool that does the reading for you.&lt;br&gt;
Paste any contract into Clause and it flags the dangerous clauses explains what each one means in plain English, why it's risky, and suggests a fairer rewrite you can send back to the client.&lt;br&gt;
It's free to use and doesn't require an account to try.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.clauseapp.space/" rel="noopener noreferrer"&gt;Clauseapp.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've ever been burned by a contract clause or nearly missed on I'd genuinely like to hear about it in the comments.&lt;/p&gt;

</description>
      <category>freelancing</category>
      <category>webdev</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>How to Create Product GIFs for Your Landing Page (No Video Editing Required)</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Fri, 02 Jan 2026 18:24:08 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-create-product-gifs-for-your-landing-page-no-video-editing-required-5b89</link>
      <guid>https://dev.to/not_varunkv/how-to-create-product-gifs-for-your-landing-page-no-video-editing-required-5b89</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Brochure vs. The Demo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Imagine walking into an electronics store.&lt;br&gt;
You see a sleek new laptop.&lt;br&gt;
Do you just stare at the turned-off screen? No. You touch the trackpad. You open a window. You want to see it work.&lt;/p&gt;

&lt;p&gt;Your landing page is that store.&lt;br&gt;
But most SaaS landing pages are just... brochures.&lt;/p&gt;

&lt;p&gt;They have beautiful, high-res, completely static screenshots. They force the user to read about the features instead of seeing the features.&lt;/p&gt;

&lt;p&gt;And reading is hard work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The 3-Second Rule&lt;/em&gt;&lt;br&gt;
You have roughly 3 seconds to convince a visitor to stay on your site.&lt;/p&gt;

&lt;p&gt;Static Image: The brain has to process the text, look at the UI, map the text to the UI, and simulate the interaction mentally. Cognitive load: High.&lt;br&gt;
GIF/Video: The brain sees movement. It instantly understands "Clicking X does Y." Cognitive load: Low.&lt;br&gt;
Motion is a cheat code for attention.&lt;/p&gt;

&lt;p&gt;Why Not Just Use Video?&lt;br&gt;
"Okay," you say. "I'll just embed a YouTube video."&lt;/p&gt;

&lt;p&gt;You could. But videos are heavy. They require a click to play. They have sound (risky). They take over the experience.&lt;/p&gt;

&lt;p&gt;GIFs are the sweet spot.&lt;/p&gt;

&lt;p&gt;Autoplay: No click needed.&lt;br&gt;
Silent: Safe for work.&lt;br&gt;
Looping: Reinforces the core value proposition.&lt;br&gt;
Lightweight: Loads faster than a 4K video embed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Problem with GIF Creation&lt;/strong&gt;&lt;br&gt;
Creating a polished product GIF used to be painful.&lt;/p&gt;

&lt;p&gt;Record screen (OBS/Loom).&lt;br&gt;
Import to Premiere/After Effects.&lt;br&gt;
Add a device frame overlay.&lt;br&gt;
Mask the video to fit the screen.&lt;br&gt;
Add a background.&lt;br&gt;
Export and pray the file size isn't 50MB.&lt;br&gt;
Most developers (and even designers) don't have time for this. So they settle for static screenshots.&lt;/p&gt;

&lt;p&gt;The Shotframe Solution: GIFs in the Browser&lt;br&gt;
We added GIF support to Shotframe to solve exactly this problem. We wanted the polish of After Effects with the speed of a screenshot tool.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How it works:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Record Your Screen: Use any tool to grab a short clip of your key feature. (Keep it under 5 seconds for best results).&lt;br&gt;
Drop into Shotframe: Upload your video file just like an image.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgolscyr5khbg357gzi0a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgolscyr5khbg357gzi0a.png" alt="Drag and Drop the image on Shotframe" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Frame It: Choose a premium device frame (iPhone, MacBook, Browser). Shotframe handles the masking automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvh7jfhyyjgnrxspsc0i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvh7jfhyyjgnrxspsc0i.png" alt="Selected Iphone as a mockup" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Style It: Add a custom gradient background (use BlendIt to match your brand colors!).&lt;br&gt;
Export: Download a highly optimized GIF ready for your landing page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffymzyl9ulgk2t9a8kfw4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffymzyl9ulgk2t9a8kfw4.gif" alt="GIF in action" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Practices for Product GIFs&lt;/strong&gt;&lt;br&gt;
If you're going to use GIFs, do it right:&lt;/p&gt;

&lt;p&gt;Keep it Short: 3-5 seconds max. Focus on ONE interaction.&lt;br&gt;
Start with Action: Don't have 2 seconds of a stillness at the start. Movement should happen immediately.&lt;br&gt;
High Contrast: Make sure the cursor and the button clicks are visible.&lt;br&gt;
Optimization: Don't upload a 20MB GIF to your hero section. Use Shotframe’s export settings to balance quality and size.&lt;br&gt;
Conclusion&lt;br&gt;
Your product is dynamic. Your marketing should be too.&lt;br&gt;
Stop letting your hard work sit still. Make it move.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.shotframe.space/" rel="noopener noreferrer"&gt;[Try the Shotframe GIF Maker →]&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>design</category>
    </item>
    <item>
      <title>How to Make Product Mockups That Don't Look Like Templates</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 01 Jan 2026 15:59:59 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-make-product-mockups-that-dont-look-like-templates-f78</link>
      <guid>https://dev.to/not_varunkv/how-to-make-product-mockups-that-dont-look-like-templates-f78</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Stop using generic mockup backgrounds. Learn how to create custom, brand-aligned mockups (and GIFs) directly from your browser. No Figma required.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4aqhcluz3j10tzf559b.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4aqhcluz3j10tzf559b.gif" alt=" " width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Problem With Modern Mockups&lt;/strong&gt;&lt;br&gt;
Scroll through Dribbble, Product Hunt, or any SaaS landing page. You’ll see the same thing:&lt;/p&gt;

&lt;p&gt;A generic MacBook or iPhone frame.&lt;br&gt;
A "mesh gradient" background (usually purple or blue).&lt;br&gt;
A shadow that looks slightly fake.&lt;br&gt;
It’s the "Startup Starter Pack." And because everyone uses the same templates, no one stands out.&lt;/p&gt;

&lt;p&gt;If you’ve spent weeks building a unique product, wrapping it in a generic mockup kills the vibe instantly.&lt;/p&gt;

&lt;p&gt;Your product deserves better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Context Matters (And Why Templates Fail)&lt;/strong&gt;&lt;br&gt;
A mockup's job isn't just to frame your screenshot. Its job is to extend your brand.&lt;/p&gt;

&lt;p&gt;When you use a random gradient template, you are clashing with your product's actual design.&lt;/p&gt;

&lt;p&gt;The Pro Move: Your background colors should be extracted from the product screenshot itself.&lt;br&gt;
The Result: Perfect color harmony. The background feels like an extension of the UI, not a wrapper.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Method 1: The "Figma Workflow" (The Hard Way)&lt;/em&gt;&lt;br&gt;
You can do this manually, of course.&lt;/p&gt;

&lt;p&gt;Take a screenshot.&lt;br&gt;
Open Figma.&lt;br&gt;
Import screenshot.&lt;br&gt;
Use the Eyedropper tool to pick 3-4 dominant colors.&lt;br&gt;
Create a rectangle layer behind the image.&lt;br&gt;
Apply a linear or radial gradient using those colors.&lt;br&gt;
Add a device frame (search community files for a mockup).&lt;br&gt;
Add drop shadows manually.&lt;br&gt;
Export.&lt;br&gt;
Time: ~15-20 minutes per image.&lt;br&gt;
Result: Good, but slow.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Method 2: The Shotframe Workflow (The Fast Way)&lt;/em&gt;&lt;br&gt;
We built Shotframe to automate the "Pro Move."&lt;/p&gt;

&lt;p&gt;Step 1: Upload Your Screenshot&lt;br&gt;
Drop your image into the browser. No login needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpyqdmmu31i7vew1zhkwh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpyqdmmu31i7vew1zhkwh.png" alt="Shotframe Drag and Drop Feature" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 2: Auto-Match the Background&lt;br&gt;
Instead of picking a random color, use a tool like BlendIt to generate a gradient directly from your screenshot. Upload that gradient as your background in Shotframe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9erfdx7vfopc4xoe91d2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9erfdx7vfopc4xoe91d2.png" alt="Generate gradient on BlendIt" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now your background matches your UI perfectly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6lz7p19bnc5q66xffil.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6lz7p19bnc5q66xffil.png" alt="Matched to the Image" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 3: Choose Your Device&lt;br&gt;
Select from premium device frames (Safari Dark, iPhone 15, Chrome Light). It wraps your screenshot automatically with perfect shadows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuj6u88jzkuwtapp4wd81.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuj6u88jzkuwtapp4wd81.png" alt="Choose any type of device" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 4: Go Beyond Static (New Feature)&lt;br&gt;
Static images are fine. Motion is better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzq4z2z05a7mnmonpomil.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzq4z2z05a7mnmonpomil.png" alt="GIF feature on shotframe" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Shotframe now lets you export as GIF.&lt;/p&gt;

&lt;p&gt;Show a scrolling capture.&lt;br&gt;
Show a UI interaction.&lt;br&gt;
Show a workflow.&lt;br&gt;
This turns a "picture of an app" into a "demo of an app."&lt;/p&gt;

&lt;p&gt;Why GIFs Convert Better Than Images&lt;br&gt;
On a landing page, you have about 3 seconds to explain what your tool does.&lt;/p&gt;

&lt;p&gt;Screenshot: The user has to read the UI to understand it.&lt;br&gt;
GIF: The user sees the UI working.&lt;br&gt;
It builds trust. It proves the software is real. It catches the eye.&lt;/p&gt;

&lt;p&gt;Creating Your First Custom Mockup&lt;br&gt;
Go to Shotframe.xyz.&lt;br&gt;
Drop your image.&lt;br&gt;
Tweak the background (or upload your custom BlendIt gradient).&lt;br&gt;
Hit Export.&lt;br&gt;
Stop letting generic mockups lower your product's value. Make it look as premium as the code behind it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.shotframe.space/" rel="noopener noreferrer"&gt;[Create a Free Mockup Now →]&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How to Create a Gradient from Any Photo (Step-by-Step)</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Wed, 31 Dec 2025 19:40:58 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-create-a-gradient-from-any-photo-step-by-step-4379</link>
      <guid>https://dev.to/not_varunkv/how-to-create-a-gradient-from-any-photo-step-by-step-4379</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Learn how to extract colors from any photo and turn them into beautiful gradients. Stop guessing hex codes—let your photos pick the perfect palette.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Stop Guessing Hex Codes. Let Your Photos Decide.&lt;/strong&gt;&lt;br&gt;
Every designer has been here:&lt;/p&gt;

&lt;p&gt;Open a gradient generator. Pick random colors. Hate it. Repeat 47 times. Settle for something "fine."&lt;/p&gt;

&lt;p&gt;There's a better way.&lt;/p&gt;

&lt;p&gt;Instead of inventing colors from scratch, extract them from photos you already love. Sunsets, coffee cups, product shots—they all contain palettes that already work together.&lt;/p&gt;

&lt;p&gt;Why? Because nature figured out color harmony millions of years ago. We're just borrowing it.&lt;/p&gt;

&lt;p&gt;This guide shows you how to turn any photo into a usable gradient in under 60 seconds.&lt;/p&gt;




&lt;p&gt;Why Photos Make Better Palettes&lt;br&gt;
Most gradient generators give you "trending" palettes. The problem? Everyone uses the same ones.&lt;/p&gt;

&lt;p&gt;That's why every SaaS landing page has the same purple-blue mesh gradient.&lt;/p&gt;

&lt;p&gt;Photos solve this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Colors Already Harmonize&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Colors in a photo exist together naturally. A sunset has warm oranges, soft pinks, and deep purples—all in perfect balance. You didn't pick them. Light did.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Nature Is Never Wrong"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An old design principle. Flowers, landscapes, street scenes—colors that appear together in real life just work. No color theory degree required.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Unique to Your Brand&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you pull colors from YOUR product shot or YOUR office photo, the gradient is instantly tied to your brand. Not a trending palette. Yours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Step-by-Step: Photo to Gradient&lt;/em&gt;&lt;br&gt;
Here's the simple workflow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Choose Your Photo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pick something with colors you like:&lt;/p&gt;

&lt;p&gt;Product screenshot (brand-aligned)&lt;br&gt;
Landscape (natural, calming)&lt;br&gt;
Street photography (urban, moody)&lt;br&gt;
Even a coffee cup on your desk&lt;br&gt;
The best photos have a clear color mood. Avoid images with too many competing colors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9uzcf8yxlhjznb8wfo2b.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9uzcf8yxlhjznb8wfo2b.jpg" alt="Scene image got from https://walle.theblank.club/" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Upload to a Color Extraction Tool&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop your image into BlendIt (or similar tool).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn6drgxa5hi46zh0l2lsz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn6drgxa5hi46zh0l2lsz.png" alt="Drag and drop the image" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It automatically detects the dominant colors and arranges them into a gradient.&lt;/p&gt;

&lt;p&gt;No eyedropper. No manual picking. Done in seconds.&lt;/p&gt;

&lt;p&gt;Step 3: Adjust If Needed&lt;/p&gt;

&lt;p&gt;Sometimes the auto-pick isn't perfect. Most tools let you:&lt;/p&gt;

&lt;p&gt;Swap colors&lt;br&gt;
Adjust positions&lt;br&gt;
Change gradient style (mesh, linear, radial)&lt;br&gt;
Tweak until it feels right.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwkxe8he8gq9m0nsxqbsj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwkxe8he8gq9m0nsxqbsj.png" alt="Tweak the gradiency as you wish" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 4: Add Texture (Optional)&lt;/p&gt;

&lt;p&gt;Flat gradients can look sterile. Adding subtle grain or noise gives depth and a premium feel.&lt;/p&gt;

&lt;p&gt;This is popular in modern SaaS and Web3 design.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvazd08iwp58jy9rpvju0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvazd08iwp58jy9rpvju0.png" alt="Grain texture" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 5: Export&lt;/p&gt;

&lt;p&gt;Download as PNG for landing pages and social media.&lt;/p&gt;

&lt;p&gt;Or copy the CSS for direct use in code.&lt;/p&gt;

&lt;p&gt;Done. 60 seconds. No guessing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Use Cases&lt;/strong&gt;&lt;br&gt;
Where can you use photo-extracted gradients?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Landing Page Backgrounds&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hero sections need something more interesting than solid color but less busy than photos. Gradients from your product colors = perfect middle ground.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Social Media Graphics&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Instagram posts, LinkedIn carousels, Twitter headers. Matching gradients make your content look cohesive.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Presentation Slides&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Pitch decks and internal presentations. Stand out from the default PowerPoint templates.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Device Wallpapers&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Personal touch for your phone or desktop.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mockup Backgrounds&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When showcasing your product in mockups, use a gradient from the product itself. Everything matches automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tips for Best Results&lt;/strong&gt;&lt;br&gt;
Use Photos with Clear Color Themes&lt;/p&gt;

&lt;p&gt;A photo of a sunset works great. A photo of a busy street market? Too many colors competing.&lt;/p&gt;

&lt;p&gt;Product Shots = Brand Alignment&lt;/p&gt;

&lt;p&gt;Pull colors from your actual product screenshot. Your gradient will match your brand without any extra effort.&lt;/p&gt;

&lt;p&gt;Landscapes = Natural Vibes&lt;/p&gt;

&lt;p&gt;Mountains, oceans, forests. These give calm, organic palettes.&lt;/p&gt;

&lt;p&gt;Experiment with Unexpected Sources&lt;/p&gt;

&lt;p&gt;Coffee. Book covers. Album art. Some of the best gradients come from random everyday photos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Lazy Designer's Secret&lt;/strong&gt;&lt;br&gt;
Here's the truth:&lt;/p&gt;

&lt;p&gt;Great color choices aren't about talent. They're about sources.&lt;/p&gt;

&lt;p&gt;Designers who seem to have "good taste" often just steal from better sources—nature, photography, real life.&lt;/p&gt;

&lt;p&gt;Now you can too.&lt;/p&gt;

&lt;p&gt;Stop staring at color pickers. Stop cycling through trending palettes.&lt;/p&gt;

&lt;p&gt;Take a photo. Extract the colors. Move on with your life.&lt;/p&gt;

&lt;p&gt;Try It Yourself&lt;br&gt;
BlendIt makes this workflow instant:&lt;/p&gt;

&lt;p&gt;Drop any photo&lt;br&gt;
Colors extracted automatically&lt;br&gt;
Gradient generated with grain texture&lt;br&gt;
Export PNG or copy CSS&lt;br&gt;
Free to use. No account needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.blendit.space/" rel="noopener noreferrer"&gt;[Try BlendIt →]&lt;/a&gt;&lt;/p&gt;

</description>
      <category>design</category>
      <category>css</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The 48-Hour Button: Fighting Supabase, Google Auth, and "Scope Creep"</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Fri, 28 Nov 2025 20:19:28 +0000</pubDate>
      <link>https://dev.to/not_varunkv/the-48-hour-button-fighting-supabase-google-auth-and-scope-creep-1e7n</link>
      <guid>https://dev.to/not_varunkv/the-48-hour-button-fighting-supabase-google-auth-and-scope-creep-1e7n</guid>
      <description>&lt;p&gt;I just spent &lt;em&gt;48 hours&lt;/em&gt; building a feature the user will never notice.&lt;br&gt;
It wasn't a complex algorithm. It wasn't a 3D animation.&lt;/p&gt;

&lt;p&gt;It was a &lt;strong&gt;"Sign in with Google"&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;For the past two days, the Direct Google Sheets integration—my most requested feature—was dead on arrival.&lt;/p&gt;

&lt;p&gt;Here is the technical breakdown of why "simple" integrations are rarely simple, and how I solved the Supabase Scope conflict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Invisible Villain:&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;Supabase vs. Google Scopes&lt;br&gt;
The goal was simple:&lt;/p&gt;

&lt;p&gt;User logs into SpeakSheet.&lt;br&gt;
User clicks "Export to Google Sheets."&lt;br&gt;
My app writes the data to their Drive.&lt;/p&gt;

&lt;p&gt;I am using Supabase for authentication. It handles the login flow perfectly. But when I tried to push data to the Google Sheets API, I got the same error loop:&lt;br&gt;
403: Insufficient Permission&lt;/p&gt;

&lt;p&gt;I debugged for hours. I checked the Google Cloud Console. I checked the API keys. Everything looked green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Realization&lt;/strong&gt;&lt;br&gt;
The issue wasn't the API; it was the Auth Token.&lt;br&gt;
By default, Supabase Auth requests basic scopes: email and profile. It does not request spreadsheets (write access).&lt;/p&gt;

&lt;p&gt;The token I held verified who the user was, but it didn't have permission to touch their files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix: Decoupling the Auth&lt;/strong&gt;&lt;br&gt;
I tried to force the scope into the initial login, but it created a messy user experience (asking for full Drive access just to sign up?).&lt;/p&gt;

&lt;p&gt;I realized I had to change the workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The New Flow:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Login: User signs in via Supabase (Standard Email/Profile scope).&lt;br&gt;
App Access: User builds their prompt and generates the schema.&lt;br&gt;
Integration: When they click "Export to Google Sheets," I trigger a secondary re-authentication.&lt;br&gt;
This specific step asks only for the Sheets permission. It separates "Identity" from "Capability." It’s cleaner, safer, and it finally works.&lt;/p&gt;

&lt;p&gt;From "Text" to "Logic"&lt;br&gt;
Once the gate was open, I had to ensure the payload was correct.&lt;/p&gt;

&lt;p&gt;This is the difference between ChatGPT and SpeakSheet.&lt;br&gt;
If you ask a standard LLM for a "Budget Tracker," it gives you a text explanation.&lt;br&gt;
I need to give you working cells.&lt;/p&gt;

&lt;p&gt;My system prompt enforces a strict JSON schema. It flags specific fields as Formula:&lt;br&gt;
&lt;br&gt;
 &lt;code&gt;{"formulaLabel":"Sum", "formula":"("=SUM(C:C))"}&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;Before: The API treated formulas as text strings.&lt;br&gt;
Now: The API recognizes the = operator and parses logic, including complex IF statements.&lt;/p&gt;

&lt;p&gt;What’s Next? (The boring part)&lt;br&gt;
The engine is running. The integration is live.&lt;br&gt;
Now, I have to build the toll booth.&lt;/p&gt;

&lt;p&gt;I am implementing the Pricing page next.&lt;br&gt;
I have chosen LemonSqueezy as my merchant of record.&lt;/p&gt;

&lt;p&gt;Why? Because Stripe is currently not onboarding new businesses in India.&lt;br&gt;
The Upside: LemonSqueezy handles the tax compliance, which lets me focus on the code.&lt;/p&gt;

&lt;p&gt;The MVP is 90% complete. &lt;br&gt;
Next update: The Mobile Responsiveness fix.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>nextjs</category>
      <category>saas</category>
    </item>
    <item>
      <title>"Is this just a wrapper?" (How a Reddit Comment Changed My Roadmap)</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Sun, 23 Nov 2025 10:46:13 +0000</pubDate>
      <link>https://dev.to/not_varunkv/is-this-just-a-wrapper-how-a-reddit-comment-changed-my-roadmap-1pen</link>
      <guid>https://dev.to/not_varunkv/is-this-just-a-wrapper-how-a-reddit-comment-changed-my-roadmap-1pen</guid>
      <description>&lt;p&gt;I launched the MVP of SpeakSheet on Reddit this week.&lt;/p&gt;

&lt;p&gt;The concept is simple: You type a prompt, and my app generates a structured Excel file using Gemini.&lt;/p&gt;

&lt;p&gt;The post got 2,400 views. Most feedback was standard. But one comment stopped me cold.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmevqpippx23vkx0riuvx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmevqpippx23vkx0riuvx.png" alt="ScreenShot of the post I did" width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is it not just a system prompt for Gemini? It is not a product... users still have to prompt. You could chat with Gemini on Google Sheets all day long and it is free.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My initial reaction was defensive.&lt;br&gt;
I wrote the NextJS frontend. I set up the Supabase backend. I wrote the complex system prompts that enforce schema validation so the JSON doesn't break. Of course it's a product!&lt;/p&gt;

&lt;p&gt;But after the sting faded, I realized he was right.&lt;br&gt;
And he was also wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "Wrapper" Fallacy&lt;/strong&gt;&lt;br&gt;
Developers (including me) obsess over the tech stack. We think "value" means proprietary algorithms.&lt;/p&gt;

&lt;p&gt;But to a user who doesn't know what an API key is, raw technology is useless.&lt;/p&gt;

&lt;p&gt;They don't know how to write a system prompt to enforce column structures.&lt;br&gt;
They don't know how to parse a file upload to extract context.&lt;br&gt;
They don't want to "chat" with a spreadsheet; they want the spreadsheet to exist.&lt;br&gt;
My value isn't the AI model. My value is the packaging.&lt;/p&gt;

&lt;p&gt;SpeakSheet wraps the chaos of an LLM into a predictable, one-click interface. That is the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Pivot: Listening to the Haters&lt;/strong&gt;&lt;br&gt;
The most interesting part of the comment was this line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You could chat with Gemini on Google Sheets all day long."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I realized I had missed a massive use case.&lt;br&gt;
Right now, SpeakSheet generates a downloadable .xlsx file. But many users live in the browser. They don't want to download, open, and upload.&lt;/p&gt;

&lt;p&gt;Because of that "hater," I am shifting my roadmap.&lt;/p&gt;

&lt;p&gt;I am now researching Google Sheets Integration via OAuth 2.0.&lt;br&gt;
The goal: A user types a prompt in SpeakSheet, and it pushes the data directly into their live Google Sheet. No downloads. No file management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building the Integration (The Plan)&lt;/strong&gt;&lt;br&gt;
I haven't built this yet. I am currently studying the Google Workspace API documentation.&lt;br&gt;
The plan is:&lt;/p&gt;

&lt;p&gt;Authenticate the user via OAuth 2.0.&lt;br&gt;
Generate the schema via Gemini (as I do now).&lt;br&gt;
Push the structured data into a new Sheet via the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
If you are building a Micro-SaaS, do not fear the "Wrapper" accusation.&lt;br&gt;
Power users will always say they can do it themselves. They are not your customers.&lt;/p&gt;

&lt;p&gt;Your customers are the people who gladly pay to skip the learning curve.&lt;/p&gt;

&lt;p&gt;And sometimes, your harshest critics give you your best feature ideas.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>devjournal</category>
      <category>discuss</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Code Worked. Excel’s "Protected View" Killed It.</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Sat, 22 Nov 2025 12:07:46 +0000</pubDate>
      <link>https://dev.to/not_varunkv/my-code-worked-excels-protected-view-killed-it-d58</link>
      <guid>https://dev.to/not_varunkv/my-code-worked-excels-protected-view-killed-it-d58</guid>
      <description>&lt;p&gt;I am building a Micro-SaaS called SpeakSheet. The premise is simple: You speak (or type) a prompt, and it generates a structured Excel file.&lt;/p&gt;

&lt;p&gt;The stack is solid: NextJS, Tailwind, Supabase, and Gemini on the backend.&lt;/p&gt;

&lt;p&gt;This week, I tackled the hardest part of the MVP: Formulas.&lt;/p&gt;

&lt;p&gt;Teaching an LLM to understand "Profit Margin" is tricky. It knows the math, but it doesn't know the context. After hours of tweaking the JSON schema and refining the prompt, I finally got a green light in the console.&lt;/p&gt;

&lt;p&gt;The logic was perfect. The schema was validated. I downloaded the generated file to test it.&lt;/p&gt;

&lt;p&gt;Profit Margin: 0.&lt;/p&gt;

&lt;p&gt;I stared at the screen. I felt that specific mix of confusion and anger that only developers know. I checked the backend logs—the calculation was correct. I checked the cell data—the formula was there.&lt;/p&gt;

&lt;p&gt;But the cell displayed 0.&lt;/p&gt;

&lt;p&gt;I spent an hour debugging a bug that didn't exist.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Invisible Villain&lt;/strong&gt;&lt;br&gt;
Then, I looked at the top of the Excel window. A thin yellow bar:&lt;/p&gt;

&lt;p&gt;Protected View: Be careful—files from the Internet can contain viruses.&lt;/p&gt;

&lt;p&gt;Because my software generated the file programmatically, Excel didn't trust it.&lt;/p&gt;

&lt;p&gt;It blocked the execution.&lt;br&gt;
It suppressed the formula results.&lt;br&gt;
It made my product look broken.&lt;br&gt;
This is the reality of building Micro-SaaS. You spend 20% of your time writing logic in NextJS, and 80% of your time fighting edge cases in the tools your customers actually use.&lt;/p&gt;

&lt;p&gt;I realized that for a user with low Excel literacy (my target audience), this is a dealbreaker. They won't click "Enable Editing." They will just churn.&lt;/p&gt;

&lt;p&gt;I had to refactor the user journey to account for a security feature I have no control over.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdo7g1u351nupvwvlrfa4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdo7g1u351nupvwvlrfa4.png" alt="Landing Page of SpeakSheet - An excel helper better that excel." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;The Design Pivot &lt;em&gt;(feat. Gemini 3.0)&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
Once the logic was fixed, I looked at the UI. It was functional, but it looked like a Bootstrap template from 2015.&lt;/p&gt;

&lt;p&gt;I am a developer, not a designer. Usually, this is where I stall.&lt;/p&gt;

&lt;p&gt;I decided to test Gemini 3.0 Flash. I gave it a simple instruction: Design a modern, clean landing page for a SaaS that converts text to Excel.&lt;/p&gt;

&lt;p&gt;I expected the usual generic AI slop.&lt;br&gt;
Instead, it one-shot a layout that actually looked... premium. It understood whitespace. It utilized the ShadCN components correctly.&lt;/p&gt;

&lt;p&gt;It saved me two days of CSS wrestling.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Lesson&lt;/strong&gt;&lt;br&gt;
Building SpeakSheet has taught me that code is only half the product.&lt;/p&gt;

&lt;p&gt;Your logic can be perfect, but if Excel's UI hides it, you failed.&lt;br&gt;
Your backend can be robust, but if the frontend looks cheap, you failed.&lt;br&gt;
I am building this in public to show the messy reality of shipping a product.&lt;/p&gt;




&lt;p&gt;Follow me here for the next update.&lt;br&gt;
&lt;a href="https://x.com/NotVarunKV" rel="noopener noreferrer"&gt;@NotVarunKV&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Watch Demo of the working here:&lt;br&gt;
&lt;a href="https://x.com/NotVarunKV/status/1992189268559864189?s=20" rel="noopener noreferrer"&gt;Demo Working&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>productivity</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
