Stop rebuilding the same thing for every project.
Every new project starts with the same setup work:
This isn’t creative work. It’s repetitive work that eats into the time you could spend on features that actually differentiate your product.
The cost: A typical full-stack project spends 30-40% of initial development time on foundational infrastructure that’s nearly identical to the last project.
Reusable components work at two levels:
Both follow the same principle: solve the problem once, reuse it everywhere.
Here’s a real example. Every web app needs a standard setup: CloudFront distribution, S3 bucket for static assets, proper caching headers, and HTTPS.
Instead of writing this for every project:
// Without reusable components: 80+ lines per project
const bucket = new s3.Bucket(this, "Frontend", {
// ... 15 lines of config
});
const distribution = new cloudfront.Distribution(this, "CDN", {
// ... 50+ lines of behaviors, origins, caching
});
const deployment = new s3deploy.BucketDeployment(this, "Deploy", {
// ... 15 lines of deployment config
});
Create a reusable construct:
// packages/constructs/src/static-site.ts
export class StaticSite extends Construct {
public readonly bucket: s3.Bucket;
public readonly distribution: cloudfront.Distribution;
constructor(scope: Construct, id: string, props: StaticSiteProps) {
super(scope, id);
this.bucket = new s3.Bucket(this, "Bucket", {
removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
});
this.distribution = new cloudfront.Distribution(this, "Distribution", {
defaultBehavior: {
origin: new origins.S3Origin(this.bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
domainNames: props.domainName ? [props.domainName] : undefined,
certificate: props.certificate,
defaultRootObject: "index.html",
errorResponses: [
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: "/index.html", // SPA routing
},
],
});
}
}
Now every project gets a production-ready static site in 3 lines:
const site = new StaticSite(this, "Frontend", {
domainName: "app.example.com",
certificate: cert,
});
Time saved per project: 2-4 hours of infrastructure work, plus avoided debugging of edge cases you’ve already solved.
Individual components combine into full application patterns:
export class WebAppStack extends Stack {
constructor(scope: Construct, id: string, props: WebAppProps) {
super(scope, id, props);
// Reusable components snap together
const database = new PostgresDatabase(this, "Database", {
instanceSize: props.instanceSize,
vpc: props.vpc,
});
const api = new FargateApi(this, "Api", {
vpc: props.vpc,
database: database,
image: props.apiImage,
});
const frontend = new StaticSite(this, "Frontend", {
domainName: props.domainName,
certificate: props.certificate,
});
// Wire them together
new ApiGatewayIntegration(this, "Gateway", {
api: api,
frontend: frontend,
});
}
}
A complete web application infrastructure in 25 lines instead of 500.
The same principle applies to frontend code. Instead of styling buttons differently in every project:
// components/Button.tsx
interface ButtonProps {
variant: "primary" | "secondary" | "danger";
size: "sm" | "md" | "lg";
loading?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export function Button({ variant, size, loading, children, onClick }: ButtonProps) {
return (
<button
className={clsx(
"rounded font-medium transition-colors",
variants[variant],
sizes[size],
loading && "opacity-50 cursor-not-allowed"
)}
onClick={onClick}
disabled={loading}
>
{loading ? <Spinner size={size} /> : children}
</button>
);
}
This button handles:
Build it once, use it everywhere. No more inconsistent buttons across pages.
Not everything should be a reusable component. Good candidates:
| Good for Reuse | Bad for Reuse |
|---|---|
| Authentication flows | Business-specific logic |
| Database setup | One-off integrations |
| Common UI patterns | Highly customized features |
| CI/CD pipelines | Project-specific workflows |
| Error handling | Domain models |
The rule: If you’ve built it twice, it’s a candidate for extraction. If it requires heavy customization per use, keep it project-specific.
For infrastructure components, publish to a private npm registry:
{
"name": "@yourorg/constructs",
"version": "1.2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
Projects pin to specific versions:
{
"dependencies": {
"@yourorg/constructs": "^1.2.0"
}
}
When you improve a component (better defaults, bug fixes, new features), all projects benefit on their next update.
The real value isn’t in any single component. It’s in the compound effect over time:
| Project | Without Library | With Library | Savings |
|---|---|---|---|
| Project 1 | 40 hours | 40 hours (building library) | 0 |
| Project 2 | 40 hours | 8 hours | 32 hours |
| Project 3 | 40 hours | 6 hours | 34 hours |
| Project 4 | 40 hours | 4 hours | 36 hours |
| Total | 160 hours | 58 hours | 64% |
By project 4, infrastructure setup takes a morning instead of a week.
If you don’t have a component library yet:
Don’t try to build a comprehensive library upfront. Let it grow from actual needs.
Start small. One well-designed component beats ten half-baked ones.
Document the why. Future you (or your team) needs to know why decisions were made.
Version aggressively. Breaking changes happen. Semantic versioning prevents surprises.
Test the components, not just the projects. A bug in a shared component affects everything.
Don’t over-abstract early. Wait until you’ve used something three times before generalizing.
Building reusable components is an investment. The first project takes longer. Every project after that is faster, more consistent, and less error-prone.