Full Stack Development:
Type-Safe Cross Platform Applications
LFI Klub
look2023
Let's get to know each other!
Agenda
- 8:30 - 9:00 - Coffee (30min)
- 9:00 - 11:00 - Opening & first session (2h)
- 11:00 - 11:30 - Coffee & sandwiches (30min)
- 11:30 - 13:30 - Second session (2h)
- 13:30 - 14:30 - Lunch (1h)
- 14:30 - 16:30 - Third session (2h)
- 16:30 - 16:45 - Coffee (15min)
- 16:45 - 18:00- Forth session (1:15h)
- 18:00 - 18:15 - Closing (15min)
How we work
- Dedicated branches for each workshop stage
- Treat everyone as a team - collaboration
- Ask questions on loud so everyone can benefit from it
- Work sessions crossed with small presentations
https://slides.com/czystyl/code
What does Full Stack mean to You?
Why Full Stack?
Tooling
- Next.js
- Expo
- Turborepo
- tRPC & zod
- Tailwind & shadcn/ui
- Drizzle ORM
- Clerk
The Bank App 🏦
DEMO
github.com/czystyl/the-bank
# SESSION 1
Environment Setup - branch stage-1
-
Clone the repository
-
Push repo to your Github
-
Run Web
-
Run Mobile
github.com/czystyl/the-bank
Continuous Integration
Github Action
name: CI
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
build-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
- name: Setup Node 18
uses: actions/setup-node@v3
with:
node-version: 18
- name: Get pnpm store directory
id: pnpm-cache
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install deps (with cache)
run: # install deps
Vercel Deployment
# SESSION 1
Styling
export default function HomePage() {
return (
<main
className="flex h-screen flex-1 items-center justify-center bg-gray-800"
>
<h1 className="text-7xl text-gray-300">
👋 Hello!
</h1>
</main>
);
}
Styling with Tailwind CS
# SESSION 1
https://tailwindcss.com
Component library for WEB
# SESSION 1
https://ui.shadcn.com
function PageReact() {
return (
<div>
<h1>👋 Hello!</h1>
{users.map((user) => (
<div key={user.id}>
<span>{user.name}</span>
</div>
))}
<button onClick={() => {}}>
<span>Click me!</span>
</button>
</div>
);
}
From React to Native
# SESSION 1
import { View } from 'react-native';
function ScreenReactNative() {
return (
<View>
<h1>👋 Hello!</h1>
{users.map((user) => (
<View key={user.id}>
<span>{user.name}</span>
</View>
))}
<button onClick={() => {}}>
<span>Click me!</span>
</button>
</View>
);
}
From React to Native
# SESSION 1
import { View, Text } from 'react-native';
function ScreenReactNative() {
return (
<View>
<Text>👋 Hello!</Text>
{users.map((user) => (
<View key={user.id}>
<Text>{user.name}</Text>
</View>
))}
<button onClick={() => {}}>
<Text>Click me!</Text>
</button>
</View>
);
}
From React to Native
# SESSION 1
import { View, Text, Pressable } from 'react-native';
function ScreenReactNative() {
return (
<View>
<Text>👋 Hello!</Text>
{users.map((user) => (
<View key={user.id}>
<Text>{user.name}</Text>
</View>
))}
<Pressable onPress={() => {}}>
<Text>Click me!</Text>
</Pressable>
</View>
);
}
From React to Native
# SESSION 1
export default function HomeScreen() {
return (
<View styles={styles.container}>
<Text styles={styles.title}>👋 Hello!</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
title: {
fontSize: 32,
color: "lightgray",
},
});
From React to Native
# SESSION 1
export default function HomeScreen() {
return (
<View className="flex flex-1 items-center justify-center">
<Text className="text-6xl text-gray-300">👋 Hello!</Text>
</View>
);
}
From React to Native
* Not all styles are supported, particularly animated one
# SESSION 1
import { Text } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
return (
<SafeAreaView
className="flex flex-1 items-center justify-center px-4"
edges={["top"]}
>
<Text
className="text-6xl text-gray-300"
>
👋 Hello!
</Text>
</SafeAreaView>
);
}
Styling Mobile App with NativeWind
# SESSION 1
nativewind.dev
apps/web/src/app/page.tsx
apps/web/src/app/page.tsx apps/web/src/app/dashboard/page.tsx *apps/web/src/app/dashboard/layout.tsx
apps/mobile/src/app/(auth)/onboarding.tsx
apps/mobile/src/app/(auth)/sign-in.tsx
apps/mobile/src/app/(home)/index.tsx
Coffee break
👮 Authentication
🧭 Navigation
💾 Database
Clerk
Authentication and User Management
for mobile and web
Auth flow
Shared workspace packages
add @the-bank/core
Pages Router? App Router?
Dashboard Page
├─ User details Page
└─ Transfer Details Page
Expo Router v2
Home Tab
└─ Transfer Screen (index)
Transfer Tab
├─ Transfer List Screen
└─ Transfer Details Screen
Expo development build
Database 💾
using Drizzle ORM
Docker & Managed DB
@the-bank/env
export const env = createEnv({
server: {
NODE_ENV: z.union([
z.literal("development"), z.literal("production"),
]).default("development"),
CLERK_SECRET_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
},
});
Schema Declaration
import { int, mysqlEnum, mysqlTable, bigint, varchar } from 'drizzle-orm/mysql-core';
export const countries = mysqlTable('countries', {
id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
name: varchar('name', { length: 256 }),
});
export const cities = mysqlTable('cities', {
id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
name: varchar('name', { length: 256 }),
countryId: int('country_id').references(() => countries.id),
popularity: mysqlEnum('popularity', ['unknown', 'known', 'popular']),
});
Select in Drizzle
const result = await db
.select()
.from(users)
.leftJoin(
transactions,
eq(
transactions.recipientUserId,
users.clerkId
)
)
.orderBy(desc(transactions.id))
.where(
eq(transactions.type, "INCOME")
);
{
users: {
id: 56,
clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
userName: 'Luke',
},
transactions: {
id: 3256,
title: 'Add founds',
value: 1,
type: 'INCOME',
senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
}
},
{
users: {
id: 56,
clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
userName: 'Luke',
},
transactions: {
id: 3255,
title: 'Add founds',
value: 1,
type: 'INCOME',
senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
}
},
{
users: {
id: 56,
clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
userName: 'Luke',
},
transactions: {
id: 3254,
title: 'Add founds',
value: 1,
type: 'INCOME',
senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
}
},
{
users: {
id: 56,
clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
userName: 'Luke',
},
transactions: {
id: 3253,
title: 'Add founds',
value: 1,
type: 'INCOME',
senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
}
},
{
users: {
id: 56,
clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
userName: 'Luke',
},
transactions: {
id: 3252,
title: 'Add founds',
value: 999,
type: 'INCOME',
senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
}
},
Query in Drizzle
[{
id: 10,
name: "Dan",
transfert: [
{
id: 1,
content: "SQL is awesome",
authorId: 10,
},
{
id: 2,
content: "But check relational queries",
authorId: 10,
}
]
}]
const result = await db
.query
.users
.findMany({
with: {
transactions: true
}
});
Query in Drizzle (schema)
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name'),
});
export const usersRelations = relations(users, ({ one }) => ({
profileInfo: one(profileInfo, {
fields: [users.id],
references: [profileInfo.userId],
}),
}));
export const profileInfo = pgTable('profile_info', {
id: serial('id').primaryKey(),
userId: integer("user_id").references(() => users.id),
metadata: jsonb("metadata"),
});
Drizzle ORM
https://orm.drizzle.team/docs/overview
-
packages/db/src/user.ts
-
insertUser()
-
getUser()
-
getUsers()
-
-
packages/db/src/transaction.ts
- getAccountBalance()
- addFounds()
-
@the-bank/scripts
- Sync Clerk Users
- Seed Fake Users
- Seed Fake Transactions
pnpm script:dev
Drizzle Kit
https://orm.drizzle.team/kit-docs/overview
Drizzle Studio
https://orm.drizzle.team/drizzle-studio/overview
Lunch break
Let's connect DB and API
Application Programming Interface
REST API SOAP API RPC GraphQL
https://trpc.io/
github.com/iway1/trpc-panel
Let's glue the API with Database!
Data Fetching Expo
export default function RootLayout() {
return (
<ClerkProvider>
...
</ClerkProvider>
);
}
tRPC Provider Expo & Next.js
export default function RootLayout() {
return (
<ClerkProvider>
<TRPCProvider>
...
</TRPCProvider>
</ClerkProvider>
);
}
tRPC Provider Expo & Next.js
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson,
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
}),
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</api.Provider>
);
}
tRPC Provider Next.js
export function TRPCProvider(props: { children: ReactNode }) {
const { getToken } = useAuth();
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
async headers() {
const authToken = await getToken();
return {
Authorization: authToken ?? undefined,
};
},
}),
],
}),
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</api.Provider>
);
}
tRPC Provider Expo
tRCP Client with Expo
== Tanstack Query
import { api } from "~/lib/api";
export default function HomeScreen() {
const apiUtils = api.useContext();
const { data, isLoading } = api.user.balance.useQuery();
const { mutate } = api.transaction.addFounds.useMutation({
onSettled: () => {
void apiUtils.user.balance.invalidate();
void apiUtils.transaction.all.refetch();
},
});
return (
<Pressable
onPress={() => {
mutate({ value: Math.random() });
}}
/>
);
}
Router Input & Output type
type Balance = RouterOutputs["user"]["balance"];
// ^? type Balance = number
type CheatInput = RouterInputs["transaction"]["addFounds"];
// ^? type CheatInput = { value: number }
Scrollview & FlatList
<ScrollView className="pt-20">
{dummyTransactions.map(({ transaction, sender, recipient }) => {
return (
<TransactionItem
key={transaction.uuid}
transaction={transaction}
sender={sender}
recipient={recipient}
/>
);
})}
</ScrollView>
Scroll View vs FlatList
<FlatList
className="pt-20"
data={data}
keyExtractor={(item) => item.transaction.uuid}
renderItem={({ item }) => {
return (
<TransactionItem
transaction={item.transaction}
sender={item.sender}
recipient={item.recipient}
/>
);
}}
/>
Scroll View vs FlatList
const { data, isLoading, isFetching, refetch } =
api.transaction.all.useQuery();
<FlatList
className="pt-20"
data={data}
refreshing={!data && !isLoading && isFetching}
onRefresh={refetch}
keyExtractor={(item) => item.transaction.uuid}
renderItem={({ item }) => {
...
}}
/>
Flat List RefreshControl
Shared Transition
export default function TransactionItem({
transaction,
sender,
}: TransactionResult) {
const uniqueTag =
transaction.uuid + sender.clerkId + transaction.type;
return (
<Pressable
onPress={() => {
router.push({
pathname: `/(home)/transactions/${transaction.uuid}`,
});
}}
>
<Animated.Image
source={{ uri: sender?.imageUrl }}
style={styles.avatar}
sharedTransitionTag={uniqueTag}
/>
</View>
);
}
Animate from
export default function TransactionScreen() {
...
const uniqueTag =
transaction.uuid + sender.clerkId + transaction.type;
return (
<Animated.Image
source={{ uri: sender?.imageUrl }}
style={styles.avatar}
sharedTransitionTag={senderTransitionTag}
/>
);
}
Animate to
Data Fetching Web
export default function UsersPage({ params }: UserPageProps) {
const { data, isLoading, error } = api.admin.getUser.useQuery({
clerkId: params.id,
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error!</div>;
}
return <User user={data} />;
}
Client Side Fetching
export default async function UsersPage({ params }: UserPageProps) {
const user = await serverAPIClient().admin.getUser({
clerkId: params.id
});
return <User user={data} />;
}
Server Components
What about Error and Loading state?
import { auth } from "@clerk/nextjs";
import { appRouter } from "@the-bank/api";
export function serverAPIClient() {
const authSession = auth();
return appRouter.createCaller({
auth: authSession,
});
}
// error.ts
export default function UserErrorPage() {
return (
<h1>User Not Found!</h1>
);
}
// loading.ts
import { Skeleton } from "~/components/ui/skeleton";
export default function UsersPage() {
return (
<Skeleton className="mx-auto mt-3 h-[14px] w-full rounded-full" />
);
}
Error and Loading Components
Animations
import { View } from 'react-native';
export default function App() {
return (
<View
style={{
width: 100,
height: 100,
backgroundColor: 'green',
}}
/>
);
}
Regular View
import Animated from 'react-native-reanimated';
export default function App() {
return (
<Animated.View
style={{
width: 100,
height: 100,
backgroundColor: 'green',
}}
/>
);
}
Animated View
import Animated, { useSharedValue } from 'react-native-reanimated';
export default function App() {
const width = useSharedValue(100);
return (
<Animated.View
style={{
width,
height: 100,
backgroundColor: 'green',
}}
/>
);
}
Shared Value
import Animated, { useSharedValue } from "react-native-reanimated";
export default function App() {
const width = useSharedValue(100);
return (
<View>
<Animated.View
style={{
width,
height: 100,
backgroundColor: "green",
}}
/>
<Button
onPress={() => {
width.value = width.value + 50;
}}
title="Click me"
/>
</View>
);
}
Mutate shared value
import Animated, { useSharedValue, withSpring } from 'react-native-reanimated';
export default function App() {
const width = useSharedValue(100);
return (
<View>
<Animated.View
style={{
width,
height: 100,
backgroundColor: "green",
}}
/>
<Button
onPress={() => {
width.value = withSpring(width.value + 50);
}}
title="Click me"
/>
</View>
);
}
Mutated with animation
https://docs.swmansion.com/react-native-reanimated/
Real Time
using Pusher
Trigger event
addFounds: protectedProcedure
.input(z.object({ value: z.number().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
await addFounds(ctx.auth.userId, input.value);
await PusherServer.trigger(
channels.mainChannel.name,
channels.mainChannel.events.addFounds,
{
value: input.value,
clerkUserId: ctx.auth.userId,
},
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error creating transaction!",
});
}
}),
Handle event
export const PusherClient = new Pusher(env.NEXT_PUBLIC_PUSHER_KEY, {
cluster: "eu",
});
export function usePusherUpdates() {
const apiUtils = api.useContext();
const { toast } = useToast();
useEffect(() => {
async function handler(data: AddFoundEvent) {
toast({
title: "A new transaction has been made",
description: `Someone add ${formatCurrencyValue(
data.value,
)} to their account`,
});
await apiUtils.admin.invalidate();
}
const updateChannel = PusherClient.subscribe(channels.mainChannel.name);
updateChannel.bind(channels.mainChannel.events.addFounds, handler);
return () => {
PusherClient.unsubscribe(channels.mainChannel.name);
updateChannel.unbind(channels.mainChannel.events.addFounds, handler);
};
}, [toast, apiUtils.admin]);
Handle event
export const PusherMobile = Pusher.getInstance();
if (!process.env.EXPO_PUBLIC_PUSHER_KEY) {
throw new Error("Missing PUSKER_KEY env variable");
}
PusherMobile.init({
apiKey: process.env.EXPO_PUBLIC_PUSHER_KEY,
cluster: "eu",
});
export function usePusherUpdates() {
const { user } = useAuth();
const apiUtils = api.useContext();
useEffect(() => {
async function events() {
try {
await PusherMobile.connect();
await PusherMobile.subscribe({
channelName: channels.mainChannel.name,
onEvent: (event: PusherEvent) => {
if (event.eventName === channels.mainChannel.events.addFounds) {
const data = JSON.parse(event.data as string);
const parsedData = AddFoundEventSchema.parse(data);
// Handle event
}
if (
event.eventName === channels.mainChannel.events.newTransaction
) {
const data = JSON.parse(event.data as string);
const parsedData = NewTransactionEventSchema.parse(data);
// Handle event
}
},
});
} catch (error) {
console.log(error);
}
}
void events();
return () => {
void PusherMobile.unsubscribe({ channelName: channels.mainChannel.name });
void PusherMobile.disconnect();
};
}, [apiUtils.user.balance, user?.id]);
}
Monitoring
Sentry & Axiom
Full Stack Workshop
By czystyl
Full Stack Workshop
- 133