Learn how to model relationships and use authentication with InstantDB

# Clone repo
git clone https://github.com/instantdb/instant-examples
# Navigate into the microblog example
cd instant-examples/microblog
# Install dependencies
pnpm i
pnpx instant-cli login
pnpx instant-cli init
instant.schema.ts that you can push to your app.
You may have already pushed this during init in the previous step. If you
answered 'no' to the prompt during init, or if you're unsure whether you pushed
the schema, you can push it now.pnpx instant-cli push
pnpm run seed
pnpm run dev
entities: {
// ...
$users: i.entity({
email: i.string().unique().indexed().optional(),
imageURL: i.string().optional(),
type: i.string().optional(),
}),
profiles: i.entity({
displayName: i.string(),
handle: i.string().unique().indexed(), // unique username
}),
posts: i.entity({
color: i.string(),
content: i.string(),
timestamp: i.number(),
}),
likes: i.entity({
userId: i.string().indexed(),
postId: i.string().indexed(),
})
}
links: {
userProfiles: {
forward: {
on: "profiles",
has: "one",
label: "user",
onDelete: "cascade",
},
reverse: {
on: "$users",
has: "one",
label: "profile",
}
},
userLikes: {
forward: {
on: "likes",
has: "one",
label: "user",
onDelete: "cascade",
},
reverse: {
on: "profiles",
has: "many",
label: "likes",
},
},
postAuthors: {
forward: {
on: "posts",
has: "one",
label: "author",
onDelete: "cascade",
},
reverse: {
on: "profiles",
has: "many",
label: "posts",
},
},
postLikes: {
forward: {
on: "likes",
has: "one",
label: "post",
onDelete: "cascade",
},
reverse: {
on: "posts",
has: "many",
label: "likes",
},
},
}
cascade delete behavior for our links between users and their
profiles, and then between profiles and posts/likes. This means that when a user
is deleted, their profile, posts, and likes will also be automatically deleted.profiles entity instead of just
adding profile fields directly to the $users entity. This is a common pattern
that allows us to separate sensitive user data (like email) from public profile
data (like display name and handle). You can learn more about this in our
managing users docs.type Post = InstaQLEntity<AppSchema, 'posts', { author: {}; likes: {} }>;
const { isLoading, error, data } = db.useQuery({
posts: {
// `serverCreatedAt` is a built-in field available for ordering
$: { order: { serverCreatedAt: 'desc' } },
author: {},
likes: {},
},
});
Post type defined above like so:type Post = {
id: string;
color: string;
content: string;
timestamp: number;
author:
| {
id: string;
displayName: string;
handle: string;
}
| undefined;
likes: {
id: string;
postId: string;
userId: string;
}[];
};
JOIN statements to fetch the
author and likes for each post.SELECT p.*, pp.author, pl.likes
FROM posts p
JOIN (
SELECT p.id,
json_build_object(
'id', pr.id,
'displayName', pr.display_name,
'handle', pr.handle
) as author
FROM posts p
LEFT JOIN profiles pr on p.author_id = pr.id
GROUP BY 1
) pp on p.id = pp.id
JOIN (
SELECT p.id, json_agg(l.*) as likes
FROM posts p
LEFT JOIN likes l on p.id = l.post_id
GROUP BY 1
) pl on p.id = pl.id
ORDER BY p.server_created_at DESC;
db.transact along with our db.tx operations. What's new here is the usage of
the link method to associate entities together.// We use `link` to associate the post with its author
function createPost(content: string, color: string, authorProfileId: string) {
db.transact(
db.tx.posts[id()]
.create({
content: content.trim(),
timestamp: Date.now(),
color,
})
.link({ author: authorProfileId }),
);
}
// Deleting a post will also clean up the link to its author automatically
// Additionally, any likes associated with the post will also be deleted
// because of the `cascade` delete behavior we set up in the schema
function deletePost(postId: string) {
db.transact(db.tx.posts[postId].delete());
}
// `link` can be used to create multiple associations at once
function createLike(userId: string, postId: string) {
db.transact(
db.tx.likes[id()]
.create({ userId, postId })
.link({ post: postId, user: userId }),
);
}
// Deleting a like will also clean up the links to its user and post
// However, the user and post themselves will remain intact since we did not
// set up cascade delete behavior for those relationships (which is what we
// want, since deleting a like should not delete the user or post)
function deleteLike(likeId: string) {
db.transact(db.tx.likes[likeId].delete());
}
import { init } from '@instantdb/admin';
import schema from '@/instant.schema';
import dotenv from 'dotenv';
dotenv.config();
// adminToken is required for admin SDK connections
// Be sure to keep this token secret and never use the adminDB on the client
// since it allows full access to your database
export const adminDb = init({
appId: process.env.NEXT_PUBLIC_INSTANT_APP_ID!,
adminToken: process.env.INSTANT_APP_ADMIN_TOKEN!,
schema,
});
adminDb to bootstrap our database with some initial data.interface Post {
id: number;
author: string;
handle: string;
color: string;
content: string;
timestamp: string;
likes: number;
liked: boolean;
}
const mockPosts: Post[] = [
{
id: 1,
author: 'Sarah Chen',
handle: 'sarahchen',
color: 'bg-blue-100',
content:
'Just launched my new project! Really excited to share it with everyone.',
timestamp: '2h ago',
likes: 12,
liked: false,
},
{
id: 2,
author: 'Alex Rivera',
handle: 'alexrivera',
color: 'bg-purple-100',
content: 'Beautiful sunset today. Nature never stops amazing me.',
timestamp: '4h ago',
likes: 19,
liked: true,
},
{
id: 3,
author: 'Jordan Lee',
handle: 'jordanlee',
color: 'bg-pink-100',
content:
'Working on something cool with Next.js and TypeScript. Updates coming soon!',
timestamp: '6h ago',
likes: 7,
liked: false,
},
];
function friendlyTimeToTimestamp(friendlyTime: string) {
const hours = parseInt(friendlyTime);
const now = Date.now();
return now - hours * 60 * 60 * 1000;
}
function seed() {
console.log('Seeding db...');
mockPosts.forEach((post) => {
// generate unique IDs for user and post
const userId = id();
const postId = id();
// Create user and a profile linked to the user
// Notice how we use the same userId for both the user and profile
// This will be useful later when we want to restrict deleting posts/likes
// to the owner of the profile
const user = adminDb.tx.$users[userId].create({});
const profile = adminDb.tx.profiles[userId]
.create({
displayName: post.author,
handle: post.handle,
})
.link({ user: userId });
const postEntity = adminDb.tx.posts[postId]
.create({
color: post.color,
content: post.content,
timestamp: friendlyTimeToTimestamp(post.timestamp),
})
.link({ author: userId });
// Create multiple likes for the posts based on the count in the mock data
const likes = Array.from({ length: post.likes }, () =>
adminDb.tx.likes[id()]
.create({ postId })
.link({ post: postId, user: userId }),
);
// Create post along with its user, profile, and likes in a single
// transaction
adminDb.transact([user, profile, postEntity, ...likes]);
});
}
async function reset() {
console.log('Resetting database...');
// Deleting all users will cascade delete all related data (posts, likes,
// etc.)
const { $users } = await adminDb.query({ $users: {} });
adminDb.transact($users.map((user) => adminDb.tx.$users[user.id].delete()));
}
db.useAuth hook:const { user, isLoading: userLoading, error: userError } = db.useAuth();
user is defined, then the user is logged in. Otherwise they are logged out.
When they're logged out we'll present them with a login flow:function Login() {
const [sentEmail, setSentEmail] = useState('');
return (
<div className="flex items-center justify-center">
<div className="max-w-sm">
{!sentEmail ? (
<EmailStep onSendEmail={setSentEmail} />
) : (
<CodeStep sentEmail={sentEmail} />
)}
</div>
</div>
);
}
// Most of EmailStep is just form handling UI code, but the important part is
// where we call `db.auth.sendMagicCode` to send the code to the user's email
db.auth.sendMagicCode({ email });
// Similarly in CodeStep the important part is where we verify the code with
// provided email. If this is successful the user will be logged in.
db.auth.signInWithMagicCode({ email: sentEmail, code });
function useProfile(userId: string | undefined) {
const { data, isLoading, error } = db.useQuery(
userId
? {
profiles: {
$: { where: { 'user.id': userId } },
},
}
: null,
);
const profile = data?.profiles?.[0];
return { profile, isLoading, error };
}
useProfile that takes in a userId and queries for
the profile associated with that user
via a where clause.isLoading to be true until we have a valid
userId (no reason to fetch a profile if someone is not logged in).
You can learn more about deferred queries in
our docs.SetupProfile component. On
submission we try to create the profile and show a helpful error message if the
username is already taken.// Transacts are async and optimistic by default, but we can `await` them for
// blocking behavior and error handling
async function createProfile(
userId: string,
displayName: string,
handle: string,
) {
await db.transact(
db.tx.profiles[userId]
.create({
displayName: displayName.trim(),
handle: handle.trim().toLowerCase(),
})
.link({ user: userId }),
);
}
const handleCreateProfile = async (displayName: string, handle: string) => {
try {
await createProfile(currentUserId, displayName, handle);
} catch (error: any) {
// Handle unique constraint violation for handle
if (error?.body?.type === 'record-not-unique') {
alert('Handle already taken, please choose another one.');
return;
}
alert('Error creating profile: ' + error.message);
}
};