Published: Apr 22, 2025, 6:53 PM
The schema design and implementation of my blog project.
First, define the enumerated types that will be used in the project to enhance data consistency and readability. Run in Supabase SQL editor:
-- User role types
CREATE TYPE public.user_role AS ENUM ('Admin', 'Plus', 'Normal');
-- Post visibility types
CREATE TYPE public.post_visibility AS ENUM ('Public', 'Plus', 'Admin');
Here are the core database table designs:
Table: profiles
(User Information)
auth.users
, stores public information and application-specific data.Column Name | Data Type | Constraints | Description |
---|---|---|---|
id | uuid | PRIMARY KEY ,REFERENCES auth.users(id) ON DELETE CASCADE | User ID (fromauth.users ) |
username | text | UNIQUE ,NOT NULL | Username (ensure uniqueness) |
email | text | UNIQUE ,NOT NULL | User email (synced fromauth.users ) |
avatar_url | text | User avatar URL (from OAuth or user upload) | |
role | public.user_role | NOT NULL ,DEFAULT 'Normal'::public.user_role | User role (Admin ,Plus ,Normal ) |
created_at | timestamptz | NOT NULL ,DEFAULT now() | Record creation time |
updated_at | timestamptz | NOT NULL ,DEFAULT now() | Record last update time (automatically updated via trigger) |
Table: posts
(Blog Posts)
Column Name | Data Type | Constraints | Description |
---|---|---|---|
id | bigint | PRIMARY KEY ,GENERATED BY DEFAULT AS IDENTITY | Post ID |
title | text | NOT NULL | Post title |
slug | text | UNIQUE ,NOT NULL | URL Slug (generated at application layer and ensure uniqueness) |
content | text | Post content (Markdown/HTML) | |
excerpt | text | Post excerpt/summary | |
cover_image_url | text | Cover image URL (Supabase Storage) | |
published_at | timestamptz | DEFAULT now() | Publication time |
last_modified_at | timestamptz | DEFAULT now() | Last modification time |
visibility | public.post_visibility | NOT NULL ,DEFAULT 'Public'::public.post_visibility | Post visibility (Public ,Plus ,Admin ) |
created_at | timestamptz | NOT NULL ,DEFAULT now() | Record creation time |
updated_at | timestamptz | NOT NULL ,DEFAULT now() | Record last update time (automatically updated via trigger) |
Table: tags
(Tags)
Column Name | Data Type | Constraints | Description |
---|---|---|---|
id | bigint | PRIMARY KEY ,GENERATED BY DEFAULT AS IDENTITY | Tag ID |
name | text | UNIQUE ,NOT NULL | Tag name |
created_at | timestamptz | NOT NULL ,DEFAULT now() | Record creation time |
color | text | Used for rendering tag color in application layer |
Table: post_tags
(Post-Tag Association Table)
Column Name | Data Type | Constraints | Description |
---|---|---|---|
post_id | bigint | REFERENCES posts(id) ON DELETE CASCADE ,NOT NULL | Associated post ID |
tag_id | bigint | REFERENCES tags(id) ON DELETE CASCADE ,NOT NULL | Associated tag ID |
PRIMARY KEY (post_id, tag_id) | Composite primary key, ensures uniqueness |
Table: projects
(Project List)
Column Name | Data Type | Constraints | Description |
---|---|---|---|
id | bigint | PRIMARY KEY ,GENERATED BY DEFAULT AS IDENTITY | Project ID |
name | text | NOT NULL | Project name |
description | text | Project description | |
project_url | text | Project link | |
github_url | text | Project Github | |
cover_image_url | text | Project cover image URL | |
tech_stack | text[] | Project tech stack (array) | |
created_at | timestamptz | NOT NULL ,DEFAULT now() | Record creation time |
updated_at | timestamptz | NOT NULL ,DEFAULT now() | Record last update time (automatically updated via trigger) |
Table: guestbook_entries
(Guestbook)
Column Name | Data Type | Constraints | Description |
---|---|---|---|
id | bigint | PRIMARY KEY ,GENERATED BY DEFAULT AS IDENTITY | Entry ID |
name | text | NOT NULL | Guest name |
email | text | NOT NULL | Guest email |
message | text | NOT NULL | Message content |
is_read | boolean | NOT NULL ,DEFAULT false | Read status (for management) |
created_at | timestamptz | NOT NULL ,DEFAULT now() | Message time |
Table: comments
(Post Comments)
Column Name | Data Type | Constraints | Description |
---|---|---|---|
id | bigint | PRIMARY KEY ,GENERATED BY DEFAULT AS IDENTITY | Comment ID |
post_id | bigint | REFERENCES posts(id) ON DELETE CASCADE ,NOT NULL | Associated post ID |
user_id | uuid | REFERENCES profiles(id) ON DELETE CASCADE ,NOT NULL | User ID who posted the comment |
content | text | NOT NULL | Comment content |
created_at | timestamptz | NOT NULL ,DEFAULT now() | Comment creation time |
updated_at | timestamptz | NOT NULL ,DEFAULT now() | Comment last update time (automatically updated via trigger) |
Use the following SQL functions and triggers to automatically synchronize avatar URLs and basic information from auth.users
to public.profiles
after new user registration (especially through OAuth).
-- 1. Function: Handle new user data synchronization
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
begin
-- Try to extract avatar URL and username/full name from raw_user_meta_data
declare
avatar_url text := new.raw_user_meta_data ->> 'avatar_url';
username text := new.raw_user_meta_data ->> 'user_name'; -- GitHub
full_name text := new.raw_user_meta_data ->> 'full_name'; -- Google
user_email text := new.email; -- Get user's email
final_username text;
begin
-- Prioritize OAuth-provided username/full name, otherwise generate from email
if username is not null and username <> '' then
final_username := username;
elsif full_name is not null and full_name <> '' then
final_username := replace(lower(full_name), ' ', '_'); -- Simple processing
else
final_username := split_part(user_email, '@', 1);
end if;
-- Check username uniqueness, add random suffix if conflict (improve this logic as needed)
if exists (select 1 from public.profiles where profiles.username = final_username) then
final_username := final_username || '_' || substr(md5(random()::text), 1, 4);
end if;
-- Insert new user information into profiles table
insert into public.profiles (id, email, username, avatar_url)
values (
new.id,
user_email,
final_username,
avatar_url
)
on conflict (id) do update set -- Handle potential conflicts
avatar_url = excluded.avatar_url,
email = excluded.email,
username = excluded.username;
end;
return new;
end;
$$;
-- 2. Trigger: Call function after inserting new record in auth.users
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Handle slug generation in the Next.js application layer (Server Action or API Route):
slugify
.posts
records.title
.posts.slug
field.UNIQUE
constraint on posts.slug
column.comments
table stores article comments.post_id
.user_id
(profiles
table).RLS is core to Supabase security. Policies must be enabled and configured for tables requiring protection. Here is the basic approach to policy design:
profiles
table:
SELECT
: Users can read everyone's username
and avatar_url
. Users can read their own complete profile information (including email, role).UPDATE
: Users can only update their own profile information (may restrict certain fields like role
). Admin can update everyone's information.INSERT
/DELETE
: Usually handled by triggers, deletion may be restricted to Admin or triggered through account deletion process.posts
table:
SELECT
:
visibility
= Public
.Plus
or Admin
can read posts with visibility
= Public
or Plus
.Admin
can read all posts (including visibility
= Admin
).INSERT
, UPDATE
, DELETE
: Only allowed for users with role Admin
.comments
table:
SELECT
: Everyone can read all comments.INSERT
: Must be logged-in user (auth.uid()
must have corresponding user_id
in profiles
).UPDATE
: Users can only update their own comments (or disallow updates). Admin can update all comments.DELETE
: Users can only delete their own comments. Admin can delete all comments.guestbook_entries
table:
SELECT
: Only Admin
can read all entries (protect privacy).INSERT
: Allow anyone (non-logged-in users) to insert entries.UPDATE
: Only Admin
can update (is_read
status).DELETE
: Only Admin
can delete.projects
, tags
, post_tags
tables:
SELECT
: Usually allow everyone to read.INSERT
, UPDATE
, DELETE
: Only allow Admin
operations.All image files (avatars, cover images, etc.) should be uploaded to Supabase Storage.
avatars
, post_covers
, project_covers
).timestamptz
(Timestamp with Time Zone) type for storing times.DEFAULT now()
for created_at
.updated_at
(or last_modified_at
) to now()
on UPDATE
operations.Consider adding indices for columns commonly used in query conditions to improve query performance:
profiles.username
posts.slug
tags.name
comments.post_id
, comments.user_id
, post_tags.post_id
, post_tags.tag_id
)updated_at
updates).@supabase/ssr
) in Next.js application to interact with backend, implementing data read/write, user authentication, slug generation, file upload, and other features.When a user signs up via OAuth (e.g., GitHub/Google), Supabase stores some metadata (including the avatar URL) in the raw_user_meta_data
JSONB field of the auth.users
table. We can create a database function and a trigger that automatically looks up this avatar URL after a new user is added to the auth.users
table and updates it in our public profiles
table.
Run the following SQL code in the Supabase SQL Editor (Database -> SQL Editor -> New query):
-- 1. Create a function to insert or update user info in the profiles table
-- This function extracts email, id, and avatar URL from the auth.users table
create or replace function public.handle_new_user()
returns trigger
language plpgsql
-- SECURITY DEFINER allows the function to run with the privileges of the role that created it (usually postgres).
-- This is necessary to read from auth.users and write to public.profiles.
-- SET search_path ensures the function looks for tables in the public schema.
security definer set search_path = public
as $$
begin
-- Attempt to extract the avatar URL from raw_user_meta_data, defaults to NULL if not present
declare
avatar_url text := new.raw_user_meta_data ->> 'avatar_url';
username text := new.raw_user_meta_data ->> 'user_name'; -- GitHub might provide user_name
full_name text := new.raw_user_meta_data ->> 'full_name'; -- Google might provide full_name
final_username text;
begin
-- Try to use the username or full name from metadata, generate one from email if neither exists
if username is not null and username <> '' then
final_username := username;
elsif full_name is not null and full_name <> '' then
-- Simply use the full name as username, or process it (e.g., replace spaces)
final_username := replace(lower(full_name), ' ', '_');
else
-- Generate a basic username from email (remove @ and subsequent parts)
final_username := split_part(new.email, '@', 1);
end if;
-- Check if the generated username already exists, append a random suffix if it does (simple example)
-- You might want a more robust uniqueness strategy
if exists (select 1 from public.profiles where profiles.username = final_username) then
final_username := final_username || '_' || substr(md5(random()::text), 1, 4);
end if;
insert into public.profiles (id, email, username, avatar_url)
values (
new.id,
new.email,
final_username,
avatar_url -- Use the extracted avatar_url from above
)
-- Using ON CONFLICT just in case, though unlikely for an AFTER INSERT trigger on auth.users
-- This ensures if a profile with the ID somehow exists, it gets updated.
on conflict (id) do update set
avatar_url = excluded.avatar_url,
email = excluded.email; -- Optionally update the email as well
end;
return new; -- The result is ignored since it's an AFTER trigger, but required syntax
end;
$$;
-- 2. Create a trigger that calls the above function after each new record is inserted into auth.users
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Note: You might also need to handle cases where users update their avatars,
-- e.g., allowing users to manually upload or change the avatar URL in their profile settings.
-- Syncing avatar updates from the OAuth provider later is more complex,
-- it's generally recommended to let users manage their avatars within your application after the initial signup.
-- (Optional) Function: Update updated_at timestamp
-- This function can be reused across multiple tables
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now(); -- Set the updated_at column to the current timestamp
RETURN NEW; -- Return the modified row
END;
$$ language 'plpgsql';
-- (Optional) Trigger: Automatically update updated_at for the posts table
-- Create this trigger if your 'posts' table has an 'updated_at' column
CREATE TRIGGER handle_posts_updated_at BEFORE UPDATE ON public.posts
FOR EACH ROW EXECUTE PROCEDURE public.update_updated_at_column();
-- (Optional) Trigger: Automatically update updated_at for the profiles table
-- Create this trigger if your 'profiles' table has an 'updated_at' column
CREATE TRIGGER handle_profiles_updated_at BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE PROCEDURE public.update_updated_at_column();
-- (Optional) Trigger: Automatically update updated_at for the projects table
-- Create this trigger if your 'projects' table has an 'updated_at' column
CREATE TRIGGER handle_projects_updated_at BEFORE UPDATE ON public.projects
FOR EACH ROW EXECUTE PROCEDURE public.update_updated_at_column();
-- (Optional) Trigger: Automatically update updated_at for the comments table
-- Create this trigger if your 'comments' table has an 'updated_at' column
CREATE TRIGGER handle_comments_updated_at BEFORE UPDATE ON public.comments
FOR EACH ROW EXECUTE PROCEDURE public.update_updated_at_column();
Important Notes:
security definer
: This allows the function (handle_new_user
) to run with the privileges of the user who created it (typically the postgres
superuser or another role with sufficient permissions). This is necessary for it to read from the auth.users
table (which is in the auth
schema) and write to the public.profiles
table. Use security definer
with caution and ensure the function's internal logic is secure against potential SQL injection if it were to take external parameters (which this specific function does not in a risky way, as it uses NEW
record values).search_path = public
: Ensures the function looks for tables (like profiles
) within the public
schema by default, preventing potential issues if the calling user has a different search path set.updated_at
Triggers: The update_updated_at_column
function and the example triggers are provided as a common pattern. They automatically update an updated_at
timestamp column on a table whenever a row in that table is updated. You can create similar triggers for any tables where you need this functionality, assuming they have an updated_at
column of a timestamp type (like timestamp with time zone
).A slug is typically a URL-friendly version of an article title (e.g., converted to lowercase, with spaces and special characters replaced by hyphens -
). For example, the slug for "My First Blog Post!" might be my-first-blog-post
.
Best practice is to generate the slug in the Next.js application layer:
slugify
or github-slugger
) to handle the conversion.Install the library:
npm install slugify
# or
yarn add slugify
In Server Action or API Route:
import slugify from 'slugify';
import { createClient } from '@/utils/supabase/server'; // Adjust import for your Supabase client (server-side)
async function createPost(formData) {
'use server'; // Necessary directive for Next.js Server Actions
const supabase = createClient(); // Initialize Supabase client for server-side operations
const title = formData.get('title');
const content = formData.get('content');
// ... other fields from your form
if (!title) {
// Handle error: title is required
return { error: 'Title is required.' };
}
let generatedSlug = slugify(title, {
lower: true, // convert to lower case
strict: true, // strip special characters defined by the library except -
remove: /[*+~.()'"!:@]/g, // specify additional characters to remove
trim: true // trim leading/trailing spaces
});
// --- Logic needed here to check if the slug already exists ---
// Important: Always check for slug uniqueness before inserting
let isUnique = false;
let attempt = 0;
const maxAttempts = 5; // Limit attempts to avoid infinite loops
let finalSlug = generatedSlug;
while (!isUnique && attempt <= maxAttempts) {
const { data: existingPost, error: checkError } = await supabase
.from('posts') // Replace 'posts' with your actual table name
.select('id')
.eq('slug', finalSlug)
.maybeSingle(); // Check if any post already has this slug
if (checkError) {
console.error('Error checking slug uniqueness:', checkError);
return { error: 'Failed to check slug uniqueness.' }; // Handle database error
}
if (!existingPost) {
isUnique = true; // Slug is unique
} else {
// Handle duplicate slug: append a counter or random suffix
attempt++;
if (attempt > maxAttempts) {
// If still not unique after several attempts, return error or use a different strategy
console.error('Failed to generate a unique slug for:', title);
return { error: 'Could not generate a unique slug.' };
}
// Append a short random string or a counter
finalSlug = `${generatedSlug}-${Math.random().toString(36).substring(2, 6)}`;
}
}
if (!isUnique) {
// Should not happen if maxAttempts logic is correct, but as a safeguard
return { error: 'Failed to ensure slug uniqueness after multiple attempts.' };
}
// --- End of check logic ---
// Then insert the finalSlug along with other data into the database
const { data, error } = await supabase
.from('posts') // Replace 'posts' with your actual table name
.insert([{
title: title,
content: content,
slug: finalSlug, // Use the generated and validated unique slug
// user_id: ... // Make sure to include the user ID, etc.
// ... other fields
}])
.select() // Optionally select the inserted data
.single(); // Assuming you insert one post
if (error) {
console.error('Error creating post:', error);
// Handle specific Supabase errors if needed
return { error: `Failed to create post: ${error.message}` };
} else {
// Handle success, maybe revalidate paths for ISR/SSR or redirect
// Example: revalidatePath('/blog');
// Example: redirect(`/blog/${finalSlug}`);
console.log('Post created successfully:', data);
return { success: true, post: data };
}
}
UNIQUE
constraint to the slug
column in your posts
(or equivalent) table in the database. This provides a final guarantee of uniqueness at the database level.