How to Use TypeScript for Full-Stack Web Development
A practical guide to using TypeScript across a full-stack app — covering type safety, backend methods, database models, and API contracts.
Why TypeScript Makes Full-Stack Development Less Painful
Most of the frustrating bugs in a web app don’t come from clever algorithmic errors. They come from a function expecting a string getting an undefined, or an API response shape that changed on the backend but nobody told the frontend. TypeScript fixes this category of bugs almost entirely — and when you use it across your whole stack, the benefit compounds.
This guide covers how to use TypeScript for full-stack web development in practice: project structure, shared types, backend methods, database models, API contracts, authentication, and deployment. Whether you’re building a SaaS product, an internal tool, or something more ambitious, the patterns here apply.
What “Full-Stack TypeScript” Actually Means
Full-stack TypeScript means using TypeScript on both the server and the client, and — critically — sharing type definitions between them. You write types once and reference them in both places.
The alternative is the status quo most teams end up with: a backend that returns JSON and a frontend that guesses what shape that JSON will be. When they diverge, the app breaks at runtime and you find out from a user.
With TypeScript across the stack, you find out at compile time. The compiler tells you the contract was broken before you ship.
This isn’t just theoretical. TypeScript is now the default choice for serious full-stack work, and that’s reflected in how the ecosystem has moved: frameworks like Next.js, Remix, tRPC, and Prisma are all built with first-class TypeScript support. The tooling has caught up to the idea.
Setting Up Your Project Structure
A clean full-stack TypeScript project starts with a monorepo or a carefully separated directory structure. Two common approaches:
Monorepo with Shared Packages
/apps
/web ← React or Next.js frontend
/server ← Express or Fastify backend
/packages
/types ← Shared TypeScript types
/utils ← Shared utility functions
This structure lets your /packages/types module be imported in both apps/web and apps/server. A type change in one place propagates everywhere.
Tools like Turborepo and pnpm workspaces manage this well. Both are stable and widely used.
Single Next.js App
If you’re building with Next.js, you already have a full-stack TypeScript setup out of the box. API routes or server actions run on Node.js and share the same codebase as your frontend components. For many projects — especially early-stage ones — this is the simplest and most practical structure.
Prerequisites
Before going further, make sure you have:
- Node.js 18+
- A package manager (npm, pnpm, or yarn)
- TypeScript 5+ installed
- A code editor with TypeScript support (VS Code has excellent built-in support)
Defining Shared Types
This is the highest-leverage thing you can do in a full-stack TypeScript project. Define your core domain types once in a shared location, then import them wherever needed.
Start with Your Data Models
// packages/types/src/user.ts
export interface User {
id: string;
email: string;
name: string;
role: "admin" | "member" | "viewer";
createdAt: Date;
}
export type CreateUserInput = Omit<User, "id" | "createdAt">;
export type UpdateUserInput = Partial<Pick<User, "name" | "role">>;
The User type is shared. The backend uses it when fetching from the database. The frontend uses it when rendering user data. If you rename a field — say, changing name to displayName — both sides break at compile time, not at runtime.
Define API Response Shapes
// packages/types/src/api.ts
export interface ApiResponse<T> {
data: T;
error: null;
}
export interface ApiError {
data: null;
error: {
message: string;
code: string;
};
}
export type ApiResult<T> = ApiResponse<T> | ApiError;
This pattern gives you a consistent response envelope across every endpoint. Your frontend knows exactly what to expect.
Building the Backend with TypeScript
The most common backend setup for full-stack TypeScript is Node.js with Express or Fastify. Both work well. Fastify has better TypeScript support by default; Express has more middleware and a larger ecosystem.
Express with TypeScript
npm install express
npm install -D @types/express typescript ts-node
A typed route handler looks like this:
import { Request, Response } from "express";
import { User, CreateUserInput } from "@yourapp/types";
app.post("/users", async (req: Request<{}, User, CreateUserInput>, res: Response<User>) => {
const input = req.body; // typed as CreateUserInput
const user = await createUser(input);
res.json(user); // must match User shape
});
The request body is typed, the response is typed, and the compiler will complain if they don’t match your shared definitions.
Consider tRPC for End-to-End Type Safety
If you want to eliminate the API contract entirely and just call backend functions from your frontend with full type inference, tRPC is worth serious consideration.
tRPC lets you define typed procedures on the server and call them from the client without any code generation step. The types flow automatically.
// server
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.users.findById(input.id); // returns User
}),
});
// client
const user = await trpc.getUser.query({ id: "123" }); // typed as User
This is genuinely excellent for internal tools, dashboards, and early-stage products where the frontend and backend are controlled by the same team. If you’re building a dashboard app with a tight feedback loop, tRPC removes a whole class of integration errors.
Database Models and TypeScript
The database layer is where type safety often breaks down. Raw SQL queries return any. ORMs can lose type information at runtime. Choosing the right tool matters.
Prisma
Prisma is the most popular TypeScript-first ORM and for good reason. You define your schema in a .prisma file, run a codegen step, and get a fully typed client.
// schema.prisma
model User {
id String @id @default(uuid())
email String @unique
name String
role Role @default(MEMBER)
createdAt DateTime @default(now())
}
enum Role {
ADMIN
MEMBER
VIEWER
}
After running prisma generate, your Prisma client is fully typed:
const user = await prisma.user.findUnique({ where: { id } });
// user is typed as User | null — no guessing
Prisma works well with PostgreSQL, MySQL, SQLite, and more. If you’re figuring out which database to put behind it, the comparison between Supabase and PlanetScale is a practical starting point for managed options.
Drizzle ORM
Drizzle is a newer alternative that stays closer to SQL while still providing TypeScript types. It’s lighter than Prisma and popular with developers who want more control over their queries.
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
Drizzle infers types directly from your table definitions. No separate codegen step required.
Raw SQL with Kysely
If you prefer writing SQL directly but still want type safety, Kysely is a query builder that provides TypeScript-typed queries over your own schema definitions. It’s a good middle ground.
For most teams starting a new project, Prisma is the most practical choice. It has the best documentation, the largest community, and solid migration tooling. When you’re setting up a managed database for your web app, Prisma handles the application side cleanly.
Enforcing API Contracts
Type safety inside a codebase is useful. Type safety at the API boundary is critical.
The problem with REST APIs is that the request and response shapes live in your TypeScript types but nothing enforces them at runtime. You can have perfectly typed code that still breaks because an incoming request has the wrong shape.
Runtime Validation with Zod
Zod solves this. It’s a schema library that validates data at runtime and infers TypeScript types from the same schema definition.
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(["admin", "member", "viewer"]),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// same as your manual type definition, but now validated at runtime
Use the schema on your API handler to validate incoming data before touching your database:
app.post("/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() });
}
const user = await createUser(result.data);
res.json(user);
});
Now you have a single source of truth for the shape of the input: the Zod schema. TypeScript infers from it, and runtime validation uses it. They can’t diverge.
OpenAPI for External APIs
If your API is consumed by external clients or third parties, generating an OpenAPI spec from your TypeScript types is the right move. Libraries like zod-to-openapi and @anatine/zod-openapi let you annotate your Zod schemas and export a spec automatically.
This gives you documented, contract-tested endpoints without maintaining a separate spec file.
Authentication in a TypeScript Full-Stack App
Auth is one area where type safety matters a lot but is often done sloppily. You want your session data, user claims, and access control checks to all be typed.
Session and JWT Types
If you’re using JWTs, define what your token payload looks like:
export interface JwtPayload {
sub: string; // user ID
email: string;
role: "admin" | "member" | "viewer";
iat: number;
exp: number;
}
When you decode a token, assert the type rather than leaving it as any:
import jwt from "jsonwebtoken";
function verifyToken(token: string): JwtPayload {
const payload = jwt.verify(token, process.env.JWT_SECRET!);
return payload as JwtPayload;
}
You can go further and use Zod to validate the payload shape at runtime, which is the safer approach if you can’t fully trust the token source.
Extending Express Request Types
Express’s Request type doesn’t know about your user by default. Augment it:
// types/express.d.ts
declare namespace Express {
interface Request {
user?: JwtPayload;
}
}
Now req.user is typed everywhere in your Express app. Middleware that sets req.user and handlers that read it all work off the same type.
For a broader look at how to implement auth properly, the developer’s guide to adding authentication covers the full flow including sessions, tokens, and common mistakes.
Frontend TypeScript: Connecting to Your Backend
The frontend half of a full-stack TypeScript app is where shared types pay off most visibly. When a component renders user data, the compiler knows exactly what fields are available.
Data Fetching with Type Safety
Using the shared User type in a React component:
import { User } from "@yourapp/types";
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Failed to fetch user");
return res.json() as Promise<User>;
}
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <p>Loading...</p>;
return <p>{user.name} — {user.role}</p>;
}
The user.name and user.role accesses are type-checked. If the backend renames a field, this component breaks at compile time.
Using React Query or SWR
For real applications, raw useEffect for data fetching gets messy quickly. React Query (now TanStack Query) is the standard:
import { useQuery } from "@tanstack/react-query";
import { User } from "@yourapp/types";
function useUser(id: string) {
return useQuery<User>({
queryKey: ["user", id],
queryFn: () => fetch(`/api/users/${id}`).then(res => res.json()),
});
}
The generic <User> tells React Query what type to expect. The data property is typed as User | undefined automatically.
Environment Variables and Configuration
TypeScript doesn’t type-check process.env by default — those values are all string | undefined. For a production app, this matters.
Use a typed config module:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const env = envSchema.parse(process.env);
If a required variable is missing or malformed, the app crashes at startup with a clear error — not halfway through a request handler.
Testing TypeScript Full-Stack Apps
TypeScript gives you compile-time confidence. Tests give you runtime confidence. Both are necessary.
Unit Tests with Vitest or Jest
Vitest is the modern choice for TypeScript projects. It supports TypeScript natively without additional config.
import { describe, it, expect } from "vitest";
import { createUser } from "./user.service";
describe("createUser", () => {
it("should create a user with the correct role", async () => {
const user = await createUser({
email: "test@example.com",
name: "Test User",
role: "member",
});
expect(user.role).toBe("member");
});
});
Since your service functions are typed, the test inputs are too. You can’t accidentally pass the wrong shape.
Integration Tests
For API routes, use supertest with your Express app:
import request from "supertest";
import { app } from "./app";
it("POST /users returns 400 for invalid email", async () => {
const res = await request(app)
.post("/users")
.send({ email: "not-an-email", name: "Test" });
expect(res.status).toBe(400);
});
The combination of unit tests for logic and integration tests for API contracts gives you high confidence before deployment.
Deploying a TypeScript Full-Stack App
TypeScript doesn’t run in Node.js directly — you compile it to JavaScript first. Your deployment process needs to account for this.
Build Step
// package.json
{
"scripts": {
"build": "tsc --outDir dist",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts"
}
}
In production, npm run build then npm start. In development, ts-node or tsx runs TypeScript files directly.
Where to Deploy
For most full-stack TypeScript apps:
- Railway or Render — good defaults, simple deploys, handles Node.js well
- Fly.io — more control, great for containerized apps
- Vercel — excellent for Next.js, serverless functions for the backend
- AWS / GCP / Azure — when you need enterprise infrastructure
If you’re figuring out how to choose a backend for your app, infrastructure choices depend a lot on scale, budget, and what your team already knows. For most early-stage projects, Railway or Render is enough.
The beginner’s guide to deploying a web app covers the deployment side in more detail.
Where Remy Fits Into This
Full-stack TypeScript is powerful, but setting it up from scratch is work. You need to configure the monorepo, write shared types, wire up the database, set up auth, add Zod validation, configure deployment — before you write a single feature.
Remy is a different starting point. Instead of setting up infrastructure and then writing features, you describe your application in a spec — annotated markdown that carries the precision of types, edge cases, and rules — and Remy compiles it into a full-stack app. The output is real TypeScript: typed backend methods, SQL database, auth with real sessions, a working frontend.
The spec is the source of truth. The TypeScript is the compiled output.
This is especially useful when you want the rigor of a properly typed full-stack app without the setup overhead. If you’re building a SaaS app from idea to launch, starting from a spec document rather than a blank project means your architecture is defined before any code exists — and the code follows from that definition.
The result is also something you can read, edit, and extend. It’s not a black-box output. The generated TypeScript follows the same patterns covered in this guide: shared types, typed API contracts, Zod validation, Prisma for the database. You can take it and run.
You can try Remy at mindstudio.ai/remy.
Common Mistakes in Full-Stack TypeScript Projects
A few patterns that cause problems and how to avoid them:
Using any to Escape Type Errors
any defeats the purpose. If you’re tempted to use it, that’s usually a signal to define the type properly. Use unknown when the shape is genuinely unknown, then narrow it with a type guard or Zod validation.
Not Sharing Types Between Frontend and Backend
If your frontend defines its own version of the User type separately from the backend’s version, they will eventually diverge. Share types. One source of truth.
Skipping Runtime Validation
TypeScript types don’t exist at runtime. A typed function signature tells you what you intended — it doesn’t prevent a bad request from coming in with the wrong shape. Always validate incoming data at your API boundary.
Ignoring strictNullChecks
Enable strict mode in your tsconfig.json. The main one that matters is strictNullChecks: true. Without it, null and undefined sneak through and cause runtime errors that TypeScript should have caught.
{
"compilerOptions": {
"strict": true
}
}
"strict": true enables strictNullChecks and several other useful checks at once.
Frequently Asked Questions
Is TypeScript necessary for full-stack development?
No — but it makes a specific class of bugs nearly impossible. The bugs TypeScript eliminates are the kind that only show up at runtime: wrong field names, missing properties, type mismatches between frontend and backend. If your app is small and you’re the only developer, the overhead of TypeScript setup might not be worth it. For anything that will grow or be maintained over time, TypeScript pays back the initial cost quickly.
Can I use TypeScript with any backend framework?
Yes. TypeScript works with Express, Fastify, Koa, Hapi, NestJS, and others. It also works natively with Next.js server actions and API routes. The TypeScript support varies — Fastify and NestJS have excellent TypeScript integration out of the box, Express requires some manual type augmentation.
What’s the best database ORM for TypeScript?
Prisma is the most popular choice and has the best developer experience for most projects. Drizzle is a strong alternative for teams that want lighter abstraction and closer-to-SQL syntax. If you’re deciding between managed database services for either one, the comparison between Supabase and Firebase is useful context.
How do I share types between frontend and backend without a monorepo?
You can publish a private npm package containing your shared types and install it in both projects. This adds a publish step to your workflow but works well for separate repositories. Alternatively, a simple shared directory with symlinks works for smaller projects. The monorepo approach with pnpm workspaces is usually the cleanest option when starting fresh.
What is tRPC and when should I use it?
tRPC is a library that lets you call backend functions from your frontend with full end-to-end type safety, without a REST API or code generation step. It’s excellent for applications where the frontend and backend are in the same codebase or monorepo and controlled by the same team. It’s less suited to public APIs consumed by third parties, where a standard REST or GraphQL interface is preferable.
How does TypeScript work with AI coding tools?
TypeScript is especially well-suited to AI-assisted development because the types serve as machine-readable documentation. AI code editors like Cursor, Windsurf, and GitHub Copilot all have strong TypeScript support and can infer more from typed code than from untyped JavaScript. The type system constrains what the AI can suggest, which tends to produce more accurate completions.
Key Takeaways
- Define your core data types once in a shared package and import them in both your frontend and backend. This is the single highest-leverage thing you can do in a full-stack TypeScript project.
- Use Zod for runtime validation at your API boundary. TypeScript types exist at compile time; Zod enforces them at runtime.
- Prisma or Drizzle give you a typed database layer. Raw queries return
any— don’t use them for complex queries unless you have a good reason. - Enable
"strict": truein yourtsconfig.json. The extra friction upfront prevents a category of bugs at scale. - For end-to-end type safety without a REST layer, tRPC is worth evaluating — especially for internal tools and dashboards.
- Deployment is straightforward: compile TypeScript to JavaScript with
tsc, then run the output with Node.js. Most deployment platforms handle this well.
If you want to skip the setup and get to a fully typed, full-stack TypeScript app with backend, database, auth, and deployment already wired up, try Remy.